Тестирование приложений в условиях нехватки памяти

в 15:51, , рубрики: C, c++, error handling, oom, software testing, Программирование, Тестирование IT-систем

Вопрос о том надо ли проверять то, что возвращает malloc является спорным и всегда порождает жаркие дискуссии.

Часть людей считает, что надо пытаться обрабатывать все виды runtime ошибок, в т.ч. и OOM ситуации. Другие считают, что с OOM всё равно мало что можно сделать и лучше дать приложению просто упасть. На стороне второй группы людей ещё и тот факт, что дополнительная логика обработки OOM с трудом поддаётся тестированию. А если код не тестируется, то почти наверняка он не работает.

Я полностью согласен с тем, что не стоит реализовывать логику обработки ошибок которую вы не собираетесь тестировать. Почти наверняка она ничего не улучшит, а может и того хуже — всё испортит.

Вопрос о том надо ли пытаться обрабатывать OOM ситуации в библиотеках/приложениях является противоречивым и мы не будем его здесь касаться. В рамках данной публикации я лишь хочу поделиться опытом того как можно тестировать реализованную логику обработки OOM ситуаций в приложениях написанных на C/C++. Разговор будет идти об операционных системах Linux и macOS. Ввиду ряда причин, Windows будет обойдён стороной.

Введение

Все бы мы хотели чтобы OOM не случался никогда, однако в реальной жизни это не всегда возможно ввиду следующих причин:

  • Объем RAM всегда ограничен.
  • SWAP не всегда включен.
  • Приложения не всегда ведут себя адекватно и порой пытаются выделить нереально большие объёмы памяти мешая себе и другим.
  • 32-битные приложения всё ещё существуют.
  • overcommit не всегда включен.
  • Потребление памяти могут ограничить с помощью ulimit, например.
  • Через тот же LD_PRELOAD приложению могут выдать специфический аллокатор который может просто не выдавать памяти сверх назначенного предела.

Если вы дочитали до этого места, то будем считать, что вы согласны с тем, что обрабатывать OOM важно и нужно. Почему это важно для себя каждый решает сам, со своей стороны могу кратко сказать, что ко многим компонентам надо которыми работал я предъявлялись следующие требования:

  • Компонент никогда не должен падать, приложения его использующие не должны падать из-за ошибок в таких компонентах.
  • При возникновении OOM ситуаций приложение должно уметь завершить свою работу не падая. Данные над которыми работает ПО не должны повреждаться даже в случае нехватки памяти.
  • При временной нехватке памяти ПО должно переходить в режим пониженного потребления ресурсов и при возможности продолжать выполнять свои функции пусть даже с низкой производительностью. Если памяти начинает снова хватать, приложение должно выходить на нормальный режим работы.

Одним из примеров библиотек, которые обрабатывают все виды ошибок, является SQLite. Как её не ломай, она не ломается. Внутренняя логика не допускает того чтобы ваша база данных превратилась в тыкву при возникновении нехватки ресурсов. SQLite обрабатывает ошибки и так же не допускает чтобы в ходе обработки этих ошибок возникли какие либо утечки ресурсов.

Сразу скажем, что, к сожалению, порой мы бессильны и не можем гарантировать выполнение данных требований. К нам может прийти OOM Killer, от него можно частично защитится, но не всегда. Ещё одним сюрпризом может оказаться то, что вам не всегда может удаться бросить объект исключения в C++, об этом мы расскажем чуть позже.

Шаг 1. Наивный подход или лучше чем ничего

На первых шагах может показаться, что создать приложению OOM ситуации для целей тестирования легко. Можно просто создать пару функций my_malloc и my_free и везде в коде использовать их вместо нативных malloc и free.

К слову переопределение my_free опционально. И так же не стоит забывать про потенциальную необходимость реализации my_realloc.

В нормальных условиях my_malloc будет просто оборачивать malloc не внося никакой логики. Для целей тестирования в my_malloc можно заложить некоторую логику, которая будет приводить к возвращению NULL в определённых условиях.

