Найти тему
Пятиминутка PHP

PHP: как работает OPcahce?

Оглавление
PHP OPcache
PHP OPcache

Никита Попов, один из основных разработчиков PHP на сегодняшний день, написал статью в своём блоге с подробностями работы OPcache. Сделаю краткий.

https://www.npopov.com/2021/10/13/How-opcache-works.html

OPcahce — это расширение PHP, которое ускоряет работу за счёт кэширования опкодов.

Напомню, что интерпретатор PHP сначала читает PHP файлы, парсит код, превращая его в набор токенов, затем в абстрактное синтаксическое дерево, а затем в набор опкодов. Опкоды — это инструкции для виртуальной машины PHP, для Zend Engine. Соответственно, если мы развернули веб-сервер и PHP, то на каждый запрос клиента интерпретатору приходится проделывать всю предварительную работу по преобразованию исходного текста программы в опкоды каждый раз. Эффективнее было сделать это лишь один раз и запомнить получившиеся опкоды где-то в оперативной памяти, таким образом последующие входящие запросы смогут воспользоваться закэшированными опкодами. Этим и занимается расширение OPcahce — кэширует опкоды.

Но не только этим занимается OPcache. В PHP 7 появилась возможность кэшировать и на диск. В PHP 7.4 новая функциональность Preload (о чём ниже), а в PHP 8.0 добавлен JIT (just in time compiler) — это всё реализовано внутри расширения.

Формально OPcache является независимым расширением, но по факту оно сильно опирается на внутреннюю структуру ядра PHP и с выходом новых версий языка расширение требует модификаций.

Общая память

Shared memory
Shared memory

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

Unix-like системах, т. е. в Linux и macOS на старте выделяется некий сегмент общей памяти, затем для каждого входящего запроса создаётся форк процесса PHP или запускается тред. Эти форки или треды видят общую память.

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

Блокировки и иммутабельность

Блокировки и иммутабельность
Блокировки и иммутабельность

Следующий интересный факт про общую память – она иммутабельна. В эту общую память опкоды только добавляются (после компиляции очередного фрагмента кода), но никогда не изменяются. Невозможно обновить какой-то уже закэшированный фрагмент. Также невозможно удалить часть кэша. OPcache можно сбросить лишь целиком, и это автоматически происходит при его полном заполнении.

Этот момент меня немного насторожил. Выходит, если размер OPcache достаточно мал, то он может быть целиком забит уже на первом запросе, затем сброшен и заново заполнен на втором запросе и опять сброшен и в итоге никакого профита? Даже если сброс будет не на каждом запросе, а через 1 или через 10 – это не то, чего я хотел бы. Надо изучить подробнее нюансы автоматического сброса.

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

Map pointers

Map pointers
Map pointers

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

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

Interned strings

Interned strings
Interned strings

OPcache хранит не только опкоды, но и так называемые interned strings — некие строковые значение. Для хранения строк используется структура с подсчётом ссылок, которая содержит длину, хэш и сам текст. Строки дедуплицируются, т. е. реально в памяти хранится только одна строка определённого содержимого, на которую могут ссылаться разные части программы. Ещё один бонусом является сравнение строк с помощью быстрого сравнения указателей на эквивалентность.

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

Class entry cache

Class entry cache
Class entry cache

Ещё один вид кэша — Class entry cache.

Например, конструкция new Foo ссылается на некий класс Foo. Но класс Foo может быть различным от запроса к запросу.

Как? На деле мы так, конечно, не делаем, но представьте себе, вместо автозагрузки вручную выполняем require некоего файла содержащего определение класса Foo. Причём если запрос пришёл по чётным секундам, то мы загружаем файл foo1.php, а если по нечётным, то запрашиваем require foo2.php — выходит у нас разное определение класса Foo на каждый запрос! Этот пример я придумал только что сам, в статье его нет, возможно Никита имел в виду что-то другое.

Короче, в процессе компиляции невозможно превратить текстовое упоминание класса в ссылку на конкретный класс в опкодах.

И в этот момент на сцену выходит PHP 8.1 в котором появляется class entry cache. Далее Никита описывает несколько трюков, которые используются в class entry cache, которые сделают процесс поиска класса по имени ещё эффективнее! Однако, я не уловил, как class entry cache справляется с исходной проблемой, что на каждый запрос под конкретным именем класса могут скрываться разные классы?

Persist

Persist
Persist

Глава Persist описывает процесс помещения скомпилированного скрипта в общую память. В этом же процессе происходит формирование упомянутых ранее interned strings, которые располагаются в отдельном сегменте памяти фиксированного размера.

