Более двух десятилетий программисты, пришедшие из таких языков, как Java, Python, Go и Node.js, смотрели на PHP и повторяли одну и ту же критику:
«У PHP нет даже персистентного сервера приложений. Он запускается, выполняет скрипт и умирает после каждого отдельного HTTP-запроса. Как вообще можно создавать серьезное ПО на такой архитектуре?»
Ирония в том, что эти критики упустили из виду самое глубокое архитектурное решение в современных веб-вычислениях.
То, что казалось примитивным ограничением, на самом деле было осознанным архитектурным выбором под названием архитектура без разделения состояния (Shared-Nothing Architecture).
В то время как рантаймы других языков борются с утечками памяти, которые накапливаются неделями, глобальным состоянием, случайно просачивающимся между параллельными пользователями, и необрабатываемыми фатальными исключениями, которые обрушивают целые кластеры серверов, PHP работает на принципиально иной основе:
PHP выполняется в полной изоляции, освобождает каждый отдельный байт памяти в конце запроса и сбрасывается до первозданного чистого состояния.
И тем не менее современный PHP вовсе не медленный. За последнее десятилетие основная команда инженеров полностью пересоздала рантайм с нуля:
- Движок Zend Engine был переписан с использованием оптимизированных под кэш 16-байтных структур zval .
- OPcache исключает парсинг файлов, кэшируя байт-код прямо в разделяемой памяти операционной системы.
- Copy-on-Write гарантирует, что массивные массивы не потребляют лишней оперативной памяти до момента их изменения.
- Tracing JIT в PHP 8 компилирует «горячие» циклы выполнения прямо в машинный код кремниевого процессора.
Чтобы по-настоящему понять, почему PHP управляет более чем 75% веб-пространства, нужно заглянуть за поверхностный синтаксис и изучить, как Zend Engine и PHP-FPM на самом деле выполняют ваш код «под капотом».
Вот полный путь выполнения HTTP-запроса в PHP.
Философия Shared-Nothing: Почему намеренная смерть — это суперсила
Чтобы по достоинству оценить Zend Engine, нужно сначала понять архитектурную разницу между долгоживущими персистентными рантаймами и PHP.
В Node.js, Python (FastAPI/Django), Ruby (Puma) и Go ваше приложение загружается один раз. Рантайм инициализирует глобальные переменные, устанавливает пулы соединений с базой данных, загружает классы фреймворка и запускает цикл событий или пул рабочих потоков, которые живут дни или месяцы.
Эта модель работает быстро, но она несет в себе серьезные архитектурные уязвимости:
- Утечки состояния между пользователями: Если разработчик случайно изменит глобальный объект или привяжет данные к неочищенному контексту запроса, Пользователь B может в итоге прочитать конфиденциальные данные, принадлежащие Пользователю A.
- Медленные утечки памяти: Если сторонний пакет постепенно добавляет элементы в кэш, который никогда не очищается, процесс медленно исчерпывает оперативную память, пока OOM-киллер (Out-Of-Memory) не завершит процесс.
- Катастрофический радиус поражения: Если необрабатываемое исключение приводит к фатальному сбою или повреждению памяти, падает весь процесс сервера, одновременно обрывая тысячи активных соединений.
PHP пошел по противоположному пути: процессная модель без разделения состояния (Shared-Nothing Process Model).
Когда HTTP-запрос поступает к воркеру PHP:
- Воркер выделяет свежую, полностью изолированную область памяти, называемую кучей запроса (Request Heap) (управляется менеджером памяти Zend Memory Manager, ZendMM ).
- Суперглобальные массивы ( $_GET , $_POST , $_SERVER ) заполняются заново специально для этого запроса.
- Ваше приложение выполняется в полной изоляции. Никакой другой параллельный пользователь не разделяет это пространство памяти.
- Заголовки и тело HTTP-ответа отправляются веб-серверу.
- Zend Engine мгновенно уничтожает кучу запроса. Каждая переменная, объект, дескриптор базы данных и буфер памяти стираются за один проход.
- Рабочий процесс возвращается в свое первозданное исходное состояние, готовый к обработке следующего запроса.
Такой дизайн наделяет PHP практически несокрушимой надежностью.
Утечки памяти просто не могут пережить завершение запроса. Если какой-то багованный кусок кода выделяет 50 мегабайт временных данных и забывает их очистить, Zend Engine автоматически стирает их в тот самый момент, когда запрос завершается.
А если скрипт сталкивается с фатальной ошибкой или сбоем сегментации (segmentation fault), это убивает только этот единственный воркер. Главный процесс (master) создает замену меньше чем за миллисекунду, не прерывая запросы остальных параллельных пользователей.
Конвейер компиляции: От текста PHP до опкодов Zend
Распространенное заблуждение заключается в том, что раз PHP умирает после каждого запроса, он якобы должен заново читать и парсить ваши файлы .php с диска каждый раз, когда кто-то загружает страницу.
В PHP 3 и 4 это было правдой. В современном PHP эта модель мертва уже больше десятилетия.
Когда PHP обрабатывает файл, он проходит через четыре четких этапа:
[PHP File] ──> [Lexer: Tokens] ──> [Parser: AST] ──> [Compiler: Opcodes] ──> [Zend VM Executor]
1. Лексический анализ (токенизация)
Лексер Zend сканирует ваш исходный скрипт PHP и преобразует поток символов в последовательность языковых токенов (таких как T_VARIABLE , T_OBJECT_OPERATOR , T_STRING ).
2. Парсинг (абстрактное синтаксическое дерево)
Представленный в PHP 7 парсер Zend считывает токены и строит иерархическое абстрактное синтаксическое дерево (AST). AST проверяет синтаксис, разрешает приоритет операторов и организует логику вашей программы в структурированное дерево перед генерацией кода.
3. Компиляция (опкоды Zend)
Компилятор обходит AST и выдает опкоды Zend (Zend Opcodes). Опкод — это низкоуровневая инструкция виртуальной машины, сопоставимая с байт-кодом Java или Python.
Например, это простое сложение:
$c = $a + $b;
компилируется в низкоуровневые инструкции, которые выглядят следующим образом:
ZEND_ADD $a, $b, ~0 ZEND_ASSIGN $c, ~0
4. Изменение правил игры: OPcache в разделяемой памяти
В стандартной установке PHP без кэширования компиляция исходных файлов при каждом запросе действительно создавала бы высокую нагрузку на CPU.
Именно здесь в дело вступает OPcache.
OPcache перехватывает конвейер компиляции. При первой загрузке PHP-файла OPcache компилирует его в опкоды Zend и сохраняет скомпилированный байт-код прямо в разделяемой памяти (Shared Memory, SHM).
Когда поступают последующие запросы:
- PHP полностью пропускает лексер.
- PHP полностью пропускает парсер и построение AST.
- PHP полностью пропускает компилятор.
Вместо этого исполнитель виртуальной машины Zend извлекает предварительно скомпилированные опкоды напрямую из памяти и немедленно выполняет их.
Благодаря предзагрузке (Preloading) (представленной в PHP 7.4 и ставшей стандартной в современных фреймворках, таких как Laravel и Symfony), фреймворки могут компилировать все основные классы, трейты и интерфейсы при запуске сервера в постоянную разделяемую память.
Когда запрос пользователя попадает в ваше приложение, весь фреймворк уже находится в оперативной памяти и готов к выполнению с нулевой задержкой инициализации.
16-байтное чудо: Как Zend Engine хранит переменные
В PHP переменная может быть целым числом, числом с плавающей точкой, строкой, массивом, объектом или null. Она может динамически менять свой тип во время выполнения:
$x = 42; $x = "Hello World"; $x = [1, 2, 3];
Как статически компилируемая программа на C (а именно таковой является Zend Engine) представляет динамически типизированную переменную с минимальными затратами памяти?
Ответ заключается в структуре zval (Zend Value).
В PHP 5 структура zval была раздутой структурой C, требующей от 32 до 48 байт на 64-битных системах. Хуже того, каждый отдельный zval выделялся как независимый блок в куче, разбрасывая указатели по памяти и вызывая частые промахи кэша процессора.
В PHP 7 ведущий разработчик Дмитрий Стогов возглавил полную перепись Zend Engine, известную как phpng (PHP Next Generation). Структура zval была полностью переработана в ультракомпактную 16-байтную структуру.
Вот как zval выглядит «под капотом» на языке C:
struct _zval_struct {
zend_value value; /* 8 bytes: union holding primitive or pointer */
union {
struct {
zend_uchar type; /* 1 byte: IS_LONG, IS_STRING, IS_ARRAY, etc. */
zend_uchar type_flags;
zend_uchar const_flags;
zend_uchar reserved;
} v;
uint32_t type_info; /* 4 bytes total */
} u1;
union {
uint32_t next; /* hash collision chaining */
uint32_t cache_slot; /* literal cache */
...
} u2; /* 4 bytes total */
};
Обратите внимание на инженерное изящество этой компоновки:
- Ровно 16 байт в сумме на любой 64-битной архитектуре.
- Для примитивных целых чисел ( IS_LONG ) и чисел с плавающей точкой ( IS_DOUBLE ) число сохраняется прямо внутри 8-байтного поля value . Здесь нет косвенной адресации через указатели и выделения памяти в куче!
- Для сложных типов, таких как строки ( IS_STRING ) или массивы ( IS_ARRAY ), поле value хранит указатель на выделенную структуру полезной нагрузки ( zend_string или zend_array ).
- Сама структура zval выделяется прямо в стеке выполнения процессора или встраивается в непрерывную память массива. Она не требует отдельных вызовов выделения памяти в куче.
Этот единственный рефакторинг удвоил скорость выполнения PHP и сократил потребление памяти вдвое по всему миру в день релиза PHP 7.
Copy-on-Write: Искусство ленивой памяти
Рассмотрите этот код на PHP:
$users = get_large_user_dataset(); // A massive 100,000-item array (~20 MB) $backup = $users; // What happens here?
В во многих языках присваивание массива новой переменной либо дублирует весь массив в памяти (пуская в расход 20 МБ ОЗУ), либо передает изменяемую ссылку, где изменения в $backup мутируют $users .
В PHP массивы имеют семантику значений: изменение $backup никогда не изменит $users .
Означает ли это, что PHP дублирует 20 мегабайт памяти при выполнении $backup = $users ?
Ответ — нет, благодаря механизму Copy-on-Write (COW).
Вот как Copy-on-Write работает в Zend Engine:
- Присваивание без дублирования: Когда вы пишете $backup = $users , PHP просто копирует 16-байтную структуру zval . И $users , и $backup указывают на один и тот же физический блок zend_array в памяти.
- Увеличение счетчика ссылок: PHP увеличивает счетчик ссылок массива: gc.refcount = 2 . Данные массива не дублируются. Операция занимает считанные наносекунды.
- Триггер изменения: Пока обе переменные только читают данные из массива, они вечно делят один и тот же блок физической памяти.
- Физическое разделение: В тот момент, когда вы изменяете одну из переменных:
$backup[0] = "Modified User";
Zend Engine проверяет поле refcount . Поскольку refcount > 1 , PHP понимает, что на эту область памяти ссылается несколько переменных.
Только в этот самый момент PHP клонирует массив в памяти, выделяет свежий блок для $backup , уменьшает счетчик $users ( refcount ) до 1 и применяет изменения к новому клону.
Если вы передаете 50-мегабайтный массив через десять функций, которые только читают данные, PHP ни разу не дублирует этот массив. Вы получаете безопасность чистой семантики передачи по значению со скоростью и эффективностью указателей передачи по ссылке.
PHP-FPM: Отказоустойчивый пул воркеров
Как веб-сервер вроде Nginx или Caddy взаимодействует с PHP в продакшене?
Этим мостом является PHP-FPM (FastCGI Process Manager).
PHP больше не работает как модуль Apache. Современная продакшн-архитектура запускает PHP в качестве независимого демона, управляющего пулом изолированных рабочих процессов.
Архитектура PHP-FPM работает по модели «мастер-воркер» (master-worker):
- Главный процесс (Master Process): Запускается с привилегиями супервизора. Он привязывается к доменному сокету UNIX или порту TCP, парсит конфигурации php.ini , загружает OPcache в разделяемую память и порождает рабочие процессы (воркеры).
- Пул воркеров (Worker Pool): Коллекция предварительно форкнутых рабочих процессов, которые прослушивают входящие соединения FastCGI.
- Распределение запросов: Когда Nginx получает HTTP-запрос для эндпоинта .php , он преобразует запрос в бинарные фреймы FastCGI и перенаправляет его через сокет свободному воркеру PHP-FPM.
- Выполнение и очистка: Воркер выполняет скрипт, передает сгенерированный HTML или JSON обратно в Nginx, запускает процедуры очистки памяти и отмечает себя как готового к следующему запросу.
Отказоустойчивость по дизайну
Истинная красота PHP-FPM заключается в изоляции сбоев.
В долгоживущем процессе Node.js или Python, если нативное расширение на C аварийно завершается с ошибкой сегментации, весь серверный процесс мгновенно падает. Все текущие запросы всех пользователей внезапно обрываются.
В PHP-FPM, если воркер 4104 сталкивается с фатальным сбоем сегментации или превышает лимит памяти, завершается только этот единственный воркер.
Главный процесс обнаруживает сигнал, записывает сбой в лог и создает совершенно новый воркер за доли миллисекунда. Ни один из других воркеров не страдает, и ни один другой пользователь не сталкивается с простоем.
Tracing JIT в PHP 8: Компиляция байт-кода в нативный машинный код
Вместе с PHP 8 язык совершил свой самый большой эволюционный скачок со времен PHP 7 — внедрение компилятора Just-In-Time (JIT).
При стандартном выполнении виртуальная машина Zend является интерпретатором. Она запускает бесконечный цикл на языке C, который извлекает опкод, ищет обработчик опкода в таблице и выполняет его:
while (1) {
execute(opline++);
}
Хотя этот цикл интерпретатора сильно оптимизирован, он все равно создает накладные расходы на CPU: переходы по таблицам обработчиков и поддержание стековых фреймов виртуальной машины.
JIT в PHP 8 обходит этот цикл интерпретатора для критически важных для производительности участков кода.
PHP 8 реализует Tracing JIT (трассирующий JIT):
- Запись профиля: По мере того как виртуальная машина Zend выполняет опкоды, JIT отслеживает часто выполняемые пути кода (известные как «горячие циклы» или «горячие трассы»).
- Промежуточное представление: Когда последовательность опкодов превышает заданный порог выполнения, JIT записывает точный профиль типов задействованных переменных.
- Генерация нативного ассемблера: Используя DynASM (библиотеку ассемблера, изначально созданную для LuaJIT), JIT транслирует эти опкоды напрямую в инструкции сырого машинного кода: ассемблер x86-64 или ARM64 .
- Прямое выполнение: При последующих итерациях процессор выполняет машинный код напрямую на физическом железе, полностью обходя цикл интерпретатора виртуальной машины Zend.
Для типичных веб-приложений, ограниченных операциями ввода-вывода (I/O-bound) и ожидающих запросы к базе данных, JIT дает скромный прирост (около 5–15%), поскольку время выполнения определяется задержками базы данных.
Однако для задач, ограниченных процессором (CPU-bound), таких как манипуляции с изображениями, математическое моделирование, парсинг JSON и инференс моделей машинного обучения, JIT позволяет PHP работать на скоростях, приближающихся к нативному C.
Смена парадигмы: почему архитектура одержала победу
Репутация PHP как «грязного» языка закрепилась в конце 1990-х годов, когда разработчики встраивали сырой SQL прямо в HTML-шаблоны.
Тем не менее, пока тенденции в разработке сменяли друг друга через бесчисленные рантаймы, фреймворки и парадигмы деплоя, PHP тихо оставался рабочей лошадкой интернета.
Почему? Потому что его базовая архитектурная философия была верна с самого начала:
- Shared-Nothing означает пуленепробиваемую надежность. Никаких утечек памяти между запросами, никакого загрязнения состояния между пользователями и мгновенная изоляция сбоев.
- OPcache обеспечивает скорость компилируемых языков. Исходные файлы компилируются один раз в разделяемую память, что исключает дисковый I/O и накладные расходы на парсинг.
- 16-байтная zval максимизирует локальность кэша. Мелкие примитивы никогда не попадают в кучу, сохраняя кэш процессора заполненным, а задержки — минимальными.
- Copy-on-Write обеспечивает безопасность без лишних затрат. Вы получаете чистую семантику передачи по значению без дублирования блоков памяти.
- PHP-FPM масштабируется без усилий. Пул предварительно форкнутых воркеров отлично ложится на многоядерные серверы без сложных багов многопоточности.
PHP выживает вовсе не вопреки своей модели выполнения. Он процветает благодаря ей.
Приняв тот факт, что каждый запрос — это кратковременное изолированное событие, PHP добился того, что долгоживущие рантаймы до сих пор гарантируют с трудом: легкое масштабирование, железобетонную стабильность и такой опыт разработки, при котором вам никогда не придется перезапускать сервер, чтобы увидеть работу своего кода.
Если посмотреть на современный PHP сквозь призму Zend Engine, то что стало для вас главным откровением, изменившим представление о том, как язык работает на самом деле? И каким вы видите баланс между shared-nothing рантаймами и постоянными серверами приложений в будущем?
Источники:






Комментарии (0)
Пока нет комментариев — будьте первым.