PHP. Под капотом. Архитектура, память и за гранью кода

- -
- 100%
- +

Глава 1. Жизненный цикл запроса
В массовом сознании PHP-программистов живёт удобная, но неточная метафора:
«PHP рождается, обслуживает HTTP-запрос и умирает. Всё.»
В этой метафоре нет лжи, но она скрывает механизм, понимание которого отделяет пишущих на PHP от инженеров, работающих с PHP. Начнём с того, что метафору нужно разобрать на три неверных допущения.
Три мифа, которые мы разнесём в первой же главе
Миф 1: PHP — это интерпретатор, выполняющий ваш код строка за строкой.
PHP — транслятор в байт-код. Ваш index.php проходит лексический анализ, парсинг в AST (абстрактное синтаксическое дерево) и компиляцию в опкоды — низкоуровневые инструкции Zend Engine. Интерпретируется уже байт-код, а не исходный текст. Более того, с OPCache этот байт-код кешируется в разделяемой памяти, и повторные запросы вообще не касаются ваших .php-файлов. PHP ближе к Java HotSpot (с JIT в PHP 8+), чем к Bash.
Миф 2: «PHP умирает после каждого запроса».
Не совсем. Умирает контекст запроса. Сам процесс (в случае PHP-FPM) живёт и обслуживает сотни и тысячи запросов. Это означает, что:
Существует фаза инициализации модуля (MINIT), которая выполняется один раз при старте процесса.
Существует фаза инициализации запроса (RINIT), которая выполняется для каждого HTTP-запроса.
Существует фаза завершения запроса (RSHUTDOWN), после которой процесс не умирает, а возвращается в пул ожидания.
Если вы пишете расширение PHP на C, вы обязаны знать эти фазы. Если вы архитектор приложения, вы должны учитывать «утечки» между запросами в long-running режимах.
Миф 3: PHP работает только с HTTP.
SAPI (Server Application Programming Interface) — слой абстракции, отделяющий Zend Engine от способа взаимодействия с внешним миром. HTTP-запросы через Apache (mod_php), PHP-FPM (FastCGI) — лишь самые распространённые SAPI. Вы можете запустить PHP из командной строки (CLI SAPI), встроить его в другой сервер (Embed SAPI) или использовать для асинхронных демонов.
Давайте пройдём полный цикл — от нажатия пользователем Enter в браузере до возврата HTML.
1.1. Архитектура процесса: SAPI, ядро, расширения
Представьте три слоя.
┌─────────────────────────────────┐
│ SAPI (PHP-FPM / CLI / Apache) │ ← взаимодействие с внешним миром
├─────────────────────────────────┤
│ Zend Engine │ ← компиляция и исполнение
├────┬────┬────┬────┬────┬───────┤
│ PDO│JSON│Mb │Curl│... │Ваше │ ← расширения (extensions)
│ │ │ │ │ │расш-е │
└────┴────┴────┴────┴────┴───────┘
SAPI принимает запрос, передаёт его Zend Engine и возвращает ответ. Серверное API ничего не знает о том, как парсить PHP; оно лишь организует вход и выход.
Zend Engine — сердце языка: компиляция в байт-код и виртуальная машина.
Расширения расширяют возможности ядра: от функций вроде array_map до PDO и собственных, написанных на C.
В контексте веб-сервера (PHP-FPM) используется FastCGI SAPI. Он запускает мастер-процесс, управляющий пулом воркеров. Каждый воркер — это долгоживущий процесс, внутри которого крутится бесконечный цикл: accept запроса → выполнение → ответ → ожидание следующего.
1.2. Жизненный цикл процесса PHP-FPM (взгляд изнутри C-расширения)
Для инженера, который пишет на C расширение PHP или отлаживает утечку памяти, жизненный цикл процесса состоит из чётких фаз.
MINIT (Module Initialization)
Выполняется один раз при старте процесса (воркера).
Что здесь происходит:
Регистрация классов, констант, ini-директив расширениями.
Выделение ресурсов, которые должны жить глобально в рамках процесса (например, подключение к shared memory).
Код расширения на C обычно содержит:
c
PHP_MINIT_FUNCTION(my_extension) {
REGISTER_INI_ENTRIES();
REGISTER_STRING_CONSTANT("MY_VERSION", "1.0", CONST_CS | CONST_PERSISTENT);
return SUCCESS;
}
Ключевой нюанс: MINIT исполняется до того, как пришёл первый запрос. Ресурсы, созданные здесь, не освобождаются до завершения процесса. Если здесь выделить память, которая не освобождается в MSHUTDOWN, получаем классическую утечку.
RINIT (Request Initialization)
Выполняется для каждого HTTP-запроса.
Здесь расширения инициализируют ресурсы, специфичные для запроса: суперглобальные массивы ($_GET, $_POST, $_SERVER), открывают соединения к базе данных, если они не persistent.
Ключевой нюанс: всё, что создано в RINIT, должно быть уничтожено в RSHUTDOWN. PHP предоставляет для этого механизм менеджера памяти с маркерами, но расширения обязаны корректно подчищать за собой, иначе память будет утекать с каждым запросом, убивая воркер медленно, но неизбежно.
Исполнение скрипта
Файл прочитан, скомпилирован (если не взят из OPCache), опкоды выполнены.
RSHUTDOWN (Request Shutdown)
Выполняется после отправки ответа (или фатальной ошибки) для каждого запроса.
Что здесь:
Вызов деструкторов объектов, которые не были освобождены.
Закрытие открытых файловых дескрипторов, созданных в этом запросе.
Сброс буферов вывода.
Освобождение Persistent Database Connections обратно в пул (но не закрытие!).
MSHUTDOWN (Module Shutdown)
Выполняется один раз, когда процесс воркера завершается.
Освобождаются глобальные ресурсы, зарегистрированные в MINIT. Выполняется при плавном перезапуске PHP-FPM (сигнал SIGQUIT), но не при аварийном SIGKILL.
1.3. Модель «ничего общего» (Shared-Nothing)
PHP спроектирован так, что каждый запрос стартует с чистого листа. Переменные, созданные в одном запросе, недоступны в другом. Это архитектурный выбор, а не ограничение.
Преимущества:
Нет гонок за состоянием между запросами.
Утечка памяти в одном запросе изолирована: RSHUTDOWN освободит всё, даже если вы забыли unset().
Простое масштабирование: запросы без состояния легко разбрасывать по пулу процессов.
Недостатки:
Невозможно хранить состояние между запросами в памяти PHP (требуются Redis, Memcached, APCu).
Каждый запрос платит цену инициализации (хотя OPCache и Preloading сильно снижают её).
Важный инженерный вывод: любая технология, превращающая PHP в долгоживущее приложение (Swoole, RoadRunner, ReactPHP), должна вручную эмулировать RSHUTDOWN для предотвращения пересечения состояний запросов.
1.4. PHP — транслятор в байт-код, а не интерпретатор текста
Давайте раз и навсегда разорвём ментальную связку «скриптовый язык == интерпретация строк».
Стадии превращения PHP-файла в исполняемый код
Шаг 1. Лексический анализ (Lexing)
Файл читается и превращается в поток токенов. Можно увидеть своими глазами:
bash
php -r 'token_get_all("
Вы получите массив: T_OPEN_TAG, T_ECHO, T_LNUMBER, ;. Это сырьё.
Шаг 2. Парсинг в AST (Abstract Syntax Tree)
Токены преобразуются в дерево, описывающее синтаксическую структуру программы. С PHP 7 у нас появился явный AST как внутренний этап до генерации опкодов.
Можно увидеть AST командой:
bash
php -r 'ast\parse_code("
Вместо линейного потока токенов мы получаем иерархическое представление: AST_STMT_LIST → AST_ECHO → 42.
Шаг 3. Компиляция в опкоды
AST обходится, и генерируются операции виртуальной машины Zend Engine:
ZEND_ECHO
ZEND_RETURN
Для функции sum($a, $b) { return $a + $b; } опкоды выглядят так:
ZEND_RECV (получить параметры)
ZEND_ADD
ZEND_RETURN
Шаг 4. Выполнение на виртуальной машине
Опкоды выполняются виртуальной машиной — циклом, который последовательно диспетчеризует инструкции. С PHP 8.0 добавлен JIT-компилятор, который транслирует горячие участки опкодов прямо в машинный код процессора, минуя виртуальную машину.
1.5. OPCache: как избежать Шагов 1–3 для миллионов запросов
Поскольку PHP-FPM воркер живёт долго, а код приложения меняется редко, было бы безумием парсить index.php на каждом запросе. OPCache решает это, кешируя скомпилированный байт-код в разделяемой памяти (shared memory).
Трёхуровневое хранилище байт-кода
Memory-mapped файл — выделяется область памяти, доступная всем дочерним процессам PHP-FPM.
Хэш-таблица — ключ: путь к файлу + временная метка, значение: указатель на структуру с опкодами.
Структура с опкодами — содержит не только байт-код, но и interned strings, таблицу классов, функций.
При получении запроса PHP проверяет, есть ли в OPCache актуальный байт-код для index.php. Если да — лексический анализ, парсинг и компиляция пропускаются. Экономия может достигать 90% времени выполнения запроса для фреймворков с тысячами файлов.
Interned Strings
Строки, повторяющиеся в коде (имена классов, названия методов, ключи массивов), хранятся в специальной таблице интернированных строк. Вместо дублирования строки "getUserById" в сотне опкодов, хранится одна копия в shared memory, а опкоды ссылаются на неё по указателю. Это экономит память и ускоряет сравнение строк (сравнение указателей вместо memcmp).
Preloading (PHP 7.4+)
OPCache умеет загружать и компилировать PHP-файлы один раз при старте PHP-FPM сервера и держать их в памяти постоянно. Это шаг к модели Java/.NET, где код фреймворка загружается один раз на весь пул процессов.
В php.ini указывается скрипт:
ini
opcache.preload=/app/preload.php
А внутри preload.php вызывается opcache_compile_file() для ключевых классов. После этого классы Symfony или Doctrine становятся доступны всем воркерам без чтения с диска и парсинга.
Цена preloading'а: любые изменения в preloaded-файлах требуют перезапуска PHP-FPM. Вы жертвуете гибкостью «поменял файл — подхватилось» ради производительности продакшена.
Итог первой главы
Мы разорвали шаблон «PHP — это скриптуха для формочек». Под капотом работает инженерная система с фазами жизни процесса, компиляцией в байт-код, кешированием опкодов и JIT-компиляцией.
PHP-FPM воркер не умирает после запроса — умирает контекст (RINIT → RSHUTDOWN).
PHP — не интерпретатор, а транслятор в байт-код, выполняемый виртуальной машиной с опциональной JIT-компиляцией.
OPCache — не просто «кеш файлов», а сложная система с разделяемой памятью и интернированием строк.
Shared-nothing — архитектурный выбор, дающий изоляцию ценой невозможности хранить состояние в памяти процесса.
В следующей главе мы погрузимся в модель памяти PHP: узнаем, что такое zval, как работает Copy-on-Write, и почему после PHP 7 ваши массивы стали занимать в 2-3 раза меньше памяти.
Глава 2. Модель памяти и Zend Memory Manager
Если первая глава была о времени жизни PHP-процесса, то эта — о пространстве. О том, как PHP хранит ваши переменные, почему $b = $a не всегда означает копирование, и за что мы должны благодарить PHP 7, когда сервер держит не 1000, а 3000 запросов на гигабайте оперативной памяти.
Тема зайдёт глубоко. Но после неё вы никогда не будете смотреть на оператор = как на простое присваивание.
2.1. Zval: атомарная единица всего
В основе системы типов PHP лежит структура на C, называемая zval (Zend Value). Всё, что вы создаёте — числа, строки, массивы, объекты — в какой-то момент становится zval'ом внутри рантайма.
Упрощённо zval выглядит так:
text
┌────────────┬────────────┬──────────────────┐
│ value │ type │ refcount + info │
│ (union) │ (byte) │ (bit-field) │
└────────────┴────────────┴──────────────────┘
value — union, который может хранить long, double, указатель на строку, указатель на объект, указатель на хэш-таблицу (массив) и так далее.
type — тип данных: IS_LONG, IS_DOUBLE, IS_STRING, IS_ARRAY, IS_OBJECT и другие.
refcount — счётчик ссылок (Reference Counting). Сколько переменных ссылаются на этот zval.
До PHP 7 эта структура была большой и тяжёлой. Размер zval'а достигал 48 байт (на 64-битных системах), и каждый zval выделялся отдельно в куче. Это означало, что $a = 1 требовал выделения памяти в куче для целого числа. Умножьте на миллионы переменных во фреймворке — и вот они, гигабайты.
2.2. Великая реформа PHP 7: упаковка и встраивание
Разработчики PHP 7 сделали две революционные вещи.
Во-первых, zval уменьшился до 16 байт.
Как?
value и type объединены: для простых типов (числа) значение хранится прямо в zval, без дополнительного указателя.
refcount сделан частью zval, но для простых типов он не используется (об этом ниже).
Во-вторых, для целых чисел и чисел с плавающей точкой zval больше не выделяется в куче.
Значение размещается прямо внутри структуры. $a = 42 теперь буквально занимает 16 байт на стеке или в составной структуре, а не отдельный блок в куче.
Что дало это изменение? На реальных приложениях на WordPress и Drupal потребление памяти снизилось на 50–70%. Сервер, который задыхался при 1000 одновременных соединений, теперь держал 3000 без увеличения RAM.
2.3. Строки, массивы и объекты: zval как указатель
Для составных типов (строки, массивы, объекты) zval хранит указатель на отдельную структуру в куче. Рассмотрим на примере строки.
В PHP 7 строка представлена отдельной структурой zend_string:
text
zval (16 байт) zend_string (в куче)
┌───┬─────┬─────┐ ┌───┬───┬───┬───────────┐
│ptr│ type│refc │ ───→ │ref│len│h │ "hello" │
│ │IS_STR│ │ │cnt│ 5 │ │ │
└───┴─────┴─────┘ └───┴───┴───┴───────────┘
refcnt (в структуре zend_string) — количество zval'ов, указывающих на эту строку.
len — длина строки (в байтах, не символах!).
h — предвычисленный хэш для использования в хэш-таблицах.
Сами символы строки.
Аналогично для массивов (zend_array — хэш-таблица) и объектов (указатель на zend_object).
Ключевое следствие: когда вы пишете $b = $a для строки, zval'ы копируются (16 байт), но zend_string — нет. Оба zval'а указывают на одну и ту же строку в куче, а refcnt строки становится равен 2.
2.4. Copy-on-Write: почему PHP не тормозит, когда вы «копируете» строки
Это, пожалуй, самая важная концепция производительности PHP. PHP не копирует данные, пока вы их не изменяете.
Давайте разберём классический пример:
php
$a = "Hello, World!"; // 1 zval, 1 строка в куче, refcnt строки = 1
$b = $a; // 2 zval'а, 1 строка в куче, refcnt строки = 2
// Память под строку не копировалась!
В этот момент $a и $b разделяют одну и ту же строку в памяти.
Теперь делаем изменение:
php
$b = "Goodbye!"; // Создаётся НОВАЯ строка в куче,
// refcnt старой строки уменьшается до 1,
// $b указывает на новую строку
До PHP 7 это работало медленнее из-за дополнительного уровня косвенности через «zval с разделением» (SEPARATE), но в 7-й версии механика стала элегантной и быстрой благодаря встроенным счётчикам ссылок прямо в zend_string.
Copy-on-Write в массивах
CoW работает и для массивов. Ключевое отличие: изменение массива с refcnt > 1 вызывает физическое копирование хэш-таблицы с рекурсивной проверкой элементов. Однако и здесь PHP умён:
php
$a = [1, 2, 3]; // массив с refcnt = 1
$b = $a; // refcnt массива = 2
$b[] = 4; // происходит разделение (SEPARATE),
// $a остаётся [1,2,3], $b становится новой копией + новый элемент
Проблема, которую нужно знать разработчикам фреймворков: передача массива по значению в функцию формально увеличивает refcnt при вызове, а изменение внутри функции вызывает копирование. Именно поэтому передача по ссылке (&$array) иногда оправдана — но только когда вы действительно планируете модификацию внутри.
2.5. Reference Counting и GC: когда счётчиков недостаточно
Счётчик ссылок великолепно справляется с простыми сценариями, но бессилен перед циклическими ссылками. Классика:
php
$a = new stdClass();
$b = new stdClass();
$a->ref = $b;
$b->ref = $a;
unset($a);
unset($b);
// Оба объекта всё ещё живы! refcnt каждого = 1 (второй указывает на первый и наоборот)
Счётчик ссылок каждого объекта не равен нулю, хотя из пользовательского кода они недоступны. Это классическая утечка памяти, и до PHP 5.3 она была фатальной для долгоживущих скриптов.
Циклический сборщик мусора (Cycle Collector)
PHP содержит дополнительный механизм — сборщик циклических ссылок. Он работает так:
Все возможные «корни» (zval'ы с флагом возможности цикла) помещаются в специальный буфер.
Когда буфер заполняется (по умолчанию 10 000 элементов), запускается сборщик.
Сборщик моделирует «уменьшение счётчиков»: он вычитает единицу из refcnt для каждой ссылки внутри графа.
Если после вычитания refcnt объекта становится 0 — значит, он жив только за счёт циклических ссылок. Объект помечается как мусор.
Сборщик освобождает память таких объектов.
Эту механику можно отключить: gc_disable(). Для скриптов с миллионами объектов и гарантированным отсутствием циклов это даёт прирост скорости (нет накладных расходов на поддержку буфера GC), но требует абсолютной уверенности в коде. Большинство приложений не должны этого делать никогда.
2.6. Менеджер памяти Zend MM: не просто malloc
PHP не полагается на системный malloc для каждого выделения байта. Вместо этого он использует собственный Zend Memory Manager, который:
Запрашивает большие блоки у ОС (через mmap или malloc), называемые сегментами.
Нарезает эти блоки на мелкие куски под zval'ы, строки, массивы.
Кеширует освобождённые блоки для повторного использования без обращения к ОС.
Это решает несколько проблем:
Фрагментация: системный аллокатор может фрагментировать память, PHP-аллокатор переиспользует блоки одинакового размера.
Скорость: выделение памяти из пула быстрее системного вызова.
Безопасность при завершении: при RSHUTDOWN PHP знает, какая память была выделена в течение запроса, и может одномоментно освободить её всю, даже если расширение или код разработчика «забыли» освободить отдельные блоки. Это не панацея от утечек (память, выделенная как persistent, живёт дольше), но мощная страховка.
Резюме главы
Мы спустились на уровень, где PHP перестаёт быть магией и становится понятной инженерной конструкцией:
Zval — 16-байтный контейнер. Для чисел значение внутри, для сложных типов — указатель.
PHP 7 упаковал zval и встроил простые значения, сократив потребление памяти драматически.
Copy-on-Write предотвращает копирование данных при присваивании; копия создаётся только при модификации.
Reference Counting освобождает память, когда zval больше не нужен.
Cycle Collector разруливает циклические ссылки, которые счётчик не видит.
Zend MM — это собственный аллокатор PHP, эффективный и заточенный под модель «запрос—очистка».
Теперь у нас есть понимание того, где и как живут данные. В следующей главе мы изучим, как Zend Engine превращает ваш PHP-код в эти самые zval'ы и опкоды, и как OPCache хранит их между запросами.
Глава 3. Zend Engine и OPCache
В первой главе мы сказали, что PHP — это транслятор в байт-код. Во второй — как этот байт-код оперирует данными в памяти. Теперь пришло время заглянуть в сердце машины: Zend Engine. Мы разберём, как исходный текст становится исполняемым кодом, и как OPCache позволяет не делать эту работу на каждом запросе.
Это глава о компиляции. Не бойтесь: мы не будем писать компилятор. Мы будем понимать его работу, чтобы писать лучший PHP.
3.1. Четыре фазы превращения текста в действие
Когда PHP получает файл index.php, он проходит четыре стадии, прежде чем будет выполнена первая инструкция echo.
text
Исходный код → Токены → AST → Опкоды → Исполнение
Каждая стадия — это отдельный проход, и каждая может быть изучена и отлажена отдельно.
Фаза 1: Лексический анализ (Lexing)
Задача: превратить поток байтов в поток осмысленных «слов» — токенов.
Лексер (сканер) читает исходный файл символ за символом и группирует их в токены. Каждый токен — это пара: тип + значение.
Напишем простой код:
php
$a = 42;
echo $a;
Лексер выдаст поток:
text
T_OPEN_TAG ("
T_VARIABLE ("$a")
T_WHITESPACE (" ")
= (оператор присваивания)
T_LNUMBER ("42")
; (конец инструкции)
T_ECHO ("echo")
T_VARIABLE ("$a")
; (конец инструкции)
Вы можете увидеть токены самостоятельно:
bash
php -r 'print_r(token_get_all(file_get_contents("test.php")));'
Что здесь важно инженеру:
Лексер не понимает синтаксис. $a = ; — валидный набор токенов (T_VARIABLE, '=', ';'). Синтаксическая ошибка обнаружится позже.
Лексер очень быстрый. Это конечный автомат. Он не аллоцирует сложные структуры, только токены.
Комментарии и пробелы — тоже токены (T_WHITESPACE, T_COMMENT). Они будут отброшены парсером.
Совет по производительности: лексер — не узкое место. Не пишите «оптимизированный PHP без пробелов и комментариев». OPCache сделает эту работу один раз.



