Пробуем preload (PHP 7.4) и RoadRunner

в 12:04, , рубрики: performance, php, php-fpm, preload, roadrunner, Блог компании Badoo, высокая производительность, Программирование, Разработка веб-сайтов

Пробуем preload (PHP 7.4) и RoadRunner - 1

Привет! 

Мы часто пишем и говорим о производительности PHP: как мы ей занимаемся в целом, как мы сэкономили 1 млн долларов при переходе на PHP 7.0, а также переводим разные материалы на эту тему. Это вызвано тем, что аудитория наших продуктов растёт, а масштабирование PHP-бэкенда при помощи железа сопряжено со значительными затратами — у нас 600 серверов с PHP-FPM. Поэтому инвестирование времени в оптимизацию для нас выгодно.

Прежде мы говорили в основном об обычных и уже устоявшихся способах работы с производительностью. Но сообщество PHP не дремлет! В PHP 8 появится JIT, в PHP 7.4 — preload, а за пределами core-разработки PHP развиваются фреймворки, подразумевающие работу PHP как демона. Пора поэкспериментировать с чем-то новым и посмотреть, что это может нам дать.

Так как до релиза PHP 8 ещё далеко, а асинхронные фреймворки плохо подходят для наших задач (почему — расскажу ниже), сегодня остановимся на preload, который появится в PHP 7.4, и фреймворке для демонизации PHP — RoadRunner.

Это текстовая версия моего доклада с Badoo PHP Meetup #3. Видео всех выступлений мы собрали в этом посте.

PHP-FPM, Apache mod_php и подобные способы запуска PHP-скриптов и обработки запросов (на которых работает подавляющее большинство сайтов и сервисов; для простоты я буду называть их «классическим» PHP) работают по принципам shared-nothing в широком смысле этого термина:

  • состояние не шарится между воркерами PHP;
  • состояние не шарится между различными запросами.

Рассмотрим это на примере простейшего скрипта:

// инициализация
$app = App::init();
$storage = $app->getCitiesStorage();

// полезная работа
$name = $storage->getById($_COOKIE['city_id']);

echo "Ваш город: {$name}";

Для каждого запроса скрипт выполняется с первой до последней строчки: несмотря на то, что инициализация, скорее всего, не будет отличаться от запроса к запросу и её потенциально можно выполнить единожды (сэкономив ресурсы), всё равно её придётся повторять для каждого запроса. Мы не можем просто взять и сохранить переменные (например, $app) между запросами из-за особенностей того, как работает «классический» PHP.

Как это могло бы выглядеть, если бы мы вышли за рамки «классического» PHP? Например, наш скрипт мог бы запускаться вне зависимости от запроса, производить инициализацию и иметь внутри себя цикл выполнения запросов, уже внутри которого он ждал бы следующий, обрабатывал его и повторял цикл, не очищая окружение (дальше я буду называть это решение «PHP как демон»).

// инициализация
$app = App::init();
$storage = $app->getCitiesStorage();

$cities = $storage->getAll();

// цикл обработки запросов
while ($req = getNextRequest()) {
    $name = $cities[$req->getCookie('city_id')];

    echo "Ваш город: {$name}";
}

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

Производительность такого решения потенциально значительно выше, чем у «классического» PHP. Но обычно увеличение производительности не даётся бесплатно — за него приходится платить какую-то цену. Давайте разберёмся, что это может быть в нашем случае.

Для этого давайте немного усложним наш скрипт и вместо вывода переменной $name будем заполнять массив:

-    $name = $cities[$req->getCookie('city_id')];
+    $names[] = $cities[$req->getCookie('city_id')];

В случае «классического» PHP никаких проблем не возникнет — в конце исполнения запроса переменная $name уничтожится и каждый следующий запрос будет работать так, как ожидается. В случае же запуска PHP как демона каждый запрос будет добавлять в эту переменную очередной город, что приведёт к бесконтрольному росту массива, пока на машине не закончится память.

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

Такого типа ошибки неприятны, но для них существуют эффективные решения. Куда хуже, если из-за ошибки скрипт не упадёт, а непредсказуемо изменит значения каких-то переменных (например, очистит массив $cities). В таком случае все последующие запросы будут работать с некорректными данными. 

Если подытожить, то для «классического» PHP (PHP-FPM, Apache mod_php и подобных) проще писать код — он освобождает нас от целого ряда проблем и ошибок. Но за это мы платим производительностью.

