Manifold Geometry // Многообразная Геометрия

Архивы

Пролетарский способ мониторинга кучи

Современные разработчики зачастую не сильно заморочены такими скучными вопросами, как доступные системные ресурсы, например, память. Это, конечно, неудивительно, поскольку вместо привычных в середине девяностых шестнадцати мегабайт, мы имеем теперь 16 гигабайт, и это далеко не предел. В результате, программист перестает заботиться о памяти до тех пор, пока его блаженное неведение не споткнется о грубые реалии конкретного железа, либо среды виртуализации. Простой пример — запуск набора тестов в Docker-контейнере с ограничением памяти в 3ГиБ (привет AWS). В отсутствие менеджера аллокаций и деаллокаций, попытка динамически выделить блок памяти свыше отведенного предела уронит ваше приложение без какой-либо внятной диагностики.

Простых решений здесь нет, есть только интрузивные. Самое печальное в этой истории — это внешние зависимости продукта, где происходит выделение памяти без вашего ведома и, уж и подавно, вашего на то согласия. Роскошь написать свое маленькое приложение на языке Си с уютной memory arena внутри доступна далеко (далеко!) не всем. Куда чаще программный продукт — это винегрет динамически и (реже) статически линкуемых библиотек, разрабатываемых и поддерживаемых отнюдь не вами. Но «жить-то как-то надо», это да. Увы, этот пост не содержит чудотворных рецептов и всякой занимательной алхимии, поскольку на вопрос «а как бы мне ограничить аппетит моего монструозного приложения, и чтоб всё работало?», повторюсь, нет простых ответов.

Предположим, у нас есть текстовая консоль для запуска алгоритма и библиотека с самим алгоритмом. Такие вещи принято разделять, поэтому, скорее всего, алгоритм и тестовая среда упакованы в разные динамические библиотеки. Забудем пока о внешних зависимостях, и попробуем отследить динамику памяти внутри собственного кода. Для наглядности хорошо иметь график, где горизонтальная ось отвечает некоторому месту в логике кода, а по вертикали изображается память, потребляемая процессом в данном месте. Для составления отчета о потреблении памяти можно применить следующую нехитрую тактику:

  1. Распределить в коде приложения «точки контроля» как функции, запрашивающие у операционной системы, сколько памяти в данный момент потребило приложение.
  2. Сохранить замеры где-нибудь в статической памяти, доступной через singleton из любого места в коде.
  3. Сбросить результаты измерений в файл и построить график.

Введем структуру для хранения результатов замера в определенный момент времени:

//! A single checkpoint for memory tracking.
struct t_memCheckPoint
{
  std::string name; //!< Domain-specific name of a checkpoint (kept for future).
  int         mem;  //!< Memory consumed by the process at a checkpoint.
  
  t_memCheckPoint() : mem(0) {} //!< Default ctor.
  
  //! Ctor with initialization.
  t_memCheckPoint(const std::string& _name,
                  const int          _mem)
  : name(_name), mem(_mem) {}
};

Поле name хранит имя контрольной точки, например, путь к тестовому сценарию или просто человекопонятную метку, отсылающую к конкретному месту в коде. Целочисленное поле mem содержит объем потребляемой процессом памяти на момент замера в данной контрольной точке. Для отслеживания динамики будем помещать контрольные точки в вектор:

std::vector<t_memCheckPoint>

Чтобы узнать объем потребляемой в текущий момент памяти можно использовать функцию Windows GetProcessMemoryInfo(). Регистрацию замеров памяти удобно реализовать через статические переменные, доступ к которым открыт во всех динамических библиотеках продукта. Наивный способ, состоящий в объявлении статических переменных в общедоступном заголовочном файле, работать не будет. Вот так — нельзя:

// MyBrokenMemTracker.h
  
// A vector to store IDs of the checkpoints and memory allocated for the process.
// That's a bad way of tracking as you'll end up with as many instances of checkPoints
// as many dynamic libraries are loaded.
static std::vector<t_memCheckPoint> checkPoints;

При загрузке каждая динамическая библиотека проинициализирует свою копию переменной checkPoints, и общего хранилища для замеров мы не получим. Ровно то же самое справедливо для синглтонов, поскольку и они задействуют статическую память КАЖДОЙ библиотеки. Нам нужен «сквозной» синглтон, работающий через границы динамических библиотек: Using a singleton across DLL boundary. Подход со сквозным синглтоном состоит в том, чтобы не давать зависимым библиотекам ничего, кроме интерфейса (абстрактного класса) нашего трекера памяти. Реализация остается внутри корневой библиотеки, которая, в свою очередь, экспортирует функцию для возврата трекера во внешний мир. Внешние же библиотеки получают указатель на область статической памяти внутри корневой библиотеки. В общем-то это все прописные истины, но даже спустя 12 лет профессиональной работы программистом я умудрился истратить на этот жалкий синглтон около часа времени, позабыв все нюансы в работе со статической памятью.

Итак, интерфейс трекера:

// MemTracker.h
  
...
  
//! Singleton tracker.
class IMemTracker
{
public:
  virtual void AddCheckPoint     ()                            = 0;
  virtual void GetCheckPoint     (const int, t_memCheckPoint&) = 0;
  virtual int  GetNumCheckPoints () const                      = 0;
  virtual int  GetProcessMiB     ()                            = 0;
};
  
EXPORT_MACRO IMemTracker& GetTracker();

Функция GetTracker() экспортируется для использования вовне и реализуется в корневой библиотеке (ссылка на полный исходный код дана в конце заметки). В заголовочном файле MemTracker.h удобно иметь прагму для вывода предупреждения во время компиляции. Если трассировка памяти включена, огромное количество предупреждений неминуемо привлечет внимание программиста, чтобы тот не забыл убрать все диагностические вызовы перед отгрузкой исходного кода.

Предупреждения на этапе компиляции.

Введем волшебный макрос MEMCHECK, не принимающий никаких аргументов. Идея состоит в том, чтобы «рассыпать» этот макрос в наиболее подозрительных местах кода, например, там, где стартует или завершается алгоритм, либо происходит чтение внешних данных.

След приложения на запуске тестов с утечкой памяти.

Использование трекера выглядит следующим образом:

... Whatever code, whatever library
MEMCHECK
... Whatever code, whatever library
MEMCHECK
...
MEMCHECK_DUMP("C:/Users/JohnSmith/Desktop/memlog.txt")

Мы видим, что использование макроса MEMCHECK не предполагает явной инициализации: она произойдет в момент первого обращения к синглтону через функцию GetTracker() внутри макроса. Трекер ловит динамику памяти, но не пиковые значения. Это серьезная проблема, поскольку именно попытка выделить память в условиях жесткого ограничения ресурсов приведет к краху процесса.

След приложения на запуске тестов без утечки памяти. Суммарно память растет: этот эффект еще предстоит проанализировать разработчику.

Здесь мы приходим к важности библиотек с открытым исходным кодом. Простой пример: фасетер OpenCascade из пакета BRepMesh становится очень прожорливым до памяти при небрежном выборе параметра углового отклонения (angular deflection). Попытка построить триангуляцию для умеренно сложных моделей может потребовать нескольких гигабайт оперативной памяти, и эти пиковые значения мы не сможем «поймать», поскольку алгоритм не оснащен трекером. Решение простое: интегрируем трекер в корневую библиотеку TKernel и «рассыпаем» произвольным образом макрос MEMCHEK внутри исходного кода алгоритма BRepMesh. Эта работа не требует понимания логики алгоритма как такового. Что по-настоящему важно — иметь доступ к исходным кодам сторонней библиотеки. Это та роскошь, которой лишен разработчик, использующий закрытые продукты, такие как ACIS или Parasolid.

Добавлены замеры внутри алгоритмов из внешних библиотек. Пиковое значение уточняется.

Последнее, что стоит отметить — многопоточность. Во избежание «гонок» потоков в параллельных алгоритмах на записи в трекер достаточно использовать мьютекс, например, так:

//! Default implementation of the tracker interface.
class MemTracker : public IMemTracker
{
public:
  
  std::vector<t_memCheckPoint> checkPoints; //!< All collected CPs.
  Standard_Mutex               MUTEX;
  
  virtual void AddCheckPoint()
  {
    MUTEX.Lock();
    this->checkPoints.push_back( t_memCheckPoint( "", this->GetProcessMiB() ) );
    MUTEX.Unlock();
  }
  
  virtual void GetCheckPoint(const int id, t_memCheckPoint& info)
  {
    info = this->checkPoints[id];
  }
  
  virtual int GetNumCheckPoints() const
  {
    return int( this->checkPoints.size() );
  }
  
  virtual int GetProcessMiB()
  {
    MUTEX.Lock();
    PROCESS_MEMORY_COUNTERS PMC;
    GetProcessMemoryInfo( GetCurrentProcess(), &PMC, sizeof(PMC) );
    SIZE_T physUsedBytes = PMC.WorkingSetSize;
    const int MiB = (int) ( physUsedBytes / (1024 * 1024) );
    MUTEX.Unlock();
    return MiB;
  }
};
Пиковые значения в алгоритме BRepMesh.

Исходные коды: