Как работает opcache

Расширение PHP opcache реализует различные функциональные возможности для ускорения работы PHP прозрачным способом. Как видно из названия, его происхождение и основное назначение - кэширование опкодов, но в настоящее время оно также содержит оптимизатор и компилятор just-in-time. Однако в этой статье мы сосредоточимся только на аспекте кэширования опкодов.

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

PHP

Хотя opcache номинально является независимым расширением, его функциональность сильно зависит от деталей реализации движка, и модификации движка часто требуют изменений и в opcache. Таким образом, работа opcache значительно отличается в разных версиях PHP. Эта статья описывает состояние дел в PHP 8.1 и подчеркивает некоторые изменения в этой версии.

Общая память

Основная цель opcache - кэшировать артефакты компиляции в общей памяти, чтобы избежать необходимости перекомпиляции PHP-скриптов при каждом выполнении.

В Unix-подобных системах при запуске выделяется один сегмент общей памяти фиксированного размера (SHM). Для обработки запросов PHP создает дополнительные процессы или порождает дополнительные потоки. Эти процессы/потоки будут видеть сегмент SHM по одному и тому же адресу.

Поскольку Windows не поддерживает форкинг, обычно вместо этого порождаются совершенно отдельные процессы PHP, которые не имеют общего адресного пространства. Это большая проблема для opcache, поскольку он требует, чтобы сегмент SHM был отображен на один и тот же адрес в каждом процессе. В противном случае, указатели на SHM не будут действительны в разных процессах.

Чтобы это работало, opcache сохраняет базовый адрес SHM и пытается отобразить сегмент по тому же адресу в других процессах. Если это не удается, opcache возвращается к использованию файлового кэша. Однако, даже если это удается, существуют ограничения: Хотя это гарантирует одинаковый адрес для сегмента SHM, адреса внутренних функций/классов могут отличаться между процессами из-за ASLR. Это означает, что в Windows невозможно, чтобы кэшированные артефакты зависели от внутренних функций/классов и т.д.

Windows - единственная платформа, где два несвязанных процесса PHP могут использовать один и тот же opcache SHM. Например, два одновременных вызова CLI могут использовать один и тот же кэш, что невозможно в других операционных системах. Параметр opcache.cache_id существует для того, чтобы принудительно использовать разный кэш в этом случае.

Поскольку поддерживать раздельное поведение для Windows довольно сложно, в будущем opcache может отказаться от поддержки повторного присоединения от несвязанных процессов, что означает, что для Windows потребуется использование SAPI, основанного на потоках, а не на процессах.

Блокировка и неизменяемость

Когда речь идет об общей памяти, всегда важно учитывать модель доступа. Поскольку мы не хотим выполнять какие-либо тонкие операции блокировки или атомарного подсчета ссылок во время выполнения, модель памяти opcache оказывается очень простой: Общая память неизменяема.

У opcache, по сути, есть только две блокировки: Одна из них - это блокировка на запись, которая может принадлежать только одному процессу, которому разрешено изменять SHM. Пока блокировка записи удерживается, другим процессам все еще разрешено читать SHM. Таким образом, удерживание блокировки записи обычно позволяет только выделять новую память в сегменте SHM и записывать в нее, но не изменять уже выделенную и потенциально используемую общую память (за некоторыми исключениями).

Опция opcache.protect_memory может быть использована для защиты всего сегмента SHM, когда блокировка на запись не установлена, что полезно для обнаружения нарушений инварианта неизменяемости (но не должно быть включено в производстве по причинам производительности).

Другая блокировка - это блокировка чтения, которая приобретается, когда запрос впервые использует SHM. Она не отслеживает, что используется и прекращает ли она использоваться. Единственная цель - записать, что кэш каким-то образом используется в данном запросе.

Цель этой блокировки - облегчить перезапуск opcache: Поскольку мы не отслеживаем, какие части кэша используются, невозможно удалить что-либо из кэша опкодов. Когда кэш переполняется, вместо этого планируется перезапуск.

Если запланирован перезапуск, то вновь запущенные запросы не будут использовать кэш SHM (но могут вернуться к файловому кэшу). Когда число пользователей падает до нуля, весь кэш очищается, и мы можем начать с нуля. Если количество пользователей не упадет до нуля в течение таймаута opcache.force_restart_timeout, то opcache уничтожит оставшихся пользователей.

Указатели карт

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

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

В текущей реализации указатель map принимает одну из двух форм: Либо это обычный указатель на фактическое хранилище указателя, что является представлением, используемым, когда структура не кэшируется в SHM. Указатель на указатель обычно выделяется на арене.

Альтернативно, указатель карты хранит только смещение от базового адреса, где базовый адрес будет разным для каждого запроса. Такое представление используется для неизменяемых структур в общей памяти. Мы отслеживаем, насколько большой должна быть используемая область указателя карты, и обнуляем ее при каждом запросе.

Хотя понятно, зачем нам нужно перенаправление во втором случае (отдельная область указателя карты для каждого запроса), можно задаться вопросом, какова цель указателя перенаправления в первом случае: Поскольку память является изменяемой, мы могли бы хранить указатель статических переменных напрямую. На самом деле, это всего лишь исторический артефакт, и ненужный указатель.

Интернированные строки

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

Внутренние строки дедуплицируются: Существует только одна интернированная строка с заданным содержимым. Это экономит память и может сделать сравнение более эффективным, так как вероятность срабатывания быстрого пути равенства указателей выше. Внутренние строки в PHP также неизменяемы, так как не учитывают ссылки.

Без opcache PHP разделяет внутренние строки на постоянные и per-request. Постоянные внутренние строки создаются во время запуска, например, для имен внутренних классов/функций. Строки на запрос создаются для символов и литералов в PHP-скриптах (если для них еще не существует постоянной интернированной строки) и отбрасываются по завершении запроса.

Когда opcache включен, внутренние строки хранятся в SHM, поэтому они дедуплицируются между процессами и на них могут ссылаться структуры, кэшированные в SHM. При запуске opcache будет копировать постоянные интернированные строки в SHM по принципу best-effort (он может не знать обо всех указателях, которые где-то хранятся), но это не важно для корректности.

Кроме того, создание интернированных строк во время запроса отключено. Вместо них создаются обычные, неинтернированные строки. Только когда скомпилированный скрипт будет кэширован (и будет получена блокировка записи SHM), строки преобразуются в интернированные строки SHM.

Кэш записей классов

PHP-скрипты содержат множество ссылок на классы в строковой форме, например, new Foo или тип Foo $param. Поскольку фактическая идентификация Foo может отличаться в разных запросах, невозможно скомпилировать их до прямой ссылки на класс.

Получение записи класса из имени класса является относительно дорогим для того, насколько часто оно встречается: Нам нужно преобразовать строку в нижний регистр и найти ее в хэш-таблице классов. Для ссылок типа new Foo этот поиск кэшируется в кэше времени выполнения функции. Однако не всегда можно использовать кэш времени выполнения. Например, проверка типов свойств не может использовать кэш времени выполнения и до PHP 8.1 использовала замену строкового имени на запись класса непосредственно внутри типа, что означало, что тип не мог жить в SHM.

В PHP 8.1 появился кэш записей классов, который объединяет интернированные строки с указателями карт. Для интернированных строк, используемых в определенных позициях (объявления классов и имена типов), выделяется слот map-указателя, который хранит разрешенную запись класса для этого имени. Чтобы избежать увеличения размера строки, здесь используется хитрость:

Обычно интернированные строки всегда имеют счетчик ссылок 2. Однако фактический счетчик ссылок не имеет значения, он должен быть больше 1, чтобы строка дублировалась при модификации. Строки с количеством ссылок 1 могут быть изменены на месте. Таким образом, мы можем использовать поле refcount для хранения смещения указателя карты, чтобы использовать его в качестве кэша записей класса.

Это имеет некоторые ограничения, поскольку связано с механизмом интернированных строк. Например, если opcache включен, но скрипт не кэшируется, то интернированные строки не будут использоваться и, следовательно, кэш записей классов будет недоступен.

Одна из приятных особенностей кэша записей классов заключается в том, что он является достаточно общим и не привязан к конкретным языковым конструкциям (как кэш времени выполнения). Если вы напишете new ReflectionClass(Foo::class), поиск класса может быть кэширован, даже если он происходит динамически.

Persist

Фактическое сохранение скриптов в общей памяти относительно просто. Сначала скрипт компилируется как обычно, за исключением некоторых опций, чтобы убедиться, что при компиляции не используются межфайловые зависимости. Результат компиляции перемещается из глобальных таблиц функций/классов в самостоятельную постоянную структуру скрипта.

Затем вычисляется размер необходимого сегмента разделяемой памяти. Этот шаг должен в точности повторять логику фактического шага persist, но (в основном) не изменяет сценарий. Если выделение разделяемой памяти не удается, мы все равно можем обойти opcache и выполнить его как обычно. Единственное изменение, которое делает шаг "persist calc" - это преобразование строк в SHM interned strings, если это возможно, так как interned strings хранятся в сегменте фиксированного размера, который отделен от сохраняемого скрипта. Строки, которые были успешно интернированы, не учитываются в размере скрипта.

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

Кэш наследования

Классы внутренне существуют в двух формах. Несвязанные классы представляют собой объявление класса, как вы бы написали его в коде: Оно содержит методы, объявленные в этом классе, и ссылки на зависимости (родительский класс, интерфейсы, трейты) в виде строк. Связанные классы представляют собой объявление класса, в котором успешно завершено наследование. Оно содержит унаследованные методы/свойства/etc и ссылается на зависимости в виде разрешенных записей класса.

При просмотре отдельного сценария классы обычно существуют в несвязанной форме (если только у них нет зависимостей). Для связывания классов необходимо посмотреть на классы в других файлах. Однако используемое объявление класса может отличаться от одного запроса к другому.

До версии PHP 8.1 это означало, что кэшируется только шаблон несвязанного класса, и наследование по-прежнему должно выполняться при каждом запросе. Поскольку наследование является достаточно дорогим процессом, это оказывало нетривиальное влияние на производительность. В PHP 8.1 эта проблема была решена с помощью кэша наследования.

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

Если записи в кэше не существует, то несвязанный класс копируется из SHM в изменяемую память процесса и над ним выполняется процесс наследования (in-place). Результат сохраняется в кэше наследования с помощью обычного процесса сохранения, вместе с зависимостями, для которых эта запись в кэше действительна.

Предварительная загрузка

Предварительная загрузка - это более радикальное решение проблемы наследования: все, что загружается скриптом предварительной загрузки, сохраняется во всех запросах. Таким образом, в этом случае можно смело использовать кросс-скриптовые зависимости. Недостатком является то, что состояние предварительной загрузки не может быть изменено без перезапуска PHP.

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

Предварительная загрузка может работать в двух режимах: Когда классы просто загружаются с помощью require, наследование будет происходить так, как это обычно происходит, а предварительная загрузка может поддерживать классы с произвольно сложными сценариями наследования (включая циклы дисперсии). Это также позволяет легко убедиться, что все необходимые зависимости предоставляются автозагрузчиком.

В качестве альтернативы можно предварительно загрузить файлы с помощью opcache_compile_file(). В этом случае opcache попытается предварительно загрузить класс, если все зависимости для него также доступны. В противном случае будет выдано предупреждение и скрипт будет кэширован старым способом. До версии PHP 8.1 требование "все зависимости" было довольно проблематичным.

В ранних версиях PHP несвязанные классы сохранялись в двух частях: Одна, собственно, неизменяемая, а другая, которая должна была копироваться в память на каждый запрос, поскольку могла быть изменена во время выполнения. Сюда входили типы свойств, а также инициализаторы констант/свойств. Если они не могли быть полностью разрешены во время предварительной загрузки, класс не мог быть предварительно загружен, потому что в этом случае мы не могли выполнить копирование на каждый запрос. В PHP 8.1 все оставшиеся модифицируемые во время выполнения части были переведены в указатели карт, что ослабило ограничение на то, что считать "зависимостью". Теперь сюда входят только родители/интерфейсы/типы, а также типы, необходимые для проверки дисперсии.

Проверка дисперсии - это еще одна проблема: очень трудно заранее определить, требуется ли тип аргумента/возврата для проверки дисперсии. Это зависит от того, является ли метод на самом деле переопределением (что неочевидно при наличии трейтов) и можно ли определить отношения подтипирования без загрузки класса (например, если типы в родительском и дочернем методе абсолютно одинаковы). Предыдущие версии PHP решали эту проблему эвристически, требуя больше зависимостей, чем нужно. PHP 8.1 вместо этого будет просто пытаться наследоваться на копии класса и отбрасывать ее в случае неудачи.

Это означает, что предварительная загрузка на основе opcache_compile_file() должна быть намного более предсказуемой в PHP 8.1.

Файловый кэш

Файловый кэш, появившийся в PHP 7, может использоваться либо отдельно (opcache.file_cache_only), либо совместно с SHM-кэшем в качестве кэша второго уровня. В последнем случае он будет использоваться при холодном запуске или когда SHM-кэш недоступен во время перезапуска opcache. В Windows по умолчанию включена функция отката файлового кэша, чтобы убедиться, что при неудачном повторном подключении SHM будет доступно хоть какое-то кэширование.

Сериализация файлового кэша начинается с сохраняемого представления сценария, либо в SHM (второй уровень), либо во временной области памяти (автономный), но созданного с использованием обычного механизма сохранения. Затем в процессе сериализации все указатели заменяются на смещения в область памяти ("распаковка указателей"). Это позволяет эффективно отменить сериализацию путем добавления нового базового указателя ко всем указателям.

Основной сложностью в этой модели являются интернированные строки, поскольку это единственные указатели, которые не указывают на сохраняемую область памяти. Внутренние строки со ссылками вместо этого сериализуются в отдельную область памяти. При отмене сериализации делается попытка преобразовать их обратно в интернированные строки SHM.

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

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий