
Мне нравится экспериментировать с кастомными аллокаторами памяти, используя собственные реализации. И хотя модульные тесты помогают убедиться в их корректности, настоящей проверкой становится работа аллокатора в реальных программах.
Коротко о теме статьи. Стандартная библиотека C++ инициализирует механизм обработки исключений на раннем этапе, выделяя память для «резервного пула», чтобы можно было использовать её под выброс исключений, если malloc вдруг провалится.
Содержание
Введение
В Linux переопределить штатную malloc на удивление просто — достаточно обернуть стандартные функции (например, malloc, calloc, realloc, free и утилиты вроде malloc_usable_size), скомпилировать свою реализацию в общую библиотеку и использовать LD_PRELOAD, чтобы программы сначала загружали именно её. Протестировать же работу своего аллокатора можно простой командой:
LD_PRELOAD=/home/joel/mymalloc/libmymalloc.so ls
Чтобы лучше понять механизм аллокации, я создал инструмент отладки, который при каждом вызове malloc записывает размер аллоцируемой памяти в файл. Но при создании такого инструмента нужно быть осторожным, а именно не использовать внутри него самого malloc при логировании вывода. В противном случае вы получите бесконечный цикл и сбой программы. Чтобы этого избежать, я для безопасного перехвата данных использовал выделяемый на стеке буфер и низкоуровневые функции creat, write и snprintf.
$ LOG_ALLOC=log.txt LD_PRELOAD=/home/joel/mymalloc/libmymalloc.so ls
Загадка 72 КБ
Анализируя паттерны аллокации в разных программах, я заметил кое-что необычное. При первом вызове всегда выделяется 73 728 (72 КБ). И так во всех протестированных программах, что подтверждается логами:
$ head -n 1 log.txt 73728
Чтобы отследить, откуда происходит этот первый вызов, я использовал gdb, установив точку останова на своей malloc для просмотра стека.
Примечание. Если установить точку останова на символе «malloc», тогда сработает не только моя
malloc, но и внутренняяmallocдинамического линкера (RTLD), поэтому нужно конкретизировать. RTLD использует собственную миниатюрную реализациюmallocдля выделения памяти ещё до загрузки libc (или моейmalloc). Рекомендую заглянуть в файл elf/dl-minimal-malloc.c библиотеки glibc, там всё довольно понятно.
$ gdb --args ls ... (gdb) set environment LD_PRELOAD=/home/joel/mymalloc/libmymalloc.so (gdb) b MallocWrapper.cpp:malloc ... (gdb) r Starting program: /usr/bin/ls Breakpoint 1, malloc (size=73728) at src/MallocWrapper.cpp:44 ... (gdb) bt #0 malloc (size=73728) at src/MallocWrapper.cpp:44 #1 0x00007ffff78bd17f in ?? () from /lib/x86_64-linux-gnu/libstdc++.so.6 #2 0x00007ffff7fca71f in call_init (l=<optimized out>, argc=argc@entry=1, argv=argv@entry=0x7fffffffdce8, env=env@entry=0x7fffffffdcf8) at ./elf/dl-init.c:74 #3 0x00007ffff7fca824 in call_init (env=<optimized out>, argv=<optimized out>, argc=<optimized out>, l=<optimized out>) at ./elf/dl-init.c:120 #4 dlinit (main_map=0x7ffff7ffe2e0, argc=1, argv=0x7fffffffdce8, env=0x7fffffffdcf8) at ./elf/dl-init.c:121 #5 0x00007ffff7fe45a0 in dlstart_user () from /lib64/ld-linux-x86-64.so.2 # ...
Углубление в libstdc++
Стек вызовов показал, что запрос первых 72 КБ происходит из libstdc++. И хотя добавление отладочных символов помогает сузить диапазон поиска, из-за инлайнинга сложно найти конкретную функцию, отвечающую за эту аллокацию. Известно лишь, что malloc вызывается где-то в цепочке после __pool_alloc_base::_M_allocate_chunk:
(gdb) info sharedlibrary From To Syms Read Shared Object Library ... 0x00007ffff78aa600 0x00007ffff79eef42 Yes (*) /lib/x86_64-linux-gnu/libstdc++.so.6 ... (*): Shared library is missing debugging information. (gdb) add-symbol-file /lib/x86_64-linux-gnu/debug/libstdc++.so.6 0x00007ffff78aa600 ... Reading symbols from /lib/x86_64-linux-gnu/debug/libstdc++.so.6... (gdb) bt #0 malloc (size=73728) at src/MallocWrapper.cpp:44 #1 0x00007ffff78bd17f in __gnu_cxx::__pool_alloc_base::_M_allocate_chunk (this=0x7ffff79ef08d <std::filesystem::filesystem_error::_Impl::~_Impl()+3>, n=8, nobjs=<error reading variable: Cannot access memory at address 0x0>) # at ../../../../../../src/libstdc++-v3/src/c++98/pool_allocator.cc:114 # ...
Найти конкретного вызывающего было непросто, но мне удалось сузить круг подозреваемых, сопоставляя известные функции в коде ассемблера с исходным кодом libstdc++. Это расследование привело меня к libstdc++-v3/libsupc++/eh_alloc.cc, где eh означает «exception handling». Вполне логично, так как _M_allocate_chunk наверняка является первой точкой, где может быть выброшено исключение, поэтому механизм обработки исключений должен быть инициализирован (происходит это, похоже, по ленивому принципу).
libstdc++-v3/src/c++98/pool_allocator.cc (упрощённая версия):
char* __pool_alloc_base::_M_allocate_chunk(size_t n, int& nobjs) { // ... __try { Sstart_free = static_cast<char*>(::operator new(__bytes_to_get)); } __catch(const std::bad_alloc&) { /* ... */ } // ... }
Механизм обработки исключений (резервный пул)
Наблюдаемая аллокация 72 КБ это выделение памяти для так называемого «резервного пула», который создаётся в конструкторе пула:
libstdc++-v3/libsupc++/eh_alloc.cc (упрощённая версия):
pool::pool() noexcept { // ... arena_size = buffer_size_in_bytes(obj_count, obj_size); if (arena_size == 0) return; arena = (char *)malloc (arena_size); if (!arena) { // Если аллокация провалилась, продолжает без резервного пула. arena_size = 0; return; } // Заполняет свободный список одной записью, описывающей всю арену. first_free_entry = reinterpret_cast <free_entry *> (arena); new (first_free_entry) free_entry; first_free_entry->size = arena_size; first_free_entry->next = NULL; }
Обычно под исключения память выделяется непосредственно через malloc, но если её вызов проваливается, используется резервный пул. За счёт этого сохраняется возможность выброса исключений (пока не будет исчерпан пул), выступающая последним рубежом защиты при обработке ошибок. Резервный пул аллоцируется «лениво» при запуске программы, так как в этот момент вероятность наличия достаточной памяти выше. Это вполне объясняет то последовательное выделение, которое мы наблюдаем.
libstdc++-v3/libsupc++/eh_alloc.cc (упрощённая версия):
extern "C" void * cxxabiv1::cxa_allocate_exception(std::size_t thrown_size) noexcept { // .. void *ret = malloc (thrown_size); #if USE_POOL if (!ret) ret = emergency_pool.allocate (thrown_size); #endif // ... }
Как определяется размер пула и почему 72 КБ?
Если заглянуть в исходный файл, то мы найдём краткое объяснение того, как вычисляется размер резервного пула. Расчёт размера объекта и количества объектов опирается на размер слова, который в системах x64 составляет 8 байт.
libstdc++-v3/libsupc++/eh_alloc.cc:
// Размер буфера составляет N (S P + R + D), где: // N == количество объектов, для которых нужно зарезервировать пространство. // По умолчанию задаётся EMERGENCY_OBJ_COUNT, определённым ниже. // S == оценочный размер объектов исключения, который нужно учесть. // Указывается в единицах sizeof(void*), а не байтах. // По умолчанию задаётся EMERGENCY_OBJ_SIZE, определённым ниже. // P == sizeof(void*). // R == sizeof(__cxa_refcounted_exception). // D == sizeof(__cxa_dependent_exception). // ... #define EMERGENCY_OBJ_SIZE 6 #define EMERGENCY_OBJ_COUNT (4 __SIZEOF_POINTER__ __SIZEOF_POINTER__)
Размер объекта (obj_size) и число объектов (obj_count) можно настраивать вручную через переменную среды GLIBCXX_TUNABLES. В том, что изначальная аллокация действительно происходит для резервного пула, можно убедиться, уменьшив в нём количество объектов. В результате изначальный размер аллокации тоже ожидаемо уменьшится.
$ GLIBCXX_TUNABLES=glibcxx.eh_pool.obj_count=10 \ LOG_ALLOC=log.txt \ LD_PRELOAD=/home/joel/mymalloc/libmymalloc.so \ ls $ head -n 1 log.txt 2880
Добавлю, что резервный пул также можно отключать (то есть не аллоцировать), установив количество объектов на 0. Либо использовать под него статический буфер, указав --enable-libstdcxx-static-eh-pool при сборке libstdc++.
Valgrind и путаница с утечкой памяти
Мои открытия также подкрепляются поведением Valgrind, популярным инструментом, который умеет обнаруживать ошибки в управлении памятью. Пользователь Reddit ismbks разместил в теме r/cpp_questions вопрос: «Почему моя программа выделяет ~73 КБ, даже когда ничего не делает?», где указал на то же число байтов, которое у меня выделяется для резервного пула.
==1174489== HEAP SUMMARY: ==1174489== in use at exit: 0 bytes in 0 blocks ==1174489== total heap usage: 1 allocs, 1 frees, 73,728 bytes allocated
Однако в более старых версиях Valgrind эта память отражалась как «still reachable» (всё ещё доступная), а не освобождённая. И хотя «всё ещё доступная» память не является утечкой в техническом смысле (в программе есть ссылки на неё), это может вводить в заблуждение. Один из участников StackOverflow расписал это поведение подробно. Интересно, что в его случае выделяется 71 КБ, а не 72 КБ.
==8511== HEAP SUMMARY: ==8511== in use at exit: 72,704 bytes in 1 blocks ==8511== total heap usage: 1 allocs, 0 frees, 72,704 bytes allocated ==8511== ==8511== LEAK SUMMARY: ==8511== definitely lost: 0 bytes in 0 blocks ==8511== indirectly lost: 0 bytes in 0 blocks ==8511== possibly lost: 0 bytes in 0 blocks ==8511== still reachable: 72,704 bytes in 1 blocks ==8511== suppressed: 0 bytes in 0 blocks
Многие разработчики ошибочно интерпретируют это поведение как утечку памяти, что ведёт к лишней путанице. И чтобы решить эту проблему, более свежие версии Valgrind стали явно освобождать резервный пул в процессе очистки, выдавая более однозначные отчёты. Это реализуется через описанные ниже механизмы, которые были добавлены специально для инструментов вроде Valgrind:
/* g++ преобразует имя __gnu_cxx::__freeres в ZN9_gnu_cxx9__freeresEv */ extern void ZN9_gnu_cxx9__freeresEv(void) attribute((weak)); if (((to_run & VG_RUN__GNU_CXX__FREERES) != 0) && (_ZN9__gnu_cxx9__freeresEv != NULL)) { ZN9_gnu_cxx9__freeresEv(); }
libstdc++-v3/libsupc++/eh_alloc.cc (упрощённая версия):
namespace __gnu_cxx { attribute((cold)) void __freeres() noexcept { #ifndef GLIBCXXEH_POOL_STATIC if (emergency_pool.arena) { ::free(emergency_pool.arena); emergency_pool.arena = 0; } #endif } }
Выводы
Выделение памяти под резервный пул объясняет, почему я постоянно видел эти 72 КБ при тестировании своего аллокатора. Поскольку аллокатор я писал на C++, он зависит от libstdc++, которая инициализирует резервный пул при запуске каждой программы. Вот если бы я написал его на C, на котором реализованы несколько популярных вариаций malloc (mimalloc, jemalloc), то увидел бы эту аллокацию только при тестировании исполняемых файлов C++, которые явно линкуются с libstdc++.
В вашем случае может выделяться другой объём памяти (например, 71 КБ) или не выделяться вообще. На это будут влиять факторы вроде разных версий libstdc++, использования вместо неё libc++ и даже установленных флагов компилятора. Хотя в большинстве случаев вы наверняка увидите аналогичное выделение памяти под резервный пул. Разве что, в зависимости от среды это может быть другой размер или несколько иное поведение.
Начиная работать с аллокацией памяти, быстро понимаешь, что её выделение требуется практически для всего. Взять, к примеру, RTLD, которому с незапамятных времён требовалась собственная реализация malloc, так как libc на момент его запуска ещё не загружена. Или тот же резервный пул, который использует malloc только для выделения памяти под собственный аллокатор пула.
Что могу сказать по итогу. Копаться в коде и соединять воедино все элементы этого пазла было не только интересно, но и полезно. Надеюсь, вас этот процесс тоже увлёк!

