Знакомимся с PHP Zend Memory Manager

PHP часто воспринимается как "простой" язык для веб-разработки, но под капотом у него довольно продуманная архитектура управления памятью. Он изначально спроектирован для быстрой обработки коротких запросов и следует модели share-nothing: каждый запрос выполняется в изолированной среде и не сохраняет состояние после завершения. Как отмечает официальная документация, PHP "забывает всё о текущем запросе", включая всю выделенную динамическую память.

Такой подход накладывает особые требования на систему управления памятью: она должна быстро выделять ресурсы, эффективно их использовать и гарантированно освобождать всё сразу после завершения запроса. Именно за это отвечает Zend Memory Manager — ключевой компонент, обеспечивающий стабильность и предсказуемость работы PHP-приложений.

Zend Memory Manager (ZMM) - предназначен для сценариев с жесткими лимитами памяти в традиционном PHP-FPM, а не для долгоживущих процессов (например RoadRunner, где память мониторится на уровне ОС и PHP GC).

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

Почему не использовать просто malloc()/free()

Использование стандартных функций языка С - malloc()/free() для аллокации и освобождения памяти не даёт PHP нужных гарантий.

Во-первых, производительность: PHP часто создаёт и удаляет объекты, массивы, строки и т. д., и выделение памяти под эти операции должно идти очень быстро.

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

Третья причина – отслеживание утечек: встроенный менеджер может вести статистику аллокации и сигнализировать о том, что что-то осталось неосвобождённым.

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

Фрагментация памяти — это явление, при котором большая часть памяти распределена в виде большого количества несмежных блоков или чанков. Это оставляет значительный процент общей памяти нераспределённой, но непригодной для большинства типичных сценариев

Официальная документация Zend подчёркивает, что PHP-аллокатор «выглядит как обычные malloc-функции, но они используют отдельную кучу(Heap структуру данных) и специально оптимизированы под требования PHP. Обычно вся память, выделенная во время обработки запроса, должна освобождаться сразу по его завершении. Аллокатор PHP оптимизирован, чтобы делать это очень быстро и без системной фрагментации». Благодаря отдельной куче ZMM не мешает работе других потоков и не тратит время на проверку конкурентного доступа (каждый поток PHP использует свою кучу).

Фрагментация памяти и долгоживущие процессы

В классической модели выполнения PHP, где каждый запрос обрабатывается отдельным процессом (например, в CGI или FPM), фрагментация памяти практически не заметна, поскольку после завершения запроса процесс завершается, а всё его адресное пространство очищается операционной системой. Однако при использовании долгоживущих воркеров, таких как RoadRunner, Swoole или ReactPHP, процесс продолжает жить между запросами, и память со временем может накапливаться и «подтекать».

Чтобы снизить нагрузку на операционную систему, Zend Memory Manager минимизирует количество прямых аллокаций: он заранее выделяет крупные блоки памяти (чанки) размером в несколько мегабайт и уже внутри них распределяет более мелкие участки под нужды PHP.

При этом поведение освобождения выглядит так: когда PHP-код unset()-ит или освобождает переменную, менеджер помечает участок памяти как свободный внутри своей кучи, но операционная система всё ещё видит этот раздел занятым. Процесс PHP не возвращает эти мегабайты ОС, а просто сохраняет их для последующего использования. Даже если в чанке освободились все страницы, ZMM редко отдаёт этот чанк обратно ОС: по соображениям производительности и во избежание фрагментации предполагается, что память скоро снова понадобится. В результате график потребления памяти приобретает ступенчатый вид: после каждой крупной операции потребление остаётся на пиковом уровне и не падает.

Ограничение memory_limit и контроль потребления

ZendMM отвечает также за строгий контроль лимита памяти. Каждый байт, выделенный через ZendMM, считается, и при превышении memory_limit скрипт получает фатальную ошибку. Это гарантирует, что скрипт не выйдет за рамки заданного ограничения. Кроме того, любая память, выделенная через ZendMM, отражается в функции memory_get_usage() на PHP-уровне, что позволяет разработчику измерять использование памяти. (Если же нужно обойти этот механизм, можно явно использовать malloc() из библиотеки-расширения PHP, но тогда счётчики ZendMM не увидят такую аллокацию.)

Модель share-nothing

В PHP используется модель share-nothing, что означает полную изоляцию состояния между запросами. Каждый HTTP-запрос работает с собственной памятью и объектами, а все временные данные создаются заново и автоматически очищаются по завершении запроса. Такая модель упрощает управление памятью, делает поведение предсказуемым и снижает риск утечек между запросами.

Однако есть редкие данные, которые нужно сохранить между запросами (например, кэши классов, глобальная информация и т. п.). Такие объекты обычно выделяются вне цикла запроса (настройки, данные окружения, компилированные структуры). Именно для таких случаев есть персистентные(persistence) аллокации, которые обычно создаются через обычный malloc() (или специальные PEMALLOC-функции Zend) и живут между запросами. В PHP можно выделить 2 вида аллокации:

  • Request-bounded аллокации – выделяются во время запроса с помощью API ZendMM (функции emalloc(), ecalloc() и т.д.), отслеживаются менеджером и освобождаются в конце запроса. На них приходится подавляющее большинство действий (до ~95%). При завершении запроса ZendMM проверяет, нет ли «забытой» памяти, и при debug-сборке сообщает о возможных утечках.
  • Persistence аллокации – выделяются вне обработки запроса и существуют между ними. Это бывает редко в расширениях и основном в самом движке (опкоды, кеш классов и т.п.). ZendMM не отслеживает такие аллокации: они выделяются через обычный malloc() (через функции с префиксом pemalloc/pefree), и менеджер памяти о них «не знает». Соответственно, ошибки освобождения персистентной памяти ZendMM не фиксирует – предполагается, что при необходимости они очищаются при полном завершении процесса.

Что умеет Zend MM

Zend Memory Manager выполняет следующие ключевые задачи:

  • Выделение из своих пулов. ZMM организует память в чанки, страницы и «корзины» фиксированных размеров. При малом или среднем запросе памяти он раздаёт блоки из внутренних списков, а при очень большом (>= ~2 МБ) выделяет отдельный блок через mmap. Это снижает фрагментацию и ускоряет аллокацию.
  • Учёт памяти и memory_limit. ZMM считает все запросы emalloc/ecalloc и сравнивает с лимитом. Все данные о текущем и пиковом потреблении хранит в структурах _zend_mm_heap. При превышении лимита в нужный момент генерируется ошибка и скрипт прекращает работу.
  • Быстрое очищение в конце запроса. Когда запрос заканчивается (фаза RSHUTDOWN), движок вызывает zend_mm_shutdown(). Он освобождает ВСЮ память, выделенную через ZMM, и готовит кучу к следующему запросу. Фактически после любого корректного завершения запроса в куче ZendMM не остаётся «живых» блоков – все временные данные сбрасываются.
  • Отслеживание утечек. В debug-сборке PHP при вызове shutdown_memory_manager(false) все оставшиеся в хипе блоки считаются утечками и выводятся в лог. Это помогает разработчикам расширений находить забытые efree.
  • Буферизация освобождённых чанков. При частой работе ZendMM может кэшировать пустые чанки в cached_chunks для ускоренного повторного использования.

Однако есть и явные ограничения:

  • Оптимизирован под однопоточность. Поскольку каждый запрос или поток PHP имеет свою кучу, ZendMM не тратит ресурсы на синхронизацию. Но это значит, что кучи между потоками/процессами не разделяются и память не шарится между ними (в соответствии с share-nothing).

Если по какой-то причине ZMM не нужен, можно запустить PHP с переменной окружения USE_ZEND_ALLOC=0 – тогда emalloc() будет просто вызывать стандартный malloc(). Однако при этом теряются все преимущества подсчёта памяти, и ответственность за безопасность по памяти ложится полностью на программиста.

Пример поведения Zend Memory Manager

Рассмотрим на PHP-коде, как ведёт себя ZendMM при обычных операциях:

<?php
ini_set('memory_limit', '30M');
echo "Memory limit: " . ini_get('memory_limit') . "\n";

// Начальное использование
echo "На начало теста: " . memory_get_usage(false) 
     . " bytes (PHP), " . memory_get_usage(true) . " bytes (real)\n";

// Выделяем большой объём памяти
$array = range(1, 1_000_000);
echo "После аллокации: " . memory_get_usage(false) 
     . " (PHP), " . memory_get_usage(true) . " (real)\n";

// Освобождаем массив
unset($array);
echo "После unset: " . memory_get_usage(false) 
     . " (PHP), " . memory_get_usage(true) . " (real)\n";

// Пиковое потребление
echo "Peak: " . memory_get_peak_usage(false) 
     . " (PHP), " . memory_get_peak_usage(true) . " (real)\n";

Вывод может выглядеть примерно так:

Memory limit: 30M
На начало теста: 403944 bytes (PHP), 2097152 bytes (real)
После аллокации: 17185336 (PHP), 18878464 (real)
После unset: 403944 (PHP), 2097152 (real)
Peak: 17185536 (PHP), 18878464 (real)

Здесь после unset() значение memory_get_usage(false) упало почти к стартовому уровню (освобождённая память пошла во внутренний пул), но memory_get_usage(true) осталось на ~19 МБ (OS-допамять не отдана обратно). Это соответствует тому, что ZendMM «вернул» память в своё хранилище, но не вернул её системе. При этом memory_get_peak_usage() показал пиковые 19 МБ, которые уже не сбрасываются. Именно так и проявляется оптимизация ZendMM: он позволяет быстро перераспределять память в PHP, но не допускает случаев, когда процесс вдруг внезапно вырастает за счёт неограниченных malloc.

Таким образом, Zend Memory Manager реализует в PHP настраиваемый аллокатор под модель share-nothing: он быстро выдаёт и освобождает память внутри запроса, контролирует лимит и даёт минимальную фрагментацию, но при этом не занимается «возвратом» памяти ОС до завершения работы процесса. Для разработчиков расширений важно понимать эту архитектуру: привычные malloc()/free() остаются в PHP исключительно для персистентных нужд, а вся временная память должна идти через ZendMM (функции emalloc(), ecalloc() и т.п.).

Подписаться на новые выпуски блога

Не пропустите последние обновления.
i.ivanov@yandex.ru
Подписаться