Менеджер памяти Zend¶
Менеджер памяти Zend, часто сокращаемый до ZendMM или ZMM — это слой на C, предоставляющий возможности выделения и освобождения динамической памяти, привязанной к запросу.
Обратите внимание на “привязанной к запросу” в предыдущем предложении.
ZendMM — это не просто классическая обёртка над динамическим аллокатором памяти libc, представленным в основном
парой вызовов API malloc()/free(). ZendMM — это про память, привязанную к запросу, которую PHP должен выделять
при обработке запроса.
Два основных вида динамических пулов памяти в PHP¶
PHP — это архитектура с разделением состояния (“share-nothing”). Ну, не на 100%. Давайте объясним.
Note
Возможно, вам стоит прочитать главу о жизненном цикле PHP перед тем, как продолжить, — там вы найдёте дополнительную информацию о различных этапах и циклах, которые можно выделить в течение жизни PHP.
PHP может обрабатывать несколько сотен или тысяч запросов в рамках одного процесса. По умолчанию PHP забывает всё, что он знает о текущем запросе, когда тот завершается.
“Забывание” означает освобождение любого динамического буфера, выделенного при обработке запроса. Это означает, что в процессе обработки запроса нельзя выделять динамическую память с помощью традиционных вызовов libc. Делать это совершенно допустимо, но вы рискуете забыть освободить такой буфер.
ZendMM предоставляет API, замещающий динамический аллокатор libc, копируя его API. При обработке запроса программист должен использовать этот API вместо аллокатора libc.
Например, когда PHP обрабатывает запрос, он будет разбирать PHP-файлы. Это, например, приведёт к объявлению функций и классов. Когда компилятор доходит до компиляции PHP-файлов, он выделяет некоторую динамическую память для хранения обнаруженных классов и функций. Но в конце запроса PHP забудет об этих данных. По умолчанию PHP забывает очень большой объём информации от одного запроса к другому.
Тем не менее существует довольно редкая информация, которую нужно сохранять между несколькими запросами. Но это нечастый случай.
Что можно оставить неизменным между запросами? Так называемые постоянные (persistent) объекты. Ещё раз
подчеркнём: это редкие случаи. Например, путь к текущему исполняемому файлу PHP не изменится от запроса к запросу.
Эта информация выделяется навсегда, то есть с помощью традиционного вызова libc malloc().
Что ещё? Некоторые строки. Например, строка “_SERVER” будет переиспользоваться от запроса к запросу, так как
каждый запрос создаёт PHP-массив $_SERVER. Поэтому сама строка “_SERVER” может быть выделена навсегда,
поскольку она будет выделена всего один раз.
Что нужно запомнить:
- Существует два вида динамических выделений памяти при программировании ядра PHP или расширений:
Динамические выделения, привязанные к запросу.
Постоянные динамические выделения.
- Динамические выделения памяти, привязанные к запросу:
Должны выполняться только когда PHP обрабатывает запрос (не до, не после).
Должны выполняться только с помощью API динамического выделения памяти ZendMM.
Очень распространены в дизайне расширений, в среднем 95% ваших динамических выделений будут привязаны к запросу.
Отслеживаются ZendMM, и вы будете уведомлены об утечках.
- Постоянные динамические выделения памяти:
Не должны выполняться во время обработки PHP запроса (не запрещено, но плохая идея).
Не отслеживаются ZendMM, и вы не будете уведомлены об утечках.
Должны быть довольно редки в расширении.
Также помните, что весь исходный код PHP основан на таком уровне работы с памятью. Таким образом, многие внутренние структуры выделяются с помощью менеджера памяти Zend. У большинства из них есть “постоянный” (persistent) вызов API, который при использовании приводит к традиционному выделению через libc.
Вот пример выделения zend_string, привязанного к запросу:
zend_string *foo = zend_string_init("foo", strlen("foo"), 0);
А вот постоянно выделенный вариант:
zend_string *foo = zend_string_init("foo", strlen("foo"), 1);
То же самое для HashTable. Выделение, привязанное к запросу:
zend_array ar;
zend_hash_init(&ar, 8, NULL, NULL, 0);
Постоянное выделение:
zend_array ar;
zend_hash_init(&ar, 8, NULL, NULL, 1);
Во всех различных API Zend это всегда одинаково. Обычно нужно передать либо “0” в качестве последнего параметра,
означающего “я хочу, чтобы эта структура была выделена через ZendMM, то есть привязана к запросу”, либо “1”,
означающий “я хочу, чтобы эта структура была выделена в обход ZendMM, с помощью традиционного вызова malloc()
из libc”.
Очевидно, что эти структуры предоставляют API, который помнит, каким образом была выделена структура, чтобы при уничтожении использовать правильную функцию освобождения. Поэтому в таком коде:
zend_string_release(foo);
zend_hash_destroy(&ar);
API знает, были ли эти структуры выделены с помощью привязанного к запросу выделения или постоянного, и в первом
случае использует efree() для их освобождения, а во втором — libc-функцию free().
API менеджера памяти Zend¶
API находится в файле Zend/zend_alloc.h
Вызовы API — это в основном C-макросы, а не функции, так что будьте готовы к этому, если будете их отлаживать и изучать, как они работают. Эти вызовы копируют вызовы libc, обычно добавляя “e” к имени функции; так что вы не должны заблудиться, и в этом API не так много всего, что нужно детально разбирать.
В основном вы будете использовать emalloc(size_t) и efree(void *).
Также предоставляется ecalloc(size_t nmemb, size_t size), который выделяет nmemb элементов отдельного
размера size и обнуляет область. Если вы опытный программист на C, вы должны знать, что по возможности лучше
использовать ecalloc(), а не emalloc(), так как ecalloc() обнуляет область памяти, что может сильно
помочь при обнаружении ошибок с указателями. Помните, что emalloc() работает в основном как libc-функция
malloc(): она ищет достаточно большую область в разных пулах и возвращает вам наиболее подходящую. Поэтому вам
может быть выдан переработанный указатель, указывающий на мусор.
Далее идёт safe_emalloc(size_t nmemb, size_t size, size_t offset), который представляет собой
emalloc(size * nmemb + offset), но с проверкой переполнения. Этот вызов API следует использовать, если числа,
которые вы должны предоставить, приходят из ненадёжного источника, например, из пользовательского пространства.
Что касается работы со строками, estrdup(char *) и estrndup(char *, size_t len) позволяют дублировать
строки или бинарные строки.
Что бы ни случилось, указатели, возвращённые ZendMM, должны освобождаться с помощью ZendMM, то есть вызовом
efree(), а не libc-функцией free().
Note
Заметка о постоянных выделениях. Постоянные выделения остаются активными между запросами. Традиционно для
этого используются обычные libc-функции malloc/free, но ZendMM предоставляет несколько коротких путей
к аллокатору libc — “постоянный” (persistent) API. Этот API начинается с буквы “p” и позволяет
выбирать между выделением через ZendMM или постоянным выделением. Таким образом, pemalloc(size_t, 1)
— это не что иное как malloc(), pefree(void *, 1) — это free(), а pestrdup(void *, 1) —
это strdup(). Просто для справки.
Средства отладки менеджера памяти Zend¶
ZendMM предоставляет следующие возможности:
Управление потреблением памяти.
Отслеживание утечек памяти и автоматическое освобождение.
Ускорение выделений за счёт предварительного выделения буферов известного размера и поддержания “тёплого” кэша при освобождении.
Управление потреблением памяти¶
ZendMM — это слой, стоящий за пользовательской функцией PHP “memory_limit”. Каждый байт, выделенный через слой
ZendMM, учитывается и суммируется. Когда достигается значение INI-настройки memory_limit, вы знаете, что
происходит.
Это также означает, что любое выделение, выполненное через ZendMM, отражается в вызове memory_get_usage() из
пользовательского пространства PHP.
Как разработчику расширений, это удобно, так как помогает контролировать размер кучи процесса PHP.
Если возникает ошибка превышения лимита памяти, движок выходит из текущей позиции кода в блок catch и завершает работу штатно. Но нет никаких шансов, что он вернётся в то место вашего кода, где произошло превышение лимита. Вы должны быть к этому готовы.
Это означает, что теоретически ZendMM не может вернуть вам NULL-указатель. Если выделение не удаётся на уровне ОС, или если выделение вызывает ошибку превышения лимита памяти, код перейдёт в блок catch и не вернётся к вашему вызову выделения.
Если по какой-то причине вам нужно обойти эту защиту, вам нужно использовать традиционный вызов libc, например
malloc(). Однако будьте осторожны и понимайте, что делаете. Может оказаться, что вам нужно выделить много
памяти, и использование ZendMM могло бы превысить memory_limit PHP. Поэтому используйте другой аллокатор (например,
libc), но учтите: ваше расширение увеличит размер кучи текущего процесса. Это не будет видно через
memory_get_usage() в PHP, но можно увидеть, анализируя текущую кучу средствами ОС (например, через
/proc/{pid}/maps).
Note
Если вам нужно полностью отключить ZendMM, можно запустить PHP с переменной среды USE_ZEND_ALLOC=0.
В этом случае каждый вызов API ZendMM (например, emalloc()) будет направлен к вызову libc, и ZendMM
будет отключён. Это особенно полезно при
отладке памяти.
Отслеживание утечек памяти¶
Вспомним основные правила ZendMM: он запускается при старте запроса и затем ожидает, что вы будете вызывать его API, когда вам нужна динамическая память во время обработки запроса. Когда текущий запрос завершается, ZendMM завершает работу.
При завершении работы он просматривает все свои активные указатели, и если используется debug-сборка PHP, предупредит вас об утечках памяти.
Скажем прямо: если в конце текущего запроса ZendMM обнаруживает какие-то активные блоки памяти, это значит, что они протекают. В конце запроса в куче ZendMM не должно оставаться ни одного активного блока памяти, так как любой, кто что-то выделил, должен был это освободить.
Если вы забыли освободить блоки, все они будут выведены в stderr. Этот процесс отчётности об утечках памяти работает только при следующих условиях:
Вы используете debug-сборку PHP.
У вас установлено report_memleaks=On в php.ini (значение по умолчанию).
Вот пример простой утечки в расширении:
PHP_RINIT_FUNCTION(example)
{
void *foo = emalloc(128);
}
При запуске PHP с активированным этим расширением на debug-сборке в stderr выводится следующее:
[Fri Jun 9 16:04:59 2017] Script: '/tmp/foobar.php'
/path/to/extension/file.c(123) : Freeing 0x00007fffeee65000 (128 bytes), script=/tmp/foobar.php
=== Total 1 memory leaks detected ===
Эти строки генерируются при завершении работы менеджера памяти Zend, то есть в конце каждого обработанного запроса.
Однако будьте внимательны:
Очевидно, ZendMM ничего не знает о постоянных выделениях или выделениях, выполненных каким-либо другим способом, отличным от использования его самого. Поэтому ZendMM может предупредить вас только о выделениях, о которых он знает, а любое традиционное выделение через libc не будет учтено, например.
Если PHP завершает работу некорректно (то, что мы называем нечистым завершением работы), ZendMM сообщит о массе утечек. Это происходит потому, что при некорректном завершении работы движок использует вызов longjmp() в блок catch, предотвращающий срабатывание всего кода, очищающего память. Таким образом, сообщается о множестве утечек. Это особенно происходит после вызова функций PHP exit()/die(), или если фатальная ошибка возникает в некоторых критических частях PHP.
Если вы используете не-debug сборку PHP, в stderr ничего не выводится, ZendMM “глуп”, но всё равно очистит любой выделенный привязанный к запросу буфер, который не был явно освобождён программистом.
Нужно помнить, что отслеживание утечек ZendMM — это приятный бонусный инструмент, но он не заменяет настоящий C-отладчик памяти.
Жизненный цикл¶
PHP вызывает функцию start_memory_manager() на этапе запуска, в частности, когда стартует процесс PHP (например,
при старте сервиса PHP-FPM, или при запуске CLI-скрипта PHP). Это выделит кучу и первый чанк.
Во время запроса ZendMM будет выделять чанки по мере необходимости.
При каждом завершении запроса (на этапе RSHUTDOWN) Zend Engine вызывает функцию shutdown_memory_manager()
(которая вызывает функцию zend_mm_shutdown()) с булевым аргументом full, установленным в false. Это
выполнит очистку для следующего запроса, но не выполнит полное завершение работы менеджера памяти. Например, куча
не будет освобождена, и среднее количество чанков, использованных в течение текущего запроса, останется активным в
указателе cached_chunks на куче, чтобы быть переиспользованным в следующем запросе.
На этапе завершения работы модуля (MSHUTDOWN) Zend Engine вызывает функцию shutdown_memory_manager()
(которая вызывает функцию zend_mm_shutdown()) с булевым аргументом full, установленным в true, что
запускает полное завершение работы и освобождает все закэшированные чанки, а также саму кучу.
Внутреннее устройство ZendMM¶
В основе ZendMM лежит структура _zend_mm_heap (как определено в Zend/zend_alloc.c), которая
создаётся для каждого запроса во время инициализации запроса и хранится в alloc_globals->mm_heap. Эта куча
также поставляется с первым чанком, выделяемым вместе с ней. Затем чанки подразделяются на страницы. Более мелкие
выделения хранятся в бинах (bins), которые могут помещаться на одной странице, а некоторые занимают несколько
страниц.
Внутренняя организация памяти¶
Куча (Heap)¶
Куча, как определено в структуре _zend_mm_heap, хранит ссылки на чанки (main_chunk и cached_chunks, для
малых и крупных выделений), huge_list для огромных выделений (>= 2 МБ), а также на бины (для малых выделений) в
free_slots[BIN]. После инициализации существует только main_chunk и, возможно, несколько cached_chunks.
Чанки (Chunks)¶
Каждый чанк имеет размер 2 МБ и состоит из 512 страниц. Первая страница каждого чанка зарезервирована под заголовок
чанка, как определено в структуре _zend_mm_chunk (как определено в Zend/zend_alloc.c). Чанки
организованы в связный список с указателями prev и next.
Каждый чанк хранит битовую маску в free_map (512 бит), где один бит указывает, занята страница или свободна.
Информация о содержимом страницы хранится в map, представляющем собой массив из 512 32-битных целых чисел.
Каждое из этих чисел используется как битовая маска и хранит метаинформацию об этой странице.
Страницы (Pages)¶
Размер страницы составляет 4096 байт, и она может либо содержать бин (для малых выделений), либо быть частью крупного выделения. Что находится в ней, можно узнать из карты (map) чанка, к которому принадлежит страница.
Бины (Bins)¶
Малые выделения группируются в бины. Размеры бинов заранее определены и представлены в 30 различных вариантах (8, 16, 24, 32, … 3072 байта). Бин хранит значения одинакового размера и напрямую связан с кучей.
Бин может состоять из нескольких страниц. Пример: существует бин, хранящий элементы размером от 257 до 320 байт, который занимает 5 страниц и, следовательно, вмещает 64 (из расчёта 4096*5/320) элемента такого размера.
Категории выделений¶
Малые выделения¶
Выделения размером до 3072 байт включительно организованы в бинах.
Если бин уже инициализирован, указатель free_slot в структуре zend_mm_heap — это адрес, который будет
использован (этот адрес будет возвращён вызовом emalloc() и будет увеличен, указывая на следующий свободный
слот, см. реализацию в zend_mm_alloc_small).
Если бин для этого конкретного размера ещё не инициализирован, он будет создан в функции
zend_mm_alloc_small_slow, и возвращается указатель на первый элемент бина.
Крупные выделения¶
Выделения больше 3072 байт, но достаточно маленькие, чтобы поместиться в чанк (размер чанка 2 МБ минус 4096 байт
заголовка чанка (первая страница) составляет 2093056 байт), хранятся непосредственно в страницах. Первая страница
помечается как LRUN на карте чанка и также хранит количество выделенных страниц.
Огромные выделения¶
Если выделение больше размера чанка минус одна страница (размер чанка 2 МБ минус 4096 байт заголовка чанка (первая
страница) составляет 2093056 байт), память выделяется с помощью mmap() и помещается в связный список
huge_list на куче.
Подключение к ZendMM¶
Вы можете вызвать функцию zend_mm_set_custom_handlers() и передать ей указатели на ваши обработчики malloc,
free и realloc, а также вашу собственную кучу или текущую кучу, которую можно получить с помощью
zend_mm_get_heap().
void* my_malloc(size_t len) {
return malloc(len);
}
void my_free(void* ptr) {
free(ptr);
}
void* my_realloc(void* ptr, size_t len) {
return realloc(ptr, len);
}
PHP_MINIT_FUNCTION(my_extension) {
zend_mm_set_custom_handlers(
zend_mm_get_heap(),
my_malloc,
my_free,
my_realloc
);
return SUCCESS;
}
Вы также можете принести свою собственную кучу и внедрить её через zend_mm_set_heap(), который возвращает
указатель на текущую (или старую) кучу. Учтите, что в куче с пользовательскими обработчиками поведение ZendMM будет
отличаться:
ZendMM не выполнит очистку во время
zend_mm_shutdown()(которая вызывается на этапе завершения запроса в PHP), что приведёт к утечке памяти, если ваши пользовательские обработчики просто перенаправляют вызовы к внутренним функциям ZendMM.Сборщик мусора ZendMM, реализованный в
zend_mm_gc(), не будет выполнять никаких действий. Это также означает, что он не попытается освободить чанки в случае достижения лимита памяти во время выделения в одной из внутренних функций ZendMM.Единственный способ обнаружить, что в вашей куче с пользовательскими обработчиками выполняется полное завершение работы — это то, что ваша функция
freeбудет вызвана с адресом вашей кучи.Нет никакой возможности узнать, когда
zend_mm_shutdown()будет выполнять завершение запроса.
Распространённые ошибки¶
Вот самые распространённые ошибки при использовании ZendMM и что с ними делать.
Использование ZendMM, когда вы не обрабатываете запрос.
Получите информацию о
жизненном цикле PHP, чтобы понимать в своих расширениях, когда вы
обрабатываете запрос, а когда нет. Если вы используете ZendMM за пределами запроса (например, в MINIT()),
выделение будет молча очищено ZendMM перед обработкой первого запроса, и вы, вероятно, получите use-after-free:
просто не делайте этого.
Переполнение и недополнение буфера.
Используйте отладчик памяти. Если вы пишете до или за пределами области памяти, возвращённой ZendMM, вы перезапишете критически важные структуры ZendMM и вызовете крэш. Может появиться сообщение “zend_mm_heap corrupted”, если ZendMM смог обнаружить эту проблему за вас. Трассировка стека покажет крэш из какого-то кода в какой-то код ZendMM. Код ZendMM сам по себе не падает. Если у вас происходит крэш посреди кода ZendMM, это с высокой вероятностью означает, что вы где-то напутали с указателем. Включите ваш любимый отладчик памяти и найдите виновную часть, чтобы исправить её.
Смешивание вызовов API.
Если вы выделяете указатель ZendMM (например, emalloc()) и освобождаете его через libc (free()), или в
обратном сценарии — вы получите крэш. Будьте строги. Также если вы передаёте в efree() ZendMM какой-либо
указатель, о котором он не знает — вы получите крэш.