Zend Engine и OPcache изнутри: от исходника до опкодов
Что происходит между вашим PHP-файлом и ответом сервера: как Zend Engine превращает код в опкоды, зачем нужен OPcache и что именно он хранит в разделяемой памяти.

Запрос приходит на сервер, PHP отдаёт ответ за 40 миллисекунд. Но при первом обращении после деплоя тот же запрос занимает 180 мс. Разница — компиляция: без OPcache Zend Engine на каждом запросе заново читает файлы, лексирует, парсит и генерирует опкоды. С OPcache эта работа делается один раз, а дальше движок берёт готовое представление из разделяемой памяти. Разберём, что происходит внутри между чтением файла и его исполнением, что именно кеширует OPcache и где эта схема даёт сбои.
Материал описывает поведение PHP 8.x (проверено на 8.2 и 8.3). До PHP 7.0 движок использовал старую модель zval, и часть деталей ниже к нему не относится.
Что делает Zend Engine: четыре стадии
Zend Engine — это исполняющее ядро PHP. Оно не выполняет ваш текст напрямую. Между исходником и результатом лежат четыре стадии, и понимание границ между ними объясняет, что вообще можно закешировать.
- Лексический анализ. Файл читается с диска, лексер (сгенерированный из
zend_language_scanner.l) разбивает поток символов на токены:T_FUNCTION,T_VARIABLE,T_STRINGи так далее. Результат этой стадии можно увидеть черезtoken_get_all(). - Синтаксический анализ. Парсер собирает из токенов абстрактное синтаксическое дерево (AST). AST появился именно в PHP 7.0 как отдельная промежуточная стадия — раньше парсер генерировал опкоды напрямую.
- Компиляция. Компилятор обходит AST и генерирует опкоды — низкоуровневые инструкции движка. Каждая функция и метод превращаются в структуру
zend_op_array: массив инструкций плюс таблицы литералов, переменных и обработчиков исключений. - Исполнение. Виртуальная машина Zend (файл
zend_vm_execute.h, сгенерированный) выполняет опкоды по одному, поддерживая стек вызовов и текущий контекст.
Ключевой момент: первые три стадии зависят только от текста файла, а не от данных запроса. Их результат детерминирован. Именно поэтому его можно вычислить один раз и переиспользовать — этим и занимается OPcache.
Как выглядит опкод
Один опкод — это структура zend_op с полями: код операции (opcode), до двух операндов (op1, op2), результат (result) и указатель на функцию-обработчик. Например, строка $sum = $a + $b; компилируется примерно в один опкод ADD с двумя операндами-переменными и результатом. Посмотреть реальный вывод компилятора можно расширением VLD или через встроенный дамп:
# PHP 8.x, вывод дизассемблированных опкодов без исполнения
php -d opcache.opt_debug_level=0x10000 -d opcache.enable_cli=1 script.php
Флаг opcache.opt_debug_level заставляет OPcache печатать опкоды после оптимизации. Значение 0x10000 показывает финальный результат; другие значения дают промежуточные проходы. Точный формат значений сверяйте в исходниках расширения — он менялся между версиями.
Что именно кеширует OPcache
Распространённое заблуждение: OPcache кеширует «результат работы скрипта». Нет. Он кеширует результат компиляции — тот самый zend_op_array и связанные с ним структуры класса, функции и константы файла. Данные запроса, содержимое переменных, вывод — всё это OPcache не трогает.
Хранилище — разделяемая память (shared memory, SHM), одна на все процессы PHP-FPM пула или все воркеры Apache. Это принципиально: скомпилировав файл в одном воркере, движок делает его доступным всем остальным без повторной компиляции.
OPcache экономит не время выполнения вашей логики, а время превращения текста в опкоды. Если скрипт сам по себе медленный из-за тяжёлых запросов к базе, OPcache его не ускорит — он уберёт только накладные расходы компиляции.
При старте OPcache в SHM хранятся:
- скомпилированные
zend_op_arrayкаждого файла; - таблицы классов, функций и констант, объявленных в файле;
- интернированные строки — общий пул строковых литералов, чтобы одинаковые строки из разных файлов не дублировались в памяти;
- служебные метаданные: время модификации файла, контрольная сумма (если включена валидация), хеш пути.
Механизм проверки актуальности
При запросе файла движок сначала смотрит в OPcache. Дальше поведение зависит от настройки opcache.validate_timestamps.
Если она включена (значение по умолчанию), OPcache сравнивает время модификации файла на диске с сохранённым. Но не на каждом запросе — интервал задаёт opcache.revalidate_freq в секундах. При значении 2 движок проверяет mtime не чаще раза в две секунды на файл; в промежутке отдаёт кеш без обращения к диску.
Если validate_timestamps выключена, OPcache вообще не смотрит на диск. Изменения в файлах игнорируются до принудительного сброса кеша. Это стандартная продакшен-настройка: максимальная скорость ценой того, что после деплоя кеш нужно чистить руками или перезапускать FPM.
Сброс делается через opcache_reset() из PHP или сигналом перезапуска FPM. Есть и точечный opcache_invalidate($path, $force) — убирает из кеша один файл.
Оптимизатор
Отдельно от кеширования OPcache содержит оптимизатор опкодов. Он выполняет проходы над zend_op_array: свёртка констант (2 + 3 считается на этапе компиляции), удаление мёртвого кода, инлайнинг простых операций, оптимизация переходов. Набор проходов задаётся битовой маской opcache.optimization_level. Значение по умолчанию включает безопасный набор; менять его без нужды не стоит.
Настройки, которые реально влияют
| Директива | Что делает | На что влияет |
|---|---|---|
opcache.memory_consumption | Размер SHM в мегабайтах | Не хватит — часть файлов не закешируется, будет перекомпиляция |
opcache.max_accelerated_files | Лимит числа файлов в кеше | Реальное значение округляется до простого числа; для больших проектов малое значение = промахи |
opcache.validate_timestamps | Проверять ли mtime | Выключено = максимум скорости, ручной сброс после деплоя |
opcache.revalidate_freq | Интервал проверки mtime, сек | Работает только при включённой валидации |
opcache.interned_strings_buffer | Размер пула интернированных строк, МБ | Мало — строки не интернируются, растёт общее потребление памяти |
Диагностировать состояние помогает функция opcache_get_status(). Она возвращает массив с полями по использованию памяти, числу закешированных ключей, статистике попаданий и промахов. Смотреть стоит на opcache_hit_rate и на то, не упёрлись ли вы в max_accelerated_files.
Где схема ломается
Понимание механизма объясняет типичные проблемы, которые иначе выглядят необъяснимо.
- Старый код после деплоя. При
validate_timestamps=0и деплое поверх файлов без сброса OPcache отдаёт старые опкоды. Симлинк-деплой не помогает автоматически: если путь физического файла изменился, кеш промахнётся, но если вы просто перезаписали содержимое по тому же пути — движок этого не увидит. Решение — сброс кеша или reload FPM как часть деплоя. - Переполнение SHM. Когда память кончается и в кеше нет места, поведение зависит от настроек: часть файлов компилируется на каждом запросе. В статусе это видно как рост
missesпри стабильном числе файлов и заполненная память. - Достигнут лимит файлов. Большой фреймворк с тысячами классов легко упирается в
max_accelerated_files. Значение из конфига округляется вверх до ближайшего простого числа — поставили 10000, реально получите чуть больше. Проверяйтеnum_cached_scriptsпротив фактического числа PHP-файлов проекта. - eval и генерируемый код. Код из
eval()и код, созданный на лету, не имеет пути на диске и не кешируется. Динамическая генерация классов в рантайме теряет весь профит OPcache для этих участков. - preload и рестарт. Механизм preload (PHP 7.4+) загружает классы в память один раз при старте FPM. Но изменение preload-скрипта требует полного рестарта пула —
opcache_reset()его не перечитывает.
Как это мерить
Не полагайтесь на ощущение «стало быстрее». Снимайте конкретные метрики. Разверните страницу со opcache_get_status() (закрыв её доступом только для админа) и следите за:
- hit rate — на прогретом продакшене должен быть близок к 100%. Стабильно низкий говорит о нехватке памяти или частой инвалидации;
- used_memory / free_memory — если свободной памяти почти нет, увеличивайте
memory_consumption; - num_cached_scripts — сравните с числом файлов проекта командой
find . -name '*.php' | wc -l; - oom_restarts — рестарты из-за нехватки памяти. Любое значение больше нуля — сигнал поднять лимит.
Смежные темы — JIT (появился в PHP 8.0 поверх OPcache) и внутреннее устройство zval — здесь не разбираем: JIT работает уже над оптимизированными опкодами и заслуживает отдельного материала.
Что делать дальше
Начните с проверки текущего состояния: выведите opcache_get_status() на проде и посмотрите hit rate и заполнение памяти. Если валидация таймстампов включена, а деплой у вас атомарный — переведите validate_timestamps в 0 и добавьте reload FPM в пайплайн деплоя, это снимает нагрузку проверки mtime. Дальше стоит разобраться с preload для тяжёлых фреймворков и, если процессорного времени всё ещё не хватает на вычислительных задачах, посмотреть в сторону JIT. Точные значения флагов и директив всегда сверяйте в разделе OPcache официальной документации PHP для вашей версии.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: PHP Manual: OPcache configuration directives, PHP Internals Book: Zend Engine
Частые вопросы
Ускоряет ли OPcache выполнение самого кода?
Почему после деплоя сайт показывает старый код?
А если у меня PHP 7.x, а не 8.x?
Что произойдёт, если OPcache не хватит памяти?
Кешируется ли код, созданный через eval()?
Нужно ли трогать opcache.optimization_level?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.