Из примеров выше мы видим, что в некоторых частях кода на обработку каждого запроса «классического» PHP тратит ресурсы, которые можно было бы не тратить (или тратить однократно). Это следующие области:

  • подключение файлов (include, require и т. п.);
  • инициализация (фреймворк, библиотеки, DI-контейнер и т. д.);
  • запрос данных из внешних хранилищ (вместо хранения в памяти).

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

Preload

Из трёх пунктов, перечисленных выше, preload призван бороться с первым — оверхедом при подключении файлов. На первый взгляд это может показаться странным и бессмысленным, ведь в PHP уже есть OPcache, который был создан именно для этой цели. Для понимания сути давайте попрофилируем при помощи perf реальный код, над которым включён OPcache, у которого hit rate равен 100%. 

Пробуем preload (PHP 7.4) и RoadRunner - 2

Несмотря на OPcache, мы видим, что persistent_compile_file занимает 5,84% времени выполнения запросов. 

Для того чтобы разобраться, почему так происходит, мы можем посмотреть исходники zend_accel_load_script. Из них видно, что, несмотря на наличие OPcache, при каждом вызове include/require копируются сигнатуры классов и функций из общей памяти в память процесса-воркера, а также делается различная вспомогательная работа. И эта работа должна быть сделана для каждого запроса, так как по его окончании память процесса-воркера очищается.

Пробуем preload (PHP 7.4) и RoadRunner - 3

Это усугубляется большим количеством вызовов include/require, которое мы обычно производим за один запрос. Например, Symfony 4 подключает порядка 310 файлов до выполнения первой полезной строчки кода. Иногда это происходит неявно: чтобы создать инстанс класса A, приведённого ниже, PHP выполнит автозагрузку всех остальных классов (B, C, D, E, F, G). И особенно в этом плане выделяются зависимости Composer’а, объявляющие функции: чтобы гарантировать, что эти функции будут доступны во время выполнения пользовательского кода, Composer вынужден всегда подключать их вне зависимости от использования, так как в PHP отсутствует автозагрузка функций и они не могут быть подгружены в момент вызова.

class A extends B implements C {
    use D;

    const SOME_CONST = E::E1;
    private static $someVar = F::F1;

    private $anotherVar = G::G1;
}

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

Preload имеет одну-единственную основную настройку opcache.preload, в которую передаётся путь до PHP-скрипта. Этот скрипт будет выполнен однократно при запуске PHP-FPM/Apache/и т. п., а все сигнатуры классов, методов и функций, которые будут объявлены в этом файле, станут доступны всем скриптам, обрабатывающим запросы, с первой строчки их выполнения (важное замечание: это не относится к переменным и глобальным константам — их значения обнулятся после окончания фазы preload). Больше не нужно делать вызовы include/require и копировать сигнатуры функций/классов из общей памяти в память процесса: все они объявляются immutable и за счёт этого все процессы могут ссылаться на один и тот же участок памяти, содержащий их.

Обычно нужные нам классы и функции лежат в разных файлах и объединять их в один preload-скрипт неудобно. Но этого делать и не нужно: так как preload — это обычный PHP-скрипт, мы можем просто использовать include/require или opcache_compile_file() из preload-скрипта для всех нужных нам файлов. Кроме того, так как все эти файлы будут подгружены единожды, PHP сможет произвести дополнительные оптимизации, которые невозможно было сделать, пока мы по отдельности подключали эти файлы в момент выполнения запроса. PHP делает оптимизации только в рамках каждого отдельного файла, но в случае с preload — для всего кода, подгруженного в фазе preload.

Бенчмарки preload

Для того чтобы продемонстрировать на практике пользу от preload, я взял один CPU-bound endpoint Badoo. Для нашего бэкенда в целом характерна CPU-bound-нагрузка. Этот факт является ответом на вопрос, почему мы не рассматривали асинхронные фреймворки: они не дают никакого преимущества в случае CPU-bound-нагрузки и при этом ещё больше усложняют код (его нужно писать иначе), а также для работы с сетью, диском и прочим требуются специальные асинхронные драйвера. 

Чтобы в полной мере оценить пользу от preload, для эксперимента я загрузил с помощью него вообще все файлы, которые необходимы тестируемому скрипту во время работы, и нагрузил его подобием обычной production-нагрузки при помощи wrk2 — более продвинутого аналога Apache Benchmark, но такого же простого.

Чтобы попробовать preload, нужно сначала перейти на PHP 7.4 (у нас сейчас PHP 7.2). Я замерил производительность PHP 7.2, PHP 7.4 без preload и PHP 7.4 с preload. Получилась вот такая картина:

Пробуем preload (PHP 7.4) и RoadRunner - 4

Таким образом, переход c PHP 7.2 на PHP 7.4 даёт +10% к производительности на нашем endpoint’е, а preload даёт ещё 10% сверху. 

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

Нюансы preload

У того, что увеличивает производительность, обычно есть обратная сторона. У preload есть множество нюансов, которые я приведу ниже. Их все нужно учитывать, но только один (первый) может быть принципиальным.

Изменение — перезапуск

Так как все preload-файлы компилируются только при запуске, помечаются как immutable и не перекомпилируются в дальнейшем, единственный способ применить изменения в этих файлах — перезапустить (reload или restart) PHP-FPM/Apache/и т. п.

В случае reload PHP старается произвести перезапуск максимально аккуратно: запросы пользователей не будут оборваны, но тем не менее, пока идёт preload-фаза, все новые запросы будут ждать её завершения. Если в preload будет не много кода, это может не доставить проблем, но если попытаться загрузить всё приложение, то это чревато значительным увеличением времени ответа во время перезапуска.

Также у перезапуска (вне зависимости от того, reload это или restart) есть важная особенность — в результате этого действия очищается OPcache. То есть все запросы после него будут работать с холодным опкод-кешем, что может увеличить время ответа ещё больше.

Неопределённые символы

Чтобы preload смог подгрузить класс, всё, от чего он зависит, должно быть определено до этого момента. Для класса ниже это означает, что перед компиляцией этого класса должны быть доступны все другие классы (B, C, D, E, F, G), переменная $someGlobalVar и константа SOME_CONST. Так как preload-скрипт — это обычный PHP-код, мы можем определить автолоадер. В таком случае всё, что связано с другими классами, будет подгружено им автоматически. Но это не работает с переменными и константами: мы сами должны гарантировать, что они определены на момент объявления этого класса.

class A extends B implements C {
    use D;

    const SOME_CONST = E::E1;
    private static $someVar = F::F1;

    private $anotherVar = G::G1;
    private $varLink = $someGlobalVar;
    private $constLink = SOME_CONST;
}

К счастью, preload содержит достаточно инструментов для понимания того, получилось разрезолвить что-то или нет. Во-первых, это warning-сообщения с информацией о том, что не удалось подгрузить и почему:

PHP Warning: Can't preload class MyTestClass with unresolved initializer for constant RAND in /local/preload-internal.php on line 6
PHP Warning: Can't preload unlinked class MyTestClass: Unknown parent AnotherClass in /local/preload-internal.php on line 5

Во-вторых, preload добавляет отдельную секцию в результат функции opcache_get_status(), в которой видно, что было успешно загружено в фазе preload:

Пробуем preload (PHP 7.4) и RoadRunner - 5

Оптимизация полей/констант класса

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

const.php:
<?php
define('MYTESTCONST', mt_rand(1, 1000));

preload.php:
<?php

include 'const.php';
class MyTestClass {
    const RAND = MYTESTCONST;
}

script.php:
<?php

include 'const.php';
echo MYTESTCONST, ', ', MyTestClass::RAND;
// 32, 154

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

Cannot redeclare someFunc()

В случае с классами ситуация простая: обычно мы не подключаем их в явном виде, а пользуемся автолоадером. Это означает, что если класс определён в фазе preload, то во время запроса автолоадер просто не выполнится и мы не будем пытаться подключить этот класс второй раз. 

С функциями ситуация иная: мы должны подключать их явно. Это может привести к ситуации, когда мы в preload-скрипте подключим все необходимые файлы с функциями, а во время запроса попытаемся сделать это снова (типичный пример — загрузчик Composer’а: он всегда будет пытаться подключить все файлы с функциями). В таком случае мы получим ошибку: функция уже была определена и переопределить её нельзя. 

Решить эту проблему можно по-разному. В случае с Composer’ом можно, например, подключить вообще всё в preload-фазе, а во время запросов не подключать вообще ничего, что относится к Composer’у. Другое решение — не подключать файлы с функциями напрямую, а делать это через прокси-файл с проверкой на function_exists(), как, например, делает Guzzle HTTP.

Пробуем preload (PHP 7.4) и RoadRunner - 6

PHP 7.4 ещё официально не вышел (пока)

Этот нюанс станет неактуальным через какое-то время, но пока версия PHP 7.4 ещё официально не вышла и команда PHP в release notes явно пишет: «Please DO NOT use this version in production, it is an early test version». Во время наших экспериментов с preload мы наткнулись на несколько багов, сами фиксили их и кое-что даже отправили в апстрим. Чтобы избежать неожиданностей, лучше дождаться официального релиза.

RoadRunner

RoadRunner — это демон, написанный на Go, который, с одной стороны, создаёт PHP-воркеры и следит за ними (запускает/завершает/перезапускает по мере необходимости), а с другой — принимает запросы и передаёт их на выполнение этим воркерам. В этом смысле его работа ничем не отличается от работы PHP-FPM (где тоже есть мастер-процесс, который следит за воркерами). Но отличия всё-таки есть. Ключевое заключается в том, что RoadRunner не обнуляет состояние скрипта после окончания выполнения запроса. 

Таким образом, если вспомнить наш список того, на что тратятся ресурсы в случае «классического» PHP, RoadRunner позволяет бороться со всеми пунктами (preload, как мы помним, — только с первым):

  • подключение файлов (include, require и т. п.);
  • инициализация (фреймворк, библиотеки, DI-контейнер и т. д.);
  • запрос данных из внешних хранилищ (вместо хранения в памяти).

Hello World-пример в случае с RoadRunner выглядит примерно так:

$relay = new SpiralGoridgeStreamRelay(STDIN, STDOUT);
$psr7 = new SpiralRoadRunnerPSR7Client(new SpiralRoadRunnerWorker($relay));

while ($req = $psr7->acceptRequest()) {
        $resp = new ZendDiactorosResponse();
        $resp->getBody()->write("hello world");
        $psr7->respond($resp);
}

Мы будем пытаться наш текущий endpoint, который мы тестировали с preload, без модификаций запустить на RoadRunner, нагружать его и измерять производительность. Без модификаций — потому что в противном случае бенчмарк будет не совсем честным.

Давайте попробуем адаптировать Hello World-пример для этого.

Во-первых, как я писал выше, мы не хотим, чтобы воркер падал в случае ошибки. Для этого нам нужно всё обернуть в глобальный try..catch. Во-вторых, поскольку наш скрипт ничего не знает о Zend Diactoros, для ответа нам нужно будет его результаты сконвертировать. Для этого воспользуемся ob_- функциями. В-третьих, наш скрипт ничего не знает о сущности PSR-7 запроса. Решение — заполнить стандартное PHP-окружение из этих сущностей. И в-четвёртых, наш скрипт рассчитывает на то, что запрос умрёт и всё состояние очистится. Поэтому с RoadRunner нам нужно будет эту очистку делать самостоятельно.

Таким образом, изначальный Hello World-вариант превращается примерно в такой:

while ($req = $psr7->acceptRequest()) {
    try {
        $uri = $req->getUri();

        $_COOKIE = $req->getCookieParams();
        $_POST = $req->getParsedBody();
        $_SERVER = [
            'REQUEST_METHOD' => $req->getMethod(),
            'HTTP_HOST' => $uri->getHost(),
            'DOCUMENT_URI' => $uri->getPath(),
            'SERVER_NAME' => $uri->getHost(),
            'QUERY_STRING' => $uri->getQuery(),

            // ...
        ];

        ob_start();

        // our logic here

        $output = ob_get_contents();
        ob_clean();
        
        $resp = new ZendDiactorosResponse();
        $resp->getBody()->write($output, 200);
        $psr7->respond($resp);
    } catch (Throwable $Throwable) {
        // some error handling logic here
    }

    UDSEvent::flush();
    PinbaClient::sendAll();
    PinbaClient::flushAll();
    HTTP::clear();
    ViewFactory::clear();
    Logger::clearCaches();
    
    // ...
}

Бенчмарки RoadRunner

Ну что, пришло время запускать бенчмарки.

Пробуем preload (PHP 7.4) и RoadRunner - 7

Результаты не соответствуют ожиданиям: RoadRunner позволяет нивелировать больше факторов, вызывающих потери производительности, чем preload, но результаты в итоге оказываются хуже. Давайте разбираться, почему так происходит, как всегда, запустив для этого perf.

Пробуем preload (PHP 7.4) и RoadRunner - 8

В результатах perf мы видим phar_compile_file. Это вызвано тем, что мы подключаем некоторые файлы во время выполнения скрипта и, так как OPcache не включён (RoadRunner запускает скрипты как CLI, где OPcache по умолчанию выключен), эти файлы компилируются заново при каждом запросе. 

Отредактируем конфигурацию RoadRunner — включим OPcache:

Пробуем preload (PHP 7.4) и RoadRunner - 9

Пробуем preload (PHP 7.4) и RoadRunner - 10