Inheritance cache

Inheritance cache
Inheritance cache

Теперь про наследование.

Под капотом классы могут представлены в двух формах: Unlinked или Linked (несвязанные и связанные).

Unlinked формой называется описание класса как мы его видим в коде, в текстовом php файле — это название класса, набор констант, свойств и методов. Ссылки на внешние зависимости, например на класс предок, интерфейсы и трейты в этой форме представлены исключительно строками.

Вторая форма, Linked class — представляет собой описание класса, где движок PHP уже подтянул все унаследованные свойства и методы от классов предков, а также все внешние зависимости являются ссылками на реальные классы в опкодах, а не строками. Эту финальную форму (Linked class) невозможно закэшировать, т. к. любые отсылки к внешним классам встречающиеся в нашем коде, например указание родительского класса, может от запроса к запросу подразумевать совершенно разное. Мы говорили об этой проблеме в главе про class entry cache. В итоге весь процесс наследования происходит при обработке каждого запроса вновь и вновь, и это не простая работа сильно влияющая на производительность.

В PHP 8.1 появляется inheritance cache. Linked class кэшируется для конкретного набора зависимостей. В inheritance cache в качестве ключа выступает текущий кэшируемый класс плюс все внешние зависимости упомянутые в нём. Позже, когда приходит очередной запрос от клиента и движку PHP нужно построить форму Linked class, строковые описания зависимостей раскрываются в ссылки на реальные классы и получившийся набор зависимостей ищется в inheritance cache.

В главе про class entry cache я задавался вопросом, а как он решает проблему кэширования между запросами, ведь под одним и тем же именем класса могут скрываться разные классы в разных запросах? В данной главе про inheritance cache Никита упоминает, что действительно, набор зависимостей по факту может отличаться от запроса к запросу, но обычно, на практике, реальные зависимости не меняются и кэш будет работать.

Если в кэше нет готовой формы Linked class, тогда Unlinked class копируется из общей памяти в память процесса и запускается дорогой процесс наследования, а результат записывается в inheritance cache.

Preloading

Preloading
Preloading

Как пишет Никита, preloading — это ещё более радикальное решение. Но дальше идёт не понятное для меня предложение: «Всё что загружено с помощью preload скрипта выживает между между запросами».

Вопрос: а чем это фраза не походит для обычного OPcache без preload? Суть OPcache как раз в том и есть, чтобы выживать между запросами.

Также Никита отмечает недостаток preload — его нельзя сбросить без рестарта PHP. Так себе недостаток. OPcache на деле тоже приходится сбрасывать при деплое с помощью service php-fpm reload, как ни крути рестарт PHP нужен в любом случае, используем мы preload или нет. Строго говоря в случае OPcache у нас есть ещё функция opcache_reset(), но практика её применения для меня туманна.

С появлением inheritance cache в PHP 8.1 часть преимуществ preload станет не актуальна, но всё же у него остаётся много плюсов.

При использовании preload, всё что нужно сделать в рамках обработки запроса, это «clearing the map pointer area», чтобы это ни значило. В то время как обычный OPcache без preload требует обработки автозагрузки, поиска закэшированных скриптов, регистрации в глобальной хэш таблице, разрешение зависимостей на внешние классы, проверки в inheritance cache и много чего ещё.

Preloading может осуществляться двумя способами:

1. Загрузкой всех нужных скриптов с помощью require;

2. С использованием функции opcache_compile_file().

Второй способ до PHP 8.1 имел ряд подводных камней, о которых Никита подробно пишет в своей статье, пересказывать все детали не буду. Главный вывод, что в PHP 8.1 работа функции opcache_compile_file() в процессе preloading будет более предсказуемой.

File cache

File cache
File cache

Файловый кэш появился в PHP 7. Он может быть полезен для холодного старта после перезагрузки PHP. В этой главе описаны детали сериализации и десериализации файлового кэша и как он работает в паре с кэшем в общей памяти.

От себя скажу, что я никогда не пользовался файловым кэшем, т. к. не испытывал проблемы холодного старта. Видимо не такие уж большие у меня приложения в production.

Статья классная, раскрывает ряд технических деталей. Подразумевается, что читатель представляет, как работать с памятью, что такое указатели, адресные пространства и другие интересные термины. Я постарался передать общий смысл и поверхностно описать интересные моменты своими словами.

Рекомендую заглянуть в блог Никиты, там много всего интересного: https://www.npopov.com