Skip to content

Режим воркера

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

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

Режим воркера — это режим SAPI Worker; вместе с Classic он готов уже сегодня. Эта страница — руководство по программированию для него. Особый фреймворк режиму воркера не нужен: достаточно приложения, которое переживёт единственный запуск и множество запросов после него, а на это способно большинство современных фреймворков. Все четыре режима и то, от чего зависит доступный приложению режим, описывают Режимы выполнения, а руководства по конкретным фреймворкам собраны в разделе Фреймворки.

Резидентный цикл

Скрипт воркера состоит из трёх частей: всё, что вы поднимаете в начале файла, обработчик, отвечающий на один запрос, и цикл, который вызывает обработчик, пока сервер не остановится. Цикл написан на PHP и построен вокруг объекта-обработчика, который Rapira возвращает скрипту.

php
<?php
// worker.php
require __DIR__ . '/vendor/autoload.php';

use Rapira\Plugin\Http\HttpHandlerConfig;
use function Rapira\create_plugin_handler;

$http = create_plugin_handler(new HttpHandlerConfig());
$app = new App(); // booted once, reused for every request

$handler = static function () use ($app): void {
    header('Content-Type: text/plain');
    http_response_code(200);
    echo $app->handle($_SERVER['REQUEST_URI']);
};

while ($http->handleRequest($handler)) {
    gc_collect_cycles();
}

Режим воркера у rapira serve включён по умолчанию, поэтому достаточно направить сервер на скрипт; классический режим включается явно:

bash
rapira serve app/worker.php

Остальные флаги собраны в разделе Командная строка, а их аналоги для rapira.toml — в Конфигурации.

Что делает handleRequest()

handleRequest(callable $handler) — это весь контракт целиком:

  • Блокируется до тех пор, пока воркеру не достанется запрос. Пока воркер ждёт в handleRequest(), процессор он не расходует и при этом продолжает держать в памяти свой интерпретатор и поднятое приложение.
  • Заполняет суперглобальные переменные$_GET, $_POST, $_SERVER, $_COOKIE и всё остальное семейство — данными пришедшего запроса, заново и до того, как выполнится ваш обработчик. Обычный PHP-код, который их читает, работает точно так же, как под php-fpm.
  • Вызывает ваш обработчик без единого аргумента. Всё о запросе уже лежит в суперглобальных переменных, поэтому сигнатура колбэка — function (): void. Всё остальное, что ему нужно — контейнер, приложение, логгер, — захватывайте через use.
  • Ответом становится ваш вывод. echo, print, header(), http_response_code(), setcookie() — обработчик формирует ответ ровно так же, как классический скрипт. Как связаны данные запроса и вывод ответа, разбирает раздел HTTP.
  • Возвращает true, когда запрос закончен, то есть «продолжаем», и false, когда сервер останавливается. На этом и держится цикл: как только пришло false, выходите из него и дайте скрипту завершиться.

Получается, что запрос в режиме воркера — это одна итерация вашего цикла while. Rapira закрывает запрос вокруг обработчика: отрабатывают shutdown-функции и деструкторы, буферы вывода сбрасываются и обнуляются, сессия записывается и закрывается, а суперглобальные переменные наполняются заново к следующей итерации. Всё, что скрипт держит за пределами обработчика, при этом остаётся нетронутым.

Один обработчик на воркер

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

Значит, скрипт воркера обслуживает ровно один обработчик за раз. Если написать два цикла подряд, до второго дело не дойдёт, пока не завершится первый, а первый завершится только тогда, когда handleRequest() вернёт false, то есть когда сервер уже останавливается. Разводить запросы по разным веткам кода должен сам обработчик, внутри себя, а не несколько циклов.

php
while ($http->handleRequest($api)) {
}

// unreachable until shutdown
while ($http->handleRequest($web)) {
}

Что живёт между запросами

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

Всё, что вы создаёте внутри обработчика, — обычная работа на один запрос: она освобождается, когда обработчик возвращает управление и запрос разбирается.

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

Общим оказывается и глобальное состояние, планировали вы это или нет: статические свойства, синглтоны, реестры, которые библиотека лениво наполняет по ходу дела, ini_set(), который так и не отменили. Под php-fpm всё это жило ровно один запрос, потому что завершение запроса в PHP сбрасывало и статику, и глобальные переменные, и ini_set(). Воркер Rapira этот сброс между запросами намеренно пропускает, так что они сохраняются. Приложение, которое не может отказаться от глобального состояния, запускается в классическом режиме: классический режим лишает вас прогретого приложения, которое воркер держит в памяти, но остаётся заменой php-fpm без единой правки, а перейти на воркер приложение сможет позже, когда состояние будет распутано.

Выбор плагина

create_plugin_handler() принимает объект конфигурации, и плагин выбирается по классу этого объекта. HttpHandlerConfig означает, что этот воркер обслуживает HTTP, и в ответ вы получаете HttpHandler.

Исключение Rapira\RapiraException она бросает в двух случаях: когда переданному классу конфигурации не соответствует ни один плагин и когда скрипт вообще запущен не в режиме воркера — в классическом режиме резидентного цикла нет, так что обработчику там осталось бы только сообщать об остановке.

Конфигурация ещё и описывает, к чему она относится: в $http->config->info лежит Rapira\PluginInfo с полями name и description (для HTTP-плагина это http и HTTP request handler):

php
$http = create_plugin_handler(new HttpHandlerConfig());

echo $http->config->info->name;        // http
echo $http->config->info->description; // HTTP request handler

Как следить за воркером через getInfo()

$http->getInfo() возвращает Rapira\Plugin\Http\RuntimeInfo — счётчики самого этого воркера, снятые в момент вызова:

ПолеЧто это
statestarting, idle, active, draining или free — см. ниже
pidИдентификатор процесса этого воркера
queuedСколько запросов прямо сейчас ждёт в очереди этого воркера
handledСколько запросов воркер довёл до конца
errorsСколько из них закончились ошибкой
recyclesСколько раз воркеру пришлось пересобирать состояние после аварийного выхода PHP
restartsСколько раз пришлось пересоздавать сам PHP-поток воркера

Пять состояний описывают, где воркер находится в своём жизненном цикле: starting — мастер-процесс его форкнул, а он ещё не отчитался; idle — стоит и ждёт запроса, считаясь свободным резервом; active — обрабатывает запрос; draining — он уже на выходе (исчерпал квоту запросов или был помечен нездоровым) и в резерв больше не входит; free — за слотом вообще не закреплён воркер.

Обратите внимание: queued — это текущая глубина очереди, а не накопленный итог, и все счётчики относятся только к этому процессу. Они обнуляются при старте воркера, поэтому пришедший на замену воркер считает с нуля заново.

На этих счётчиках можно построить небольшой эндпоинт со статусом:

php
$handler = static function () use ($http): void {
    $info = $http->getInfo();
    header('Content-Type: application/json');
    echo json_encode([
        'pid' => $info->pid,
        'state' => $info->state,
        'queued' => $info->queued,
        'handled' => $info->handled,
        'errors' => $info->errors,
    ]);
};

Подводные камни

Состояние протекает между запросами. Приложение, которое ведёт себя странно в воркере, но не под php-fpm, обычно протекает состоянием между запросами. Растущий статический массив, объект запроса, осевший в синглтоне, логгер, который держит контекст прошлого пользователя, — каждая такая мелочь проявляется только на втором запросе. Прибирайтесь явно, в начале или в конце обработчика, и сбрасывайте всё, что оставила после себя библиотека. pool.max_requests заставляет воркер завершиться после N запросов, чтобы мастер-процесс заменил его свежим: это ограничивает ущерб от медленной утечки, но не устраняет её.

Несобранные циклические ссылки. Подсчёт ссылок в PHP освобождает почти всё сразу, но циклические ссылки достаются сборщику циклов и ждут его запуска. Вызов gc_collect_cycles() на каждой итерации цикла — как в скрипте выше — не обязателен, но собирает их в предсказуемый момент: между запросами, а не посреди одного из них.

Запросы, которые никогда не заканчиваются. Воркер, застрявший в зависшем запросе, остаётся в нём сколь угодно долго и всё это время больше ничего не обрабатывает. pool.request_terminate_timeout_secs задаёт предел по реальному времени на один запрос и убивает воркер, который в него не уложился. Этот ключ и pool.max_requests описаны в Конфигурации, а что делает мастер-процесс, когда воркер умирает, рассказывает Модель процессов.

Непойманное исключение затрагивает запрос, а не воркер. Непойманное исключение в обработчике попадает в счётчик errors, а клиент получает 500 — если, конечно, обработчик не успел зафиксировать статус до того, как бросил. В любом случае цикл продолжается, так что исключение не уносит воркер с собой. С фатальной ошибкой иначе: она сворачивает резидентный скрипт, поэтому воркер выполняет его заново с самого начала и снова поднимает приложение. Именно это и считает счётчик recycles.

Работа после ответа. Если нужно отдать ответ и продолжить работу — разобрать очередь, записать событие в аудит, — ровно это и делает rapira_finish_request(). Она описана на странице HTTP.

Стаб-файл для IDE

Все классы и функции, которые Rapira отдаёт в PHP, объявлены в crates/php_sys/rapira.stub.php. Это авторитетное описание API — сигнатуры, типы свойств, назначение каждого класса, — и одновременно стаб для IDE: положите его в проект, и редактор начнёт подсказывать create_plugin_handler(), handleRequest() и всё остальное вместо того, чтобы подчёркивать их как неизвестные.