Движок Zend Engine, лежащий в основе PHP, претерпел за последние два десятилетия определенные улучшения. С выходом PHP 7 произошел значительный скачок производительности, который ощутимо повлиял на скорость работы WordPress и других традиционных веб-приложений, а в PHP 8 появился JIT-компилятор, существенно ускоривший выполнение алгоритмов повышенной вычислительной сложности.

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

Выпуск PHP 8 включает в себя изменения, которые вносят в среду выполнения PHP современные подходы к обеспечению наблюдаемости. Наша команда при поддержке сообщества по разработке движка PHP разработала и выпустила новый API, Zend Observer. Прежде чем выпустить этот API, участникам проекта пришлось решить ряд проблем.

Чтобы лучше понять, как хуки наблюдения влияют на процесс разработки, рассмотрим ограничения в области наблюдаемости, присущие среде PHP до версии 8.

Ситуация с наблюдаемостью в среде PHP до версии 8

Хук выполнения ВМ

За работу среды выполнения PHP отвечает виртуальная машина PHP (ВМ). В ней есть указатель функции, который называется zend_execute_ex. Он может переопределяться расширениями. Если какое-либо расширение использует этот хук, то все вызовы функций или методов, определенных в PHP, выполняются только через него.

До появления PHP 8 этот хук был одним из самых популярных средств перехвата вызовов функций, но он имел ряд недостатков:

  • У движка PHP практически неограниченный стек вызовов. Однако если расширение использует хук zend_execute_ex, то все вызовы функций PHP попадают в нативный стек C, который ограничен значением параметра ulimit -s. В результате может возникнуть переполнение стека и аварийное завершение процесса PHP.

  • Если расширение использует хук zend_execute_ex, то перехватываются вызовы всех PHP-функций в пространстве пользователя, а не только тех, за которыми расширение должно наблюдать. Это создает лишний оверхед, который особенно заметен, если в PHP-скрипте вызывается много функций.

  • Компилятор генерирует оптимизированные опкоды вызова функций, различая вызовы функций в пространстве пользователя (т. е. функций, определенных в коде PHP-скрипта, которые движок обрабатывает через операцию DO_UCALL) и вызовы внутренних функций PHP (обрабатываются через операцию DO_ICALL). Однако компилятор не может работать таким образом, если расширение использует хук zend_execute_ex.

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

  • Этот хук несовместим с новым JIT-компилятором, который добавлен в PHP 8.

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

Специальные обработчики опкода

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

Однако у специальных обработчиков опкода, как и у хука выполнения ВМ, есть ряд недостатков:

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

  • Специальные обработчики опкода могут изменять состояние ВМ во время выполнения. Например, расширения могут запретить ВМ вызывать стандартный обработчик опкода (именно так реализована возможность scream в Xdebug). Если расширение должно пропустить обработчик опкода, надежная пересылка хука соседнему расширению не представляется возможной. В результате возникает недопустимая ситуация: два расширения предоставляют специальный обработчик для одного и того же опкода, но одно из расширений изменяет состояние ВМ.

  • Из-за способа реализации генераторов за ними невозможно полноценно наблюдать посредством специальных обработчиков опкода.

  • Такие хуки также несовместимы с новым JIT-компилятором, появившимся в PHP 8.

Как видно, реализация наблюдения посредством специальных обработчиков опкода связана с определенными сложностями. Из-за этого в некоторых расширениях для профилирования стали использовать подход на основе расширений Zend.

Хуки на основе расширений Zend

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

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

Исходные соображения

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

Доступные в движке хуки, которые до последнего времени применялись в целях обеспечения наблюдаемости среды, демонстрировали низкую эффективность: они негативно влияли на производительность, поведение и стабильность PHP. Из-за этого, а также ввиду скорого выхода нового JIT-компилятора, возник серьезный риск, что эти хуки вообще перестанут работать в PHP 8. Эта неопределенность вынудила нас обратиться к сообществу разработчиков PHP и изложить свои предварительные соображения о трассировочных хуках для PHP в октябре 2019 года. Это, в свою очередь, породило дискуссию о встраивании телеметрических хуков прямо в Zend Engine, что привело бы к ощутимым улучшениям по сравнению с существующими вариантами обеспечения наблюдаемости среды. Так мы пришли к идее создания нового API для наблюдения за средой в PHP 8.

API Zend Observer для PHP 8

Изначально для нас было важно, чтобы изменения в области наблюдаемости улучшили бы не только собственный трассировщик PHP (Datadog), но и всю экосистему расширений PHP, включая широкий спектр трассировщиков, профилировщиков и отладчиков.

После нашего обращения к разработчикам движка PHP несколько энтузиастов откликнулись на эту инициативу. Бенджамин Эберлей (Benjamin Eberlei), Никита Попов и Дмитрий Стогов оказали неоценимую помощь нашей команде в реализации API Observer и проверили большой объем его кода. Джо Уоткинс (Joe Watkins), Боб Вайнанд (Bob Weinand) и еще несколько участников сообщества также внесли весомый вклад и дали ряд полезных советов.

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

Больше никаких ограничений стека

Используя API Observer, расширения могут наблюдать за практически неограниченным стеком вызовов. Во время фазы запуска процесса (также известной как инициализация модуля, или MINIT) расширение может зарегистрироваться в качестве наблюдателя вызова функций с помощью zend_observer_fcall_register. В таком случае Zend Engine будет использовать специальные обработчики для связанных с вызовом функций опкодов. При этом полностью устраняется искусственное ограничение наблюдения за стеком, возникающее при переопределении zend_execute_ex.

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

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

Одним из примеров специализации опкода является SPEC(RETVAL) с двумя вариантами обработчика одного опкода: один обработчик запускается, когда возвращаемое значение используется, а второй — когда возвращаемое значение не используется. Пример

<?php
$a = return_something();
return_something();

Компилятор генерирует опкод DO_UCALL для каждого из вызовов функций. Но так как DO_UCALL имеет специализацию опкода SPEC(RETVAL), то во время выполнения фактически запускаются два отдельных обработчика.

Для опкодов, связанных с вызовами функций, в API Observer введена специализация SPEC(OBSERVER). При наличии расширения-наблюдателя все опкоды, связанные с вызовом функций, будут приводить к запуску специальных обработчиков для наблюдения (например, ZEND_RETURN_SPEC_OBSERVER_HANDLER для опкода RETURN). Поскольку обработчики назначаются во время компиляции, эта тактика позволяет избежать ухудшения производительности запросов, не требующих наблюдения.

Целевые функции и методы

Действие API Observer можно распространять исключительно на функции и методы, определенные в коде PHP-скрипта, т. е. функции пространства пользователя. Новый подход позволяет каждому расширению наблюдать только за нужными функциями, а не за всеми вызовами подряд.

На схеме ниже показано, как расширение-наблюдатель следит за функцией foo() в следующем PHP-скрипте:

<?php

# example.php

function foo() {}
function bar() {}

for ($x = 0; $x < 2; $x++) {
    foo();
    bar();
}

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

Наблюдение с новым JIT-компилятором

Новый JIT-компилятор в PHP 8 усложняет перехват вызовов функций трассировщиками, профилировщиками и отладчиками без побочных эффектов. В рабочем предложении JIT RFC прямо сказано о необходимости создать API трассировки, который работал бы с JIT-компилятором:

«Профилирование во время выполнения должно работать даже с JIT-кодом, но для этого может потребоваться разработка дополнительного API трассировки и соответствующего расширения JIT, чтобы генерировать обратные трассировочные вызовы».

Благодаря поддержке автора JIT и давнего разработчика движка PHP Дмитрия Стогова, API Observer способен выполнить это требование и обеспечить наблюдаемость среды даже при включенном JIT в PHP 8.

Защита от «шумного соседа»

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

Заключение

Исторически сложилось так, что различные хуки, используемые для перехвата вызовов функций в Zend Engine, отставали от развития движка, что приводило к побочным эффектам при обеспечении наблюдаемости среды. API Observer в PHP 8 не только нейтрализует основные побочные эффекты перехвата вызовов функций, но и вводит в движок более общую концепцию — «наблюдатель».

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

Если вы хотите поучаствовать в будущем развитии возможностей PHP по обеспечению наблюдаемости, поделитесь своими идеями в списке рассылки по разработке движка PHP. Кроме того, мы приглашаем всех внести посильный вклад в разработку открытого трассировщика Datadog для PHP, в котором начиная с версии 0.52.0 используется API Observer. А если вас всерьез увлекают расширения и опкоды PHP, вышлите свое резюме нашей команде по интеграции APM. Datadog ищет сотрудников!


Перевод материала подготовлен в рамках курса "PHP Developer. Professional". Если вам интересно узнать о курсе подробнее, приглашаем на день открытых дверей онлайн, на котором преподаватель подробнее расскажет о формате обучения, программе и перспективах для выпускников.