Эти результаты уже больше похожи на то, что мы ожидали увидеть: RoadRunner начал показывать бОльшую производительность, чем preload. Но, возможно, нам удастся получить ещё больше!

В perf вроде бы нет больше ничего необычного — давайте посмотрим на PHP-код. Самый простой способ профилировать его — это использовать phpspy: он не требует никакой модификации PHP-кода — нужно просто запустить его в консоли. Сделаем это и построим flame graph:

Пробуем preload (PHP 7.4) и RoadRunner - 11

Так как логику нашего приложения мы договорились не модифицировать для чистоты эксперимента, нас интересует ветка стеков, связанная с работой RoadRunner:

Пробуем preload (PHP 7.4) и RoadRunner - 12

Основная часть её сводится к вызову fread(), с этим вряд ли что-то можно сделать. Но мы видим какие-то ещё ветки в SpiralRoadRunnerPSR7Client::acceptRequest(), кроме самого fread. Понять их смысл можно, заглянув в исходный код:

   /**
     * @return ServerRequestInterface|null
     */
    public function acceptRequest()
    {
        $rawRequest = $this->httpClient->acceptRequest();
        if ($rawRequest === null) {
            return null;
        }

        $_SERVER = $this->configureServer($rawRequest['ctx']);

        $request = $this->requestFactory->createServerRequest(
            $rawRequest['ctx']['method'],
            $rawRequest['ctx']['uri'],
            $_SERVER
        );

        parse_str($rawRequest['ctx']['rawQuery'], $query);

        $request = $request
            ->withProtocolVersion(static::fetchProtocolVersion($rawRequest['ctx']['protocol']))
            ->withCookieParams($rawRequest['ctx']['cookies'])
            ->withQueryParams($query)
            ->withUploadedFiles($this->wrapUploads($rawRequest['ctx']['uploads']));

Становится понятно, что RoadRunner по рассериализованному массиву пытается создать объект PSR-7-совместимого запроса. Если ваш фреймворк работает с PSR-7-объектами запросов напрямую (например, Symfony не работает), то это вполне оправданно. В остальных случаях PSR-7 становится лишним звеном перед тем, как запрос будет сконвертирован в то, с чем может работать ваше приложение. Давайте уберём это промежуточное звено и посмотрим на результаты снова:

Пробуем preload (PHP 7.4) и RoadRunner - 13

Тестируемый скрипт был достаточно лёгким, поэтому удалось выжать ещё весомую долю производительности — +17% по сравнению с чистым PHP (напомню, что preload даёт +10% на том же скрипте).

Нюансы RoadRunner

В целом использование RoadRunner — это более серьёзное изменение, чем просто включение preload, поэтому нюансы здесь ещё более значимые.

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

Во-вторых, если мы хотим выжать из RoadRunner максимум, то недостаточно просто запустить «классический» код на нём — нужно изначально писать код под него. В таком случае мы избежим хаков с перегоном запроса/ответа между форматами RoadRunner и приложения; возможно, мы сразу будем писать код так, чтобы ему не потребовалась очистка в конце запроса, а также сможем в большем объёме использовать возможности общей памяти между запросами, например что-то кешируя в ней.

В-третьих, все результаты были получены на нашем конкретном endpoint’е, который, возможно, не лучший кандидат для запуска под RoadRunner. На других скриптах возможны абсолютно другие результаты.

Заключение

Итак, мы рассмотрели архитектуру «классического» PHP, разобрались с тем, как ей может помочь preload и чем от неё отличается архитектура RoadRunner.

PHP в «классическом» варианте использования (PHP-FPM, Apache mod_php и другие) помогает упростить разработку и избежать ряда проблем. В случае если бэкенд не имеет каких-то особых требований к производительности, это самый эффективный способ разработки из рассмотренных. Кроме того, придумано множество способов выжать из этого решения максимум производительности, к которым уже добавился preload и скоро добавится JIT.

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

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

  • PHP 7.2 — 845 RPS;
  • PHP 7.4 — 931 RPS;
  • RoadRunner без оптимизаций — 987 RPS; 
  • PHP 7.4 + preload — 1030 RPS;
  • RoadRunner после оптимизации — 1089 RPS. 

Мы в Badoo приняли решение о переходе на PHP 7.4 и сейчас адаптируем к нему наш код, чтобы перейти на него сразу после официального релиза (когда он станет достаточно стабильным). 

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

Спасибо за внимание!

Автор: Pavel Murzakov

Источник


* - обязательные к заполнению поля