Не будем вдаваться в детали, а сразу скажем почему данный подход недостаточно хорош:

  • Нет никакой возможность покрыть тестами 3rd party код не изменяя его.
  • Многие библиотечные функции используют malloc под капотом и будут продолжать вызывать его напрямую. Одним из широко-используемых примеров таких функций является strdup.
  • Оборачивание malloc’а порождает дополнительные накладные расходы, в большинстве случаев они будут незначительны, но всё же не равны нулю.
  • Данный подход слабо подходит для тестирования C++ кода в котором редко происходят явные вызовы malloc и free.

Не смотря на все недостатки наивного подхода в некоторых случаях он вполне жизнеспособен и даже используется кое-где на практике.

Шаг 2. Метод грубой силы

Те кто тесно работают с Linux наверняка в курсе что такое LD_PRELOAD. С помощью данной переменной окружения можно принудительно заставить загрузить свою библиотеку вперёд других. С её помощью чаще всего и переопределяют поведение таких стандартных функций как malloc. Возможно это по причине того, что такие функции как malloc/realloc/free исторически являются слабыми (weak). Сразу скажем, что на macOS есть брат LD_PRELOAD, зовут его DYLD_INSERT_LIBRARIES.

И так, в целях тестирования, с помощью LD_PRELOAD и DYLD_INSERT_LIBRARIES можно подменить стандартные malloc/realloc своими реализациями которые и помогут нам возвращать NULL когда нам этого нужно.

Дело за малым, осталось найти библиотеку которая позволяла бы "заваливать" некоторые аллокации памяти. Честно скажем, готовые решения мы сильно не искали ибо требовалась возможность гибкой кастомизации которую бы вряд ли бы кто предоставил.

Опять же, если просто "заваливать" случайным образом все аллокации, не беря в расчёт контекст, мы мало чего сможем добиться. Суровая реальность преподносит следующие сюрпризы:

  • Зачастую приложение может упасть даже не дойдя до выполнения функции main. С этим сделать мы ничего не можем, соответственно и симулировать подобные ситуации нет никакого смысла.
  • Runtime библиотеки на macOS уж очень не стабильны и норовят упасть при каждом "удобном случае". Опять же за чужой код мы не отвечаем, если мы знаем, что он хрупкий, то не стоит пытаться его уронить.
  • Даже вызов printf на macOS может привести к SIGSEGV/SIGBUS.
  • Чтобы бросить исключение, например std::bad_alloc, необходимо выделить память под объект исключения. И, внезапно, память под объект исключения тоже может быть не выделена, если мы сталкиваемся с OOM. В этих ситуациях приложение просто получает std::terminate. Данная ситуация также малоинтересна и её не стоит допускать в процессе тестирования.
  • Создание потока с помощью std::thread в условиях нехватки памяти может привести к std::terminate на macOS.

UPDATE: По результатам тестирования с помощью Travis CI можно сказать, что с новыми версиями macOS / Xcode ситуация улучшилась, бросать std::bad_alloc можно даже когда кончилась память, создание потока с помощью std::thread больше не приводит к std::terminate.

Для того чтобы принять в расчёт все известные факторы суровой реальности и была написана библиотека Overthrower. Она позволяет несколькими псевдо-случайными способами заставлять malloc возвращать NULL. Одновременно с этим Overthrower позволяет не допускать падений из-за кода, который нам неподвластен.

Шаг 3. Берём в расчёт суровую реальность

Гарантируем возможность дойти до main

Ни для кого не секрет, что до того момента как ваше приложение доходит до выполнения main происходит много различных вещей, по пути до main runtime выполняет различные инициализации в ходе которых требуется выделять память. Если приложение упало до момента захода в main, нам это мало интересно и мы просто хотим исключить такие ситуации из рассмотрения, т.к. нет никакой возможности хоть как-то отреагировать на проблемы которые происходят до вызова main.

