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

- -
- 100%
- +
Фаза 2: Синтаксический анализ, порождающий AST
Задача: проверить синтаксис и построить Абстрактное Синтаксическое Дерево (AST).
Парсер читает поток токенов и строит иерархическую структуру, отражающую смысл программы. Именно здесь $a = ; вызовет фатальную ошибку: парсер ожидает выражение после '=', но видит ';'.
Для нашего кода AST будет выглядеть примерно так:
text
ZEND_AST_STMT_LIST
├── ZEND_AST_ASSIGN
│ ├── ZEND_AST_VAR ($a)
│ └── ZEND_AST_ZVAL (42)
└── ZEND_AST_STMT_LIST
└── ZEND_AST_ECHO
└── ZEND_AST_VAR ($a)
До PHP 7 парсер генерировал опкоды сразу, без явного AST. Это делало язык менее гибким. Появление явного AST позволило:
Улучшить сообщения об ошибках.
Создавать инструменты статического анализа (PHPStan, Psalm).
Проводить оптимизации на уровне дерева до генерации опкодов.
Вы можете увидеть AST (потребуется расширение ast):
bash
php -r '
$code = "
var_dump(ast\parse_code($code, 70));
'
Парсер работает методом рекурсивного спуска. Такие монстры, как «LALR(1)-парсер», здесь не нужны: грамматика PHP контекстно-зависима и проще обрабатывается рекурсивным спуском с ручным разрешением неоднозначностей.
Фаза 3: Компиляция в опкоды
Задача: обойти AST и сгенерировать линейную последовательность инструкций для виртуальной машины.
Это сердце Zend Engine. Каждый узел AST транслируется в один или несколько опкодов. Опкод — это низкоуровневая инструкция виртуальной машины, обычно состоящая из:
Кода операции (ZEND_ECHO, ZEND_ASSIGN, ZEND_ADD, ZEND_RETURN, ...)
Операндов (обычно до трёх, ссылающихся на zval'ы)
Для нашего $a = 42; echo $a; компилятор сгенерирует примерно такие инструкции:
text
ZEND_ASSIGN $a, 42
ZEND_ECHO $a
ZEND_RETURN null
Реальный вывод можно увидеть через расширение vld (Vulcan Logic Dumper) или phpdbg:
bash
php -d opcache.opt_debug_level=0x20000 -r '$a = 42; echo $a;' 2>&1 | grep -E "ZEND|opline"
Или проще, через встроенную возможность OPCache:
bash
php -d opcache.opt_debug_level=0x10000 test.php
Компиляция делает несколько важных вещей:
Разрешение имён: переменные, функции, классы связываются с их внутренними представлениями.
Оптимизации времени компиляции: 1 + 2 будет вычислено прямо в опкод ZEND_ASSIGN $a, 3. Это называется constant folding (свёртка констант).
Генерация обработчиков исключений: для try/catch создаются таблицы переходов.
Результат компиляции — массив опкодов (op_array) для каждого файла, функции, метода и даже для «включённого» кода внутри eval().
Фаза 4: Исполнение на виртуальной машине
Задача: выполнить опкоды один за другим.
Виртуальная машина (ВМ) Zend Engine — это классический цикл:
text
пока (есть опкоды) {
прочитать следующий опкод
выполнить обработчик опкода
перейти к следующему опкоду
}
Каждый опкод имеет свой обработчик на C (или машинный код, если JIT скомпилировал его). Например:
Обработчик ZEND_ECHO берёт значение операнда, преобразует в строку и отправляет в буфер вывода.
Обработчик ZEND_ASSIGN берёт два операнда, записывает второй в первый с учётом Copy-on-Write и счётчиков ссылок.
ZEND_ADD складывает два числа, сохраняя результат в третий zval.
Важнейшая деталь: ВМ оперирует zval'ами, которые мы изучили в Главе 2. Каждый опкод создаёт, читает или модифицирует zval'ы. Понимание zval'ов — ключ к пониманию производительности.
С PHP 8.0 виртуальная машина получила JIT-компилятор (Just-In-Time). Он отслеживает «горячие» участки кода (те, что исполняются часто) и транслирует их напрямую в машинный код процессора x86/ARM, минуя интерпретацию опкодов. Это даёт существенный прирост на вычислительных задачах (математика, обработка изображений), но не так заметен на типичных веб-приложениях, где время выполнения уходит на I/O и ожидание базы данных.
3.2. OPCache: кеш, который меняет правила игры
Без кеширования все четыре фазы выполняются для каждого файла, каждого запроса. Для приложения на Symfony с 200+ подключаемых файлов это означало бы сотни тысяч операций парсинга и компиляции в секунду.
OPCache решает эту проблему радикально: он сохраняет результат третьей фазы (опкоды) и переиспользует его.
Что именно кешируется?
OPCache хранит в разделяемой памяти (shared memory) для каждого PHP-файла:
Массив опкодов (op_array) — готовая к исполнению программа.
Таблицу функций и классов, определённых в этом файле.
Interned strings — все строки из кода (имена переменных, функций, классов, констант) сохраняются в общую хэш-таблицу.
Метаданные: время модификации файла, контрольные суммы.
Как работает проверка актуальности?
При запросе PHP должен решить, можно ли использовать закешированный байт-код. Алгоритм проверки:
Берётся путь к файлу.
В shared memory ищется запись по этому пути.
Если запись найдена, сравнивается время модификации файла (mtime) с сохранённым.
Если совпадает — опкоды валидны, файл не читается с диска вообще.
Если не совпадает — файл перекомпилируется и кеш обновляется.
Этот механизм называется stat. При продакшен-деплое, когда все файлы меняются разом через атомарную замену директории, проверку stat можно отключить:
ini
opcache.validate_timestamps = 0
После этого OPCache никогда не проверяет файлы на диске. Производительность становится максимальной, но любое изменение кода требует перезапуска PHP-FPM или сброса кеша через opcache_reset().
Interned Strings: экономия памяти, скрытая от глаз
Представьте, что в сотне файлов вашего приложения встречается строка "getUserById" — название метода. Без интернирования каждый файл хранил бы свою копию этой строки. С OPCache:
Строка сохраняется один раз в общей таблице interned strings.
Каждый опкод, ссылающийся на эту строку, хранит не саму строку, а указатель на неё.
Сравнение строк превращается в сравнение указателей (O(1) вместо O(n)).
Это не микрооптимизация. На больших кодовых базах интернирование экономит мегабайты и десятки мегабайт оперативной памяти.
3.3. Preloading: PHP на стероидах
OPCache решил проблему повторной компиляции, но осталась проблема: каждый процесс PHP-FPM имеет свой собственный кеш опкодов. Когда воркер перезапускается, его кеш пуст, и первый запрос снова платит цену компиляции.
Preloading, введённый в PHP 7.4, решает эту проблему.
Как это работает:
В php.ini указывается preload-скрипт:
ini
opcache.preload = /app/preload.php
opcache.preload_user = www-data
Этот скрипт выполняется один раз при старте PHP-FPM мастер-процесса, до того, как создаются воркеры.
Внутри скрипта вызывается opcache_compile_file() для всех критически важных файлов:
php
// preload.php
opcache_compile_file('/app/src/Entity/User.php');
opcache_compile_file('/app/src/Service/UserService.php');
// ... сотни классов Symfony/Doctrine
Скомпилированные опкоды загружаются в разделяемую память OPCache и становятся доступны всем дочерним процессам.
Что это даёт:
Воркеры рождаются с «горячим» кешем фреймворка.
Память, занятая опкодами, распределяется между всеми воркерами (shared memory).
Экономия 30–50% памяти на воркер для типичного Symfony-приложения.
Первый запрос к новому воркеру не платит цену компиляции сотен файлов.
Цена preloading:
Изменение любого preloaded-файла требует перезапуска PHP-FPM.
Ошибка в preloaded-файле может сделать PHP-FPM неспособным запуститься.
Preloaded-классы нельзя переопределить через механизмы вроде class_alias — они буквально «запекаются» в память.
Требуется тщательная настройка: preload только то, что действительно нужно всегда.
3.4. Практические выводы для разработчика
Что из этого следует для вашего кода?
Пишите чистый, читаемый код. OPCache нивелирует стоимость пробелов, комментариев и длинных имён переменных. Лексический анализ — не узкое место.
Не оптимизируйте константные выражения вручную. 60 * 60 * 24 превращается в 86400 на этапе компиляции. Пишите выразительно: SECONDS_IN_A_DAY.
Файловый автолоад влияет на число компиляций. Каждый уникальный путь к файлу, который загружается через автолоад, требует проверки и потенциальной компиляции. PSR-4 с жёсткой структурой директорий помогает OPCache эффективнее управлять кешем.
Preloading требует дисциплины. Не preload'ьте классы с побочными эффектами (коннекты к БД, чтение конфигов). Preload'ьте чистые определения классов и функций.
JIT — не серебряная пуля. Для типичного веб-приложения выигрыш небольшой. JIT раскрывается на математических вычислениях, машинном обучении на PHP и обработке изображений.
Резюме главы
Zend Engine — это не чёрный ящик, а конвейер:
text
Исходный код → Лексер (токены) → Парсер (AST) → Компилятор (опкоды) → ВМ (исполнение)
OPCache прерывает этот конвейер после компиляции, сохраняя опкоды в разделяемой памяти и делая повторные запросы практически бесплатными с точки зрения CPU.
Preloading идёт дальше: он загружает опкоды в память ещё до прихода первого запроса, делая PHP похожим на «настоящие» долгоживущие приложения.
Теперь мы знаем, как PHP исполняет код. В следующей главе мы погрузимся в то, на чём он его исполняет: строки, бинарная безопасность, и почему strpos может вернуть false, который равен нулю.
Глава 4. Строки, которые мы (не) знаем
Строки в PHP обманчиво просты. Мы используем их каждый день: конкатенируем, обрезаем, ищем подстроки. Но за этим удобным фасадом скрывается инженерная конструкция, полная нюансов. Тот факт, что strpos() может вернуть false, который в нестрогом сравнении равен 0 — лишь вершина айсберга.
Начнём с самых основ и дойдём до оптимизаций на уровне памяти.
4.1. Бинарно-безопасные строки: PHP против C
В языке C строка — это последовательность байтов, заканчивающаяся нулевым байтом (\0). Функция strlen() в C считает байты, пока не встретит \0. Это фундаментальное ограничение: C-строка не может содержать нулевой байт внутри себя.
PHP с самого начала пошёл другим путём. PHP-строки бинарно-безопасны. Это означает:
Строка хранит свою длину явно (в поле len структуры zend_string, которую мы видели в Главе 2).
Нулевой байт — такой же легитимный символ, как и любой другой.
strlen() в PHP возвращает сохранённую длину и не зависит от наличия \0 внутри.
Демонстрация:
php
$binary = "Hello\0World";
echo strlen($binary); // 11, не 5!
echo $binary; // "Hello" — терминал обрезает, но строка полная
Почему это критически важно? Потому что PHP работает с бинарными данными постоянно. Изображения, загруженные через $_FILES, содержимое зашифрованных данных, сериализованные объекты — всё это бинарные строки с потенциальными нулевыми байтами. Если бы PHP использовал C-строки, каждый такой случай приводил бы к усечению данных.
Конец ознакомительного фрагмента.
Текст предоставлен ООО «Литрес».
Прочитайте эту книгу целиком, купив полную легальную версию на Литрес.
Безопасно оплатить книгу можно банковской картой Visa, MasterCard, Maestro, со счета мобильного телефона, с платежного терминала, в салоне МТС или Связной, через PayPal, WebMoney, Яндекс.Деньги, QIWI Кошелек, бонусными картами или другим удобным Вам способом.