К слову, когда мы покидаем main мы тоже мало на что можем повлиять. То есть библиотека Overthrower, которая будет синтетически создавать OOM ситуации должна иметь возможность отложенного запуска и раннего останова. Необходим некий способ который позволит проинформировать библиотеку Overthrower о том, когда ей стоит начать работать и когда стоит прекратить.

Самым простым способом оказалось создать пару функций которые будут видны тестируемому приложению:

  • activateOverthrower
  • deactivateOverthrower

Чтобы иметь возможность увидеть эти функции в тестируемое приложение необходимо добавить следующее:

#ifdef __cplusplus
extern "C" {
#endif
void activateOverthrower() __attribute__((weak));
unsigned int deactivateOverthrower() __attribute__((weak));
#ifdef __cplusplus
}
#endif

В исходных кодах библиотеки есть заголовочный файл с объявлениями этих функций.

Если библиотека Overthrower не подгружена с помощью механизма LD_PRELOAD, эти функции будут указывать в NULL, очевидно, что пытаться вызывать их в данном случае не стоит.

Итак, в самом простом случае, протестировать некий код на предмет устойчивости можно следующим образом:

int main(int argc, char** argv)
{
    activateOverthrower();
    // Some code we want to test ...
    deactivateOverthrower();
}

На практике вызывать activateOverthrower/deactivateOverthrower можно где угодно и сколько угодно раз, на практике это можно делать в модульных тестах следующим образом:

TEST(Foo, Bar)
{
    activateOverthrower();
    // Some code we want to test ...
    deactivateOverthrower();
}

Иногда, например в случаях когда нам нужно вызвать что-то, что точно упадёт, может понадобиться поставить Overthrower на паузу, для это есть пара других функций:

#ifdef __cplusplus
extern "C" {
#endif
void pauseOverthrower(unsigned int duration) __attribute__((weak));
void resumeOverthrower() __attribute__((weak));
#ifdef __cplusplus
}
#endif

Использовать это можно следующим образом:

TEST(Foo, Bar)
{
    activateOverthrower();
    // Some code we want to test ...
    pauseOverthrower(0);
    // Some fragile code we can not fix ...
    resumeOverthrower();
    // Some code we want to test ...
    deactivateOverthrower();
}

Реальные примеры использования библиотеки Overthrower можно найти в тестах самой библиотеки.

Гарантируем возможность бросить исключение

Для выделения памяти под объект исключения используется функция __cxa_allocate_exception, она, о сюрприз, может вызвать под капотом malloc, который может вернуть NULL. Тут стоит отметить, что на Linux, при невозможности выделить память с помощью malloc, __cxa_allocate_exception будет пытаться использовать запасной буфер (emergency buffer), данный буфер аллоцируется статически и в большинстве случаев позволяет без проблем бросать небольшие исключения даже тогда, когда память на куче закончилась совсем. Дополнительное подробности могут быть найдены тут.

На macOS никаких запасных буферов выявлено не было, вследствие этого, если память на куче закончилась, то при попытке бросить любое исключение, в том числе и std::bad_alloc, работа приложения будет аварийно завершена с помощью std::terminate.

UPDATE: Как уже было сказано ранее, новые версии macOS / Xcode не имеют этой проблемы.

Чтобы гарантировать возможность тестирования кода, который пишем мы, нам не остаётся ничего иного как гарантировать то, что __cxa_allocate_exception всегда сможет выделить память под объект исключения с помощью вызова malloc. Из-за этого Overthrower’у приходится немного анализировать стек вызовов на каждый вызов malloc. Overthrower никогда не заваливает malloc который приходит из __cxa_allocate_exception.

Также, некоторые широко используемые функции славятся тем, что никогда явно не освобождают за собой память, например на macOS этим славится __cxa_atexit, на Linux dlerror. Есть и другие примеры.

Overthrower ожидает, что всё что выделяется с помощью malloc будет освобождено с помощью free. При вызове упомянутых выше функций Overthrower’у может показаться, что в коде вызываемом между activateOverthrower и deactivateOverthrower есть утечки, о чём он обязательно пожалуется:

overthrower got deactivation signal.
overthrower will not fail allocations anymore.
overthrower has detected not freed memory blocks with following addresses:
0x0000000000dd1e70  -       2  -         128
0x0000000000dd1de0  -       1  -         128
0x0000000000dd1030  -       0  -         128
^^^^^^^^^^^^^^^^^^  |  ^^^^^^  |  ^^^^^^^^^^
      pointer       |  malloc  |  block size
                    |invocation|
                    |  number  |

Overthrower в курсе, что некоторые системные функции не освобождают за собой память и не ожидает, что память выделяемая ими будет освобождена.

Поиск утечек памяти не есть основная функция Overthrower’а, для этого есть инструменты получше, такие как valgrind. Однако, эти инструменты не могут быть использованы при запуске приложения в условиях OOM. Поскольку мы требуем, что в приложении не возникает утечек ни при каких обстоятельствах, мы используем Overthrower в том числе и для поиска возможных утечек памяти. Если Overthrower считает, что он нашёл утечки памяти, функция deactivateOverthrower вернёт количество не освобождённых блоков памяти, а в stderr будет выведен краткий отчёт.

Выбор стратегии заваливания

Overthrower имеет 3 стратегии для заваливания аллокаций:

  • Random — заваливает аллокации для которых rand() % duty_cycle == 0. Параметр duty_cycle, можно установить в желаемое значение.
  • Step — начинает заваливать все аллокации после достижения указанного момента (malloc_seq_num >= delay), delay регулируется по желанию.

<--- delay --->
--------------+
              |
              | All further allocations fail
              |
              +------------------------------

  • Pulse — заваливает указанное количество аллокаций после некоторой задержки (malloc_seq_num > delay && malloc_seq_num <= delay + duration), параметры delay и duration могут быть настроены согласно требованиям.

<--- delay --->
--------------+                +------------------------------
              |                |
              |                | All further allocations pass
              |                |
              +----------------+
              <--- duration --->

Выбор стратегии и её настройка выполняются с помощью переменных окружения:

  • OVERTHROWER_STRATEGY
  • OVERTHROWER_SEED
  • OVERTHROWER_DUTY_CYCLE
  • OVERTHROWER_DELAY
  • OVERTHROWER_DURATION

Переменные окружение можно выставить в любой момент до вызова activateOverthrower. Если переменные окружения не заданы, Overthrower выбирает стратегию и её параметры случайным образом, в качестве источника случайных данных используется устройство /dev/urandom.

В некотором виде возможные способы тонкой настройки описаны в файле README.md.

Заключение

  • Overthrower позволяет завалить любой вызов malloc по отдельности и проверить как это обрабатывает тестируемое приложение/библиотека.
  • Можно тестировать отдельные части приложения по отдельности многократно задавая разные параметры заваливания.
  • Overthrower можно использовать для тестирования кода написанного на голом Си.
  • Хрупкие фрагменты кода можно защитить от падения поставив Overthrower на паузу.
  • Overthrower замедляет работу приложения, но не катастрофически.
  • Побочным эффектом использования Overthrower’а является возможность обнаруживать утечки памяти которые могут возникнуть в том числе и вследствие неправильной обработки ошибочных ситуаций.
  • Для того чтобы интегрировать Overthrower в процесс тестирования необходимо писать Overthrower-aware тесты. В принципе, сложного в этом ничего нет.
  • Сам Overthrower тестируется на Ubuntu (начиная с версии 14.04) и macOS (начиная с Sierra (10.12) и Xcode 8.3). В процессе тестирования Overthrower пытается в том числе уронить сам себя.
  • Если в системе возникает реальный OOM, Overthrower делает всё чтобы не упасть самому.

Автор: Александр Кутелев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js