Режим воркеров
Режим воркеров запускает постоянные PHP-воркеры, которые инициализируются один раз, а затем обрабатывают множество запросов, поэтому стоимость запуска PHP оплачивается однократно, а не при каждом запросе. Вместо того чтобы разрушать и заново выстраивать состояние PHP на каждый запрос, ваше приложение загружает автозагрузчик, конфигурацию и подключения к базе данных один раз и переиспользует их на протяжении всего времени жизни воркера.
Как это работает
- Включите режим воркеров. Установите
WORKER_MODE_ENABLED=trueи укажите вENTRY_FILEваш скрипт инициализации. Это включает режим воркеров для всех PHP-воркеров в пуле. - Инициализация один раз. PHP запускается и выполняет внешнюю область видимости один раз. Регистрация автозагрузчика, загрузка конфигурации, подключения к базе данных и любой другой код инициализации выполняются однократно.
- Вход в цикл обработки запросов. Вызовите
oxphp_worker(callback). OxPHP начинает направлять входящие HTTP-запросы в ваш колбэк. - Сброс между запросами. Суперглобальные переменные (
$_GET,$_POST,$_SERVER,$_COOKIE,$_FILES,$_REQUEST,php://input), буферы вывода, заголовки ответа, сессия, открытая запросом, и ini-директивы, изменённые запросом, сбрасываются автоматически. Что именно это покрывает и где заканчивается, см. в разделе Что сбрасывается, а что сохраняется. Мягкий сброс очищает состояние отдельного запроса, сохраняя при этом инициализированные ресурсы во внешней области видимости.$_ENV— намеренное исключение: при значении PHP по умолчаниюauto_globals_jit=1она не сбрасывается (см. Суперглобальные переменные). - Внешняя область видимости сохраняется. Переменные, определённые до
oxphp_worker(), статические свойства, подключения к базе данных и автозагрузчики остаются доступными для всех запросов, обрабатываемых этим воркером.
Режим воркеров меняет поведение маршрутизации. Все запросы, которые не соответствуют статическому файлу на диске, направляются воркеру вместо возврата 404. Подробнее см. Маршрутизация.
Конфигурация
| Переменная | По умолчанию | Описание |
|---|---|---|
WORKER_MODE_ENABLED |
false |
Включает постоянный режим воркеров. Принимает true, 1, yes. Требует, чтобы ENTRY_FILE указывал на .php-скрипт |
ENTRY_FILE |
(не задано) | Путь к скрипту инициализации воркера. Для относительных путей разрешается относительно DOCUMENT_ROOT; сегменты .. и абсолютные пути допускаются (расположение скриптов инициализации воркера за пределами публичного корня документов — поддерживаемая схема) |
WORKER_MAX_MEMORY_MIB |
0 |
Максимальный объём памяти PHP на воркер в МиБ до перезапуска. 0 = без ограничений |
Устаревшая переменная WORKER_FILE по-прежнему разбирается (с выводом WARN при запуске) и ведёт себя как WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE. В новых развёртываниях следует использовать явную пару переменных; устаревшая форма будет удалена в одном из будущих релизов.
Для перезапуска, управляемого приложением, вызовите OxPHP\Server\Worker::scheduleExit() внутри обработчика запроса. Воркер завершится корректно после того, как текущий запрос будет обработан.
Написание скрипта воркера
Скрипт воркера состоит из двух частей: внешней области видимости, которая выполняется один раз при запуске, и колбэка, передаваемого в oxphp_worker(), который выполняется при каждом запросе.
<?php
// Outer scope: runs once at startup
require __DIR__ . '/../vendor/autoload.php';
$config = parse_ini_file(__DIR__ . '/../config/app.ini');
$db = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_PERSISTENT => true,
]);
$app = new MyApp\Application($config, $db);
// Request loop: runs for every request
oxphp_worker(function () use ($app) {
$app->handle();
});
// Teardown: runs when this worker's loop ends, which is not
// only at server shutdown — see Recycling below
$app->terminate();Что сбрасывается, а что сохраняется
OxPHP выполняет мягкий сброс между запросами. Состояние отдельного запроса очищается автоматически, тогда как всё, что было инициализировано во внешней области видимости, сохраняется на протяжении всего времени жизни воркера.
- Суперглобальные переменные —
$_GET,$_POST,$_SERVER,$_COOKIE,$_FILES,$_REQUESTиphp://inputзаполняются заново данными нового запроса - Буферы вывода — все буферы вывода сбрасываются и очищаются
- Заголовки ответа — HTTP-код состояния и заголовки возвращаются к значениям по умолчанию
- Состояние ошибок — информация о последней ошибке (сообщение, файл, строка, тип) и статус соединения очищаются. Пользовательские обработчики ошибок (
set_error_handler()) и обработчики исключений (set_exception_handler()) сохраняются между запросами - Состояние сессии — сессия, открытая запросом, закрывается до следующего запроса воркера, а
$_SESSIONочищается. Границы описаны ниже - Ini-директивы, изменённые запросом — изменения через
ini_set(),ignore_user_abort()иerror_reporting()принадлежат запросу и откатываются к базовым значениям инициализации, когда он заканчивается. Границы описаны ниже
- Переменные во внешней области видимости — всё, что определено до
oxphp_worker()и захвачено черезuse - Статические свойства — статические свойства классов сохраняют свои значения
- Подключения к базе данных — PDO, MySQLi и другие постоянные соединения остаются открытыми
- Автозагрузчики — зарегистрированные автозагрузчики (Composer, собственные) остаются активными
- Загруженные классы и функции — все ранее загруженные классы, интерфейсы, трейты и функции
- Ini-директивы, заданные при инициализации —
ini_set()во внешней области видимости действует всё время жизни воркера, и именно к этим значениям откатываются изменения, сделанные в запросах $_ENV— намеренно не сбрасывается (при значении PHP по умолчаниюauto_globals_jit=1). Она описывает воркер, а не запрос. Благодаря этому значения, записанные загрузчиком.env, живут и после загрузки, которая их записала, — но столько же живёт и запись, сделанная внутри обработчика запроса: она видна каждому последующему запросу на этом воркере и любому запросу, мультиплексированному рядом с ним. См. Суперглобальные переменные
Состояние сессии
Сессия, открытая запросом, закрывается до того, как воркер возьмёт следующий: данные передаются обработчику сохранения, идентификатор сессии освобождается, а $_SESSION покидает таблицу символов. Поэтому следующий запрос начинает сессию под собственной cookie, получает '' от session_id(), пока не начнёт её, и до тех пор видит $_SESSION неопределённой. Стоит знать три границы:
- Сессия, в которой ещё находится другой запрос, закрывается в следующий момент простоя воркера, а не когда возвращается открывший её запрос. Сессия, в которой нет других запросов на воркере, закрывается по окончании запроса: после его shutdown-функций и до того, как будут возвращены его ini-изменения. Это тот же порядок, что и у собственного завершения запроса в PHP, поэтому запись выполняется с настройками этого запроса. Разделяемая сессия записывается с теми настройками, которые окажутся у потока в момент её возврата. До тех пор воркер держит сессию открытой, а обработчик сохранения держит всё, что заблокировал. В обработчике
filesпо умолчанию это эксклюзивная блокирующая блокировка файла сессии: запрос с тем же идентификатором сессии, пришедший куда-либо ещё (на другой воркер, в другой процесс), ждёт внутри обработчика сохранения, а поскольку это ожидание — блокирующий системный вызов в потоке воркера, оно останавливает все запросы, которые несёт этот воркер. Вызовитеsession_write_close(), как только данные сессии окончательны, — тогда и запись, и снятие блокировки произойдут там, где их поставило ваше приложение. - Сессия принадлежит потоку воркера, а не запросу, поэтому запросы, перекрывающиеся на одном воркере, разделяют её. Воркер берёт новые запросы всякий раз, когда выполняемый приостанавливается: в
oxphp_async_await(),oxphp_sleep(),oxphp_usleep()или на ожидании сокета подRUNTIME_HOOKS(чтение, а с категориейnet— ещё и установка соединения, TLS-рукопожатие или запись, которой приходится ждать места; установка соединения включаетnew PDO('mysql:…')иnew mysqli(...)). Он не может вернуть сессию, пока в ней может находиться любой из запросов, которые он несёт. Запрос, взятый в этом окне, получает её вместо этого:session_start()отвечаетtrue, не глядя на cookie нового запроса, а$_SESSION— это массив приостановленного запроса.session_write_close()тоже не разделяет их: она закрывает сессию, но оставляет её идентификатор установленным, так что запрос, взятый в этом окне, затем начинает сессию с этим идентификатором: он читает из хранилища данные приостановленного запроса и получает тот же идентификатор обратно в собственномSet-Cookie. Там, где приложение и использует сессии, и позволяет запросам приостанавливаться, полагаться следует на одно разделение: не оставлять сессию открытой через точку приостановки. - Запрос, который был приостановлен ещё до появления сессии, не считается находящимся в ней. Разделение отсчитывается с момента, когда запрос взят: в сессии находятся запрос, начавший её, и все запросы, взятые, пока она существует, и воркер держит сессию, пока жив хоть один из них. Запрос, приостановившийся раньше, в неё не входит — иначе один долгоживущий запрос, который вообще не трогает сессии, не давал бы воркеру вернуть ни одну сессию. Такой запрос всё же может прочитать
$_SESSIONпосле пробуждения, и прочитает он то, что существует в этот момент, — возможно, сессию другого клиента. Не читайте$_SESSIONпосле точки приостановки в запросе, который не начинал собственной сессии.
Закрывайте сессию через session_write_close() до любого oxphp_async_await(), oxphp_sleep() или перехваченного ожидания сокета. Сессия, открытая через точку приостановки, разделяется с тем запросом, который воркер возьмёт следующим.
Откат ini-директив
Всё, что запрос изменил через ini_set(), ignore_user_abort() или error_reporting(), принадлежит этому запросу, как это было бы под PHP-FPM: оно возвращается на место, когда запрос заканчивается. Большинство директив остаются собственностью запроса и пока он приостановлен на await, sleep или ожидании сокета: их снимают с воркера, так что запросы, которые воркер выполняет тем временем, видят базовые значения, а приостановленный запрос возобновляется со своими. ini_set('default_socket_timeout', 5) вокруг одного HTTP-вызова и ini_set('display_errors', '1') в отладочной ветке действуют для запроса, который их сделал, а не для следующего.
Базовые значения, к которым они восстанавливаются, — это то, что задала ваша инициализация, а не значение из php.ini: ini_set() во внешней области видимости — это конфигурация приложения, она переживает каждый запрос, и каждый запрос с неё начинается. Это касается и ignore_user_abort(true): вызванный при инициализации, он действует для каждого запроса, который обслуживает воркер, включая потоки (см. Server-Sent Events). Стоит знать семь границ:
memory_limitи настройкиsession.*остаются на воркере, пока у него в обработке есть другие запросы. Куча принадлежит потоку воркера, а не запросу, как и сессия, которую настраивают параметрыsession.*, включая обработчик сохранения, устанавливаемыйsession_set_save_handler(). Их не снимают с воркера, когда запрос приостанавливается, и возвращают на место только тогда, когда воркер берёт следующий запрос, не имея ничего другого в работе. Пока у воркера есть работа в обработке (всякий раз, когда запрос приостановлен, а также пока ещё не прибран fire-and-forget-промис), значение, заданное здесь одним запросом, действует для запросов, которые воркер берёт в этом окне.set_time_limit()задаёт лимит вызвавшего его запроса, ноini_get('max_execution_time')сообщает значение воркера. У каждого запроса собственный лимит времени (см. Дедлайны запросов), иset_time_limit(), а такжеini_set()илиini_restore()дляmax_execution_timeменяют именно его и никакой другой. Сама директива остаётся на воркере, как иmemory_limit:ini_get('max_execution_time')отвечает последним значением, которое задал любой запрос с тех пор, как у воркера в последний раз не было ничего в обработке (или значением инициализации, если ни один запрос его не задавал). На воркере, который несёт несколько запросов, это не обязательно собственный лимит вызывающего. Директива возвращается на место, когда воркер берёт следующий запрос, не имея ничего другого в работе.- Директива, обработчик которой делает больше, чем просто сохраняет значение, остаётся на воркере, пока её запрос приостановлен. Перенос директивы запускает её обработчик, а некоторые обработчики действуют при сохранении:
default_charsetи настройки кодировок сбрасывают кодировки mbstring,zlib.output_compressionзапускает обработчик вывода,open_basedirразрешает относительный путь относительно текущего каталога, а устаревшие настройкиassert.*каждый раз выдают предупреждение об устаревании. Если запускать их на каждой приостановке и возобновлении, они отменяли бы то, что запрос сделал в промежутке, поэтому такие директивы остаются на воркере: запросы, которые он выполняет, пока один приостановлен, видят значение приостановленного запроса, а директива возвращается на место, когда заканчивается последний запрос в обработке. Переносятся те директивы, которые сохраняются одним из стандартных сеттеров PHP:ignore_user_abort,precision,display_errors,error_reporting,default_socket_timeout,user_agentи большинство остальных. Несколько директив, которые только проверяют значение, всё равно остаются на воркере:include_pathпри включённом OPcache (включаяset_include_path(); с выключенным OPcache она переносится),error_log,date.timezoneиdefault_mimetype. То, что запрос задаёт функцией, а не ini-директивой (mb_internal_encoding(),date_default_timezone_set(),setlocale()), вообще не является ini-изменением. Оно принадлежит воркеру и переживает запрос, если запрос сам не вернёт его на место. - Директива, обработчик которой отказывается возвращать значение, остаётся такой, какой её оставил запрос. Такая директива сохраняет значение запроса до тех пор, пока воркер не возьмёт следующий запрос, не имея ничего другого в работе, а приостановленный запрос, изменение которого отвергнуто при возобновлении, продолжает работу без этого изменения.
- Запрос, завершённый в момент переноса одной из его директив, перезапускает воркер. Обработчик может выполнять код приложения: возврат
assert.callbackсбрасывает колбэк, а вместе с ним и всё, что колбэк удерживал, и деструкторы этого выполняются прямо там. Всё, что завершает запрос изнутри одного из них (фатальная ошибка или вывод в поток, клиент которого уже ушёл), оставляет обработчик на полпути — с колбэком, по-прежнему установленным и указывающим на наполовину разобранное замыкание. Исправить это может только новый воркер, поэтому воркер завершается сoxphp_worker_recycles_by_reason_total{reason="scheduled"}, а запросы, ещё находящиеся на нём в обработке, завершаются, как при любом другом перезапуске. Сам запрос учитывается в соответствии с тем, как он закончился: на фатал отвечают500, и он идёт в счётчик ошибок, ушедший клиент — это отмена, и она не считается. Исключение, выброшенное таким деструктором, вместо этого сообщается как необработанное исключение запроса и больше ничего не стоит. memory_limitвосстанавливается как значение раньше, чем как лимит. PHP отказывается опускать потолок аллокатора, пока занято больше нового лимита. Поэтому воркер, оставшийся с тем, что выделил запрос, сообщает черезini_get()восстановленныйmemory_limit, тогда как аллокатор всё ещё применяет повышенный. Потолок подтягивается, как только собственный объём памяти воркера оставляет для него место.opcache.enableне восстанавливается вовсе. Выключить OPcache — единственное, что запрос может с ним сделать (включить его обратно посреди запроса PHP отказывается), а снова поднимает его собственный по-запросный старт OPcache, который воркер выполняет один раз, при загрузке. Поэтому воркер, чей запрос выключил OPcache, до конца своей жизни компилирует каждый файл из исходников, и директива остаётся со значением0, чтобы об этом сообщать. Восстановление означало бы, чтоini_get('opcache.enable')иopcache_get_status()рапортуют о включённом кэше, который не работает, — вариант хуже, потому что приложению, которое спрашивает, чтобы что-то решить, сообщили бы противоположное тому, что происходит.
Перезапуск
Цикл перезапускаемого воркера заканчивается, а его PHP-состояние разбирается; займёт ли его место новый воркер, решает пул, как описано ниже. Воркеры автоматически перезапускаются при выполнении любого из следующих условий:
- Превышен лимит памяти — использование памяти PHP воркером превышает
WORKER_MAX_MEMORY_MIBМиБ - Приложение запросило завершение — обработчик вызвал
Worker::scheduleExit(). Полезно для управляемой приложением горячей перезагрузки, перезагрузки на основе mtime файлов или повторного выполнения инициализации на каждый запрос - Последовательные ошибки — воркер взял 3 подряд запроса, которые развалились: фатальная ошибка, нехватка памяти, переполнение стека или один из двух сбоев вокруг файбера, в котором выполняется запрос. Три подряд говорят о том, что виноват воркер, а не какой-то один запрос, и воркер, который продолжает разваливаться, заменяется, а не латается снова. Что считается, а что нет — см. ниже
- Запрос, завершённый внутри внутренней функции, оставил после себя много — фатальная ошибка или отмена (например, истёкший
max_execution_time), завершающая запрос внутри внутренней функции, оставляет после себя то, что эта функция выделила для себя, и воркер не может это вернуть:usort()сортирует копию массива, и копия удерживает каждый элемент. Воркер сравнивает использование памяти между двумя моментами, когда в обработке нет ни одного запроса. Если в промежутке какой-то запрос был завершён с вызовом внутренней функции на стеке, а использование выросло более чем на 4 МиБ, воркер перезапускается, и в лог пишется предупреждение с указанием причины. Запрос, завершённый ровно в тот момент, когда внутренняя функция вродеcurl_exec()возвращала управление, тоже учитывается, потому что лимит времени замечается, пока этот вызов ещё на стеке. Стеки файберов, созданных воркером за это время, не учитываются, поскольку он хранит их всю свою жизнь. Мусор, который может освободить только сборщик циклов, сначала собирается, но только тот, о котором сборщик уже знал: цикл, освобождённый лишь очисткой после аварийного выхода, ему никогда не передаётся, остаётся выделенным, учитывается и перезапускает воркер. В этот момент в обработке нет ни одного запроса, поэтому перезапуск ни один из них не завершает, а учитывается он вoxphp_worker_recycles_by_reason_total{reason="scheduled"}. ОтWORKER_MAX_MEMORY_MIBэто не зависит. Решает рост, а не вызов, поэтому учитывается и всё остальное, что увеличило память воркера за тот же промежуток: впервые заполненный кеш, ещё не освобождённое большое тело запроса. Чего эта проверка не ловит: утечку меньше примерно 4 МиБ, которая накапливается, пока не будет превышенWORKER_MAX_MEMORY_MIBили, без него, пока запросы не начнут падать наmemory_limitPHP; утечку в первом промежутке, когда воркер занят, — его не оценивают, потому что иначе то, что новый воркер строит для себя в первых запросах, перезапускало бы каждую замену по очереди, пока продолжают приходить таймауты; что-либо во время завершения работы сервера; и воркер, у которого никогда не бывает момента без запросов в обработке, — там проверка ждёт первого такого момента - Ошибка нехватки памяти оставила занятым больше
memory_limit— PHP формирует отчётAllowed memory size … exhausted, отложив лимит в сторону, поскольку отчёту нужна собственная память, а в PHP 8.5 этот отчёт включает трассировку каждого кадра на стеке (fatal_error_backtraces, по умолчанию включено). После неконтролируемой рекурсии эта трассировка может оказаться больше самого лимита: рекурсия глубиной 400 000 вызовов приmemory_limit=128Mоставила около 290 МиБ, учтённых как занятые. Пока аллокатор считает занятым больше лимита, он ни в чём не отказывает — ни одному запросу на воркере. PHP-FPM возвращает эту память, завершая запрос. Воркер запросов не завершает, поэтому он отдаёт память обратно и снова вводит лимит в действие, когда сбойный запрос заканчивается, после его shutdown-функций; запрос, который воркер обслуживает, пока один из таких запросов приостановлен (вoxphp_async_await(),oxphp_sleep()/oxphp_usleep()либо вsleep(),usleep()или ожидании сокета, перехваченныхRUNTIME_HOOKS), тоже работает без лимита. Если воркер и тогда удерживает больше лимита (например, shutdown-функция этого запроса сохранила столько), он перезапускается в ближайший момент без запросов в обработке, с предупреждением в логе, называющим причину, и учитывается вoxphp_worker_recycles_by_reason_total{reason="scheduled"}. Запросы, которые он обслуживает до этого, работают без действующегоmemory_limit, как правило вообще без лимита. В отличие от проверки выше, эта применяется с первого запроса воркера, но не во время завершения работы сервера. Поток асинхронного пула отдаёт память обратно, когда заканчивается сбойная задача, а если этого недостаточно — пробует снова по окончании каждой следующей задачи; он не перезапускается.fatal_error_backtraces=0убирает трассировку, а с ней и большую часть того, что занимает отчёт
Не каждый неудавшийся запрос считается, потому что не каждый сбой говорит о непригодности воркера к обслуживанию:
| Исход | Влияние на счётчик |
|---|---|
| Фатальная ошибка, нехватка памяти или переполнение стека, возникшие в обработчике запроса, в shutdown-функции или в деструкторе, выполняемом при освобождении этих shutdown-функций | Считается |
| Файбер, в котором выполняется запрос, не запустился: движок не смог выделить для него C-стек, поэтому обработчик так и не выполнился | Считается |
| Запрос не прекращал приостанавливать этот файбер из пользовательского кода, и сервер его завершил | Считается |
Необработанное исключение (ответ 500) — из обработчика запроса или из shutdown-функции |
Нейтрально |
Отменённый запрос: истёк max_execution_time, сервер завершает работу или отключился клиент потокового ответа, не вызвавшего ignore_user_abort(true) |
Нейтрально |
Запрос завершился, включая exit()/die() |
Обнуляет счётчик |
«Нейтрально» означает ровно это: такой исход посреди серии фаталов не добавляет к счётчику и не обнуляет его, так что fatal, exception, fatal, fatal всё равно перезапускает воркер. Сбой в shutdown-функции трактуется так же, как сбой в обработчике запроса. PHP выполняет shutdown-функции под собственной защитой, поэтому для воркера запрос, сломавшийся внутри одной из них, возвращается нормально. Фатал там — это всё равно запрос, развалившийся сам по себе, поэтому он считается так же, а исключение оттуда разматывается так же чисто, как исключение из обработчика, поэтому так же нейтрально.
Счётчик определяется тем, почему запрос закончился, а не тем, что он после себя оставил. Он отвечает на вопрос, виноват ли этот воркер, а запрос, который оборвал сам сервер, не свидетельствует против него. Поэтому отмена нейтральна, где бы она ни наступила — в обработчике (дедлайн, истёкший посреди рендеринга, завершение работы сервера) или в shutdown-функции, — при условии, что запрос до этого ещё не получил фатал. Дедлайн, истёкший в shutdown-функции обработчика, который уже развалился, оставляет этот сбой засчитанным. Завершение работы сервера — единственная отмена, нейтральная безусловно, потому что воркер, который и так уходит, не оценивается.
Вне потокового ответа отключение клиента в режиме воркеров не является отдельным исходом. Оно не завершает запрос, который волен закончиться и выполнить собственную очистку, и затем запрос трактуется по тому, как он закончился: дошёл до конца — счётчик обнуляется, фатал — считается, необработанное исключение — нейтрально. Это сделано намеренно. Запрос, выполнивший обработчик до конца, свидетельствует о том, что воркер способен обслуживать, независимо от того, остался ли кто-нибудь, чтобы прочитать ответ, а воркер, который фаталит на каждом запросе, сфаталит и на этом, так что уходящий клиент не может сохранить такой воркер в живых.
Две строки про файбер считаются по той же причине, что и фатал: запрос не дошёл до конца собственной работы, и оборвал его не сервер. Файбер не запускается, когда движок не может выделить C-стек, на котором он работает (fiber.stack_size задан ниже платформенного минимума в две страницы памяти или процесс не может отобразить больше памяти), и до обработчика дело не доходит. Второй случай — дело рук самого приложения: сервер сам управляет файбером запроса, поэтому пользовательский Fiber::suspend() на нём отвергается с FiberError, а обработчик, который перехватывает это и повторяет попытку в цикле, завершается, а не получает возможность повторять бесконечно. Это завершение нельзя перехватить из пользовательского кода, но оно всё равно выполняет блоки finally, деструкторы и shutdown-функции запроса и всё равно отправляет то, что запрос произвёл, с тем статусом, который задал обработчик. Строка WARN для засчитанного сбоя называет, какой из двух это был: fiber would not start или fiber refused to park.
Чего всё это не делает — так это не диагностирует воркер, который завис, а не сломался: запрос, застрявший в системном вызове, попадает в oxphp_worker_stuck_total, чтобы оператор мог отреагировать, — он не отменяется и не считается. Ничто в сервере не завершает такой запрос автоматически (ни перезапуска, ни отмены), поэтому зависший воркер остаётся зависшим, пока вызов не вернётся или процесс не перезапустится. max_execution_time здесь тоже не страховка: лимит времени завершает запрос только там, где выполняется PHP-код, а запрос, застрявший в системном вызове, не доходит до него, пока вызов не вернётся (см. Дедлайны запросов).
Когда воркер перезапускается, его цикл заканчивается, а замена повторно выполняет внешнюю область видимости скрипта воркера. Воркеры — это потоки ОС внутри единственного процесса OxPHP, а не отдельные процессы, поэтому с точки зрения операционной системы ничего не перезапускается, и getmypid() возвращает одно и то же значение в каждом воркере, до и после. Появится ли замена сразу, зависит от пула:
- Статический пул (
PHP_WORKERS=N) при следующем обходе дополняется доN, так что замена происходит один к одному. - Динамический пул (
PHP_WORKERS=MIN:MAX) дополняется только доMIN: воркер, перезапущенный сверх минимума, оставляет пул на один меньше, пока обычное масштабирование вверх снова его не нарастит.
При перезапуске по памяти и по запланированному завершению текущий запрос обрабатывается штатно до конца, после чего воркер завершается. При перезапуске из-за ошибок воркер завершается после сбойного запроса.
Перезапуск по памяти и запланированный перезапуск логируются на уровне INFO. Воркер пишет Worker mode thread recycled с полем reason, равным метке, под которой его учитывает oxphp_worker_recycles_by_reason_total: max_memory — вместе с memory_bytes (использование, превысившее лимит) и max_memory_bytes (сам лимит) — или scheduled, которая охватывает Worker::scheduleExit() и перезапуски, которые воркер планирует для себя сам. Следом пул пишет Recycled workers detected. Воркер, перезапущенный после последовательных ошибок, логируется на уровне WARN как Worker mode thread retired after consecutive request failures, а пул сообщает о нём, как о любом потоке воркера, завершившемся не по собственному выбору, строкой Dead workers detected, respawning.
Поэтому при LOG_LEVEL=warn перезапуск по WORKER_MAX_MEMORY_MIB или по Worker::scheduleExit() не оставляет собственной строки. Воркер, который часто перезапускается по памяти, — признак утечки, которую стоит поискать, и увидеть это можно по скорости роста oxphp_worker_recycles_by_reason_total{reason="max_memory"}.
Динамический пул, выводящий простаивающий воркер (см. Динамический пул), тоже заканчивает цикл этого воркера и тоже выполняет код после oxphp_worker(), но уменьшает пул, а не заменяет воркер: на его место ничего не запускается, ни oxphp_worker_recycles_total, ни oxphp_worker_recycles_by_reason_total не меняются, а учитывается это в oxphp_workers_retired_total. Поэтому завершающий код приложения, размещённый после oxphp_worker(), выполняется и в здоровом, обслуживающем пуле (см. oxphp_worker()).
Другие запросы, которые тот же воркер обслуживал конкурентно — приостановленные в oxphp_async_await(), oxphp_sleep() или на ожидании сокета под RUNTIME_HOOKS, — закончить не успевают: каждый отменяется там, где приостановлен, и получает ответ 503 Service Unavailable с Retry-After, после выполнения собственных shutdown-функций. Перезапуск, таким образом, виден клиентам, чьи запросы оказались в обработке, — это стоит учитывать, выбирая WORKER_MAX_MEMORY_MIB или вызывая scheduleExit() на воркере, обслуживающем конкурентные запросы. Полное завершение работы сервера устроено иначе: там выполняющиеся запросы получают окно слива, чтобы завершиться нормально.
Дедлайны запросов
Каждый запрос, который обслуживает воркер, ограничен max_execution_time сам по себе — независимо от того, взял ли его воркер, не имея ничего другого в работе, или когда уже нёс другие. При мультиплексировании файберов воркер выполняет несколько запросов в одном потоке, и каждый по-прежнему получает весь свой лимит, и не больше:
- Лимит запроса отсчитывается с момента, когда воркер его берёт. Он начинается со значения
max_execution_time, оставленного инициализацией воркера (значение изphp.iniили то, что задала внешняя область видимости скрипта воркера). Как и везде в потокобезопасной сборке PHP в OxPHP, он отсчитывает реальное время, включая время, проведённое в приостановке (см. Таймауты). set_time_limit()действует на вызвавший его запрос. Он перезапускает лимит этого запроса от момента вызова, как и под PHP-FPM, и оставляет все остальные запросы на воркере как есть. Вместе с ним сдвигается только собственный дедлайн запроса; директива, которую записывает вызов, остаётся на воркере (см. границы в разделе Откат ini-директив).- Когда лимит истекает, он завершает запрос, которому принадлежит. Этот запрос трактуется как исчерпавший время:
504, бит таймаута в его собственномconnection_status()для его shutdown-функций, нейтрально для счётчика перезапусков. Запросы, выполняющиеся рядом с ним, продолжают работу. - Приостановленный запрос завершается, когда возобновляется, а не в момент, когда проходит его дедлайн. Запрос, ожидающий в
oxphp_async_await(),oxphp_sleep()/oxphp_usleep()или вsleep()либо ожидании сокета (чтение, а сnet— установка соединения, TLS-рукопожатие или запись, ждущая места), перехваченныхRUNTIME_HOOKS, не выполняет PHP-код, а лимит времени может завершить только выполняющийся код. Запрос, чей лимит прошёл, пока он ждал, завершается, как только снова оказывается в PHP-коде: когда вызов, в котором он ждал, возвращает управление, запрос завершается прямо там, как при любом другом таймауте, включая его shutdown-функции. Вызов, который ждёт один раз (sleep, одиночное чтение), возвращает управление при возобновлении; вызов, который внутри себя ждёт снова и снова, напримерstream_get_contents(), читающая поток до конца, возвращает управление, только когда завершается целиком. Запрос возобновляется, когда приходит то, чего он ждёт (промис разрешается, sleep истекает, сокет становится доступным для чтения, для записи — если запись ждёт места, или соединённым — при установке соединения), когда ожидание достигает своего таймаута или когда сервер его отменяет. Для чтения или записи этот таймаут — собственный таймаут потока (default_socket_timeout, еслиstream_set_timeout()не задал другой); для установки соединения — таймаут, переданный вstream_socket_client()илиfsockopen(). Завершение работы сервера, прошедшее свой дедлайн слива, и перезапуск воркера отменяют все запросы, приостановленные в этот момент, и запрос, чей лимит уже прошёл, завершается этой отменой, как и остальные:503, с выполнением его shutdown-функций, которые истёкший лимит не обрывает. - То, что воркер выполняет между запросами, не ограничено никаким лимитом. Сессия, которую воркер возвращает, когда в ней больше не остаётся ни одного запроса, записывается через свой обработчик сохранения вне какого-либо запроса, и деструкторы объектов, освобождаемых периодической сборкой мусора воркера, тоже выполняются вне какого-либо запроса. Лимит времени не ограничивает ничего из этого.
Больше ничто не завершит приостановленный запрос за вас. Отключение клиента не завершает непотоковый запрос, который по замыслу выполняется до конца (см. Перезапуск), а запрос, работающий дольше порога супервизора, попадает в oxphp_worker_stuck_total, но не отменяется.
Ставьте лимит на само ожидание: передавайте таймаут в oxphp_async_await() и её варианты и задавайте таймауты для сокетов и клиентов баз данных.
Перезагрузка при разработке
Режим воркеров хранит состояние инициализации (автозагрузчик, DI-контейнер, подключения к БД) в памяти, поэтому одного лишь opcache.validate_timestamps=1 недостаточно, чтобы подхватить изменения в коде, выполнявшемся во внешней области видимости. Для циклов разработки есть два варианта:
- Перезапуск на каждый запрос. Вызывайте
OxPHP\Server\Worker::current()->scheduleExit()в конце каждого вызова обработчика (например, ограничив это env-флагомOXPHP_DEV). Текущий запрос обрабатывается штатно до конца, затем воркер завершается, а замена повторно выполняет внешнюю область видимости. На статическом пуле, который обычно и используется при разработке, это происходит немедленно. Это меняет выигрыш в производительности от режима воркеров на семантику перезагрузки в стиле FPM. Это самый простой и надёжный подход для активной разработки. - Держите воркер «тёплым», перезагружайте обработчики запросов. Полностью откажитесь от
scheduleExit(), включитеopcache.validate_timestamps=1и держите инициализацию минимальной. Код, загруженный внутри колбэка запроса, будет обновлён OPcache при следующем запросе; код, загруженный один раз во внешней области видимости, — нет. Полный список нюансов см. в OPcache и JIT → Настройки для разработки.
Устранение неполадок
Запросы зависают и никогда не завершаются
Если oxphp_worker() никогда не вызывается в скрипте инициализации, ни один запрос не направляется, и каждый запрос ждёт бесконечно. Убедитесь, что ваш скрипт вызывает oxphp_worker() безусловно в нормальном пути выполнения кода.
Состояние протекает между запросами
Переменные, определённые внутри колбэка oxphp_worker(), очищаются сборщиком мусора PHP, но статические свойства и глобальные переменные, определённые во внешней области видимости, сохраняются. Если вы видите, что данные из одного запроса появляются в другом, проверьте статические свойства или глобальные переменные, накапливающие состояние между вызовами.
Решение: явно сбрасывайте статическое состояние в начале каждого колбэка запроса или не храните состояние отдельного запроса в статических свойствах.
Воркер немедленно перезапускается (лимит памяти)
Лимит памяти воркера проверяется после каждого запроса на основе данных PHP об использовании памяти. Если фаза инициализации выделяет большой объём памяти (например, загружает большой кеш), начальный объём занимаемой памяти может уже быть близок к лимиту.
Решение: увеличьте WORKER_MAX_MEMORY_MIB или отложите крупные выделения памяти до первого запроса.
Проверка: при LOG_LEVEL=info каждый такой перезапуск логирует использование, превысившее лимит, как memory_bytes, рядом с самим лимитом как max_memory_bytes. Каждая предыдущая проверка находила использование в пределах лимита, поэтому разница между ними — не больше того, на сколько воркер вырос с последней проверки перед этой: небольшая соответствует постепенному росту, большая — единичному скачку. У воркера, который перезапускается после первого же запроса, этот скачок включает всё, что выделила инициализация.
docker logs <container> 2>&1 | grep '"reason":"max_memory"'Воркер немедленно перезапускается (лимит ошибок)
Три засчитанных сбоя подряд вызывают перезапуск. Обычно это фатальная ошибка в колбэке запроса, поэтому сначала проверьте логи вашего приложения на предмет фаталов. Необработанные исключения не считаются, а отменённые запросы считаются, только если на отменённом запросе был также зафиксирован фатал.
Перезапуск без фатала за ним — это один из двух сбоев файбера, и строка WARN называет, какой именно: fiber would not start указывает на fiber.stack_size или на хост (этот C-стек отображается из операционной системы, а не из кучи PHP, поэтому ни memory_limit, ни WORKER_MAX_MEMORY_MIB для него не рычаг), а fiber refused to park — на Fiber::suspend(), который ваш собственный код продолжает повторять.
Сервер пишет одну строку WARN на каждый засчитанный сбой, называя воркер, источник сбоя и сколько сбоев было подряд, и ещё одну — на следующий за третьим перезапуск. Счётчик у каждого воркера свой: три строки от одного воркера — это перезапуск, три строки от трёх воркеров — нет.
Проверка: ищите ошибки и предупреждения в структурированном выводе логов:
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'Подключение к базе данных обрывается после простоя
Если ваш сервер базы данных закрывает простаивающие соединения, попытки переподключения в следующем запросе могут завершиться неудачей. Используйте пул соединений, который обрабатывает переподключение, или перехватывайте исключение и переподключайтесь вручную.
Пример для Docker
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
environment:
- DOCUMENT_ROOT=/var/www/html/public
- WORKER_MODE_ENABLED=true
- ENTRY_FILE=/var/www/html/worker.php
- WORKER_MAX_MEMORY_MIB=128PHP API
Интроспекция воркера и точка входа воркера доступны через класс OxPHP\Server\Worker.
<?php
$worker = OxPHP\Server\Worker::current();
$worker->serve(function () {
handleRequest();
});Устаревшие свободные функции (oxphp_is_worker, oxphp_worker_id, oxphp_worker) по-прежнему доступны и работают через то же внутреннее состояние. В новом коде следует предпочитать API класса.
Класс также предоставляет интроспекцию во время выполнения, полезную для корректного самоперезапуска, наблюдаемости и проверок работоспособности:
| Метод | Возвращает |
|---|---|
Worker::isWorkerMode(): bool |
Работает ли сервер в режиме воркеров |
$worker->id(): int |
Стабильный идентификатор воркера в рамках потока |
$worker->startTime(): float |
Unix-метка времени запуска этого воркера |
$worker->requestCount(): int |
Количество запросов, обработанных этим воркером |
$worker->memoryUsage(): int |
Текущее значение memory_get_usage(true) для этого воркера |
$worker->rss(): int |
Текущий размер резидентной памяти в байтах (Linux/macOS) |
$worker->maxMemoryBytes(): int |
Порог перезапуска — WORKER_MAX_MEMORY_MIB × 1 МиБ, или 0 при отсутствии ограничений |
$worker->isExitScheduled(): bool |
Ожидается ли завершение с причиной "scheduled": был вызван scheduleExit() или воркер сам вывел себя из работы |
$worker->exitReason(): ?string |
null во время работы; "scheduled", "max_memory" или "error", когда воркер завершается |
Полные сигнатуры и разобранные примеры см. в OxPHP\Server\Worker.
Примеры на PHP
Определение режима воркеров
Используйте OxPHP\Server\Worker::isWorkerMode(), чтобы проверить, работает ли текущий процесс в режиме воркеров. Это полезно для написания кода, который работает как в традиционном режиме, так и в режиме воркеров.
<?php
if (OxPHP\Server\Worker::isWorkerMode()) {
// Reuse a persistent connection
$redis = new Redis();
$redis->pconnect('redis', 6379);
} else {
// Traditional mode: connect per request
$redis = new Redis();
$redis->connect('redis', 6379);
}Скрипт воркера для Symfony
<?php
use App\Kernel;
require __DIR__ . '/../vendor/autoload.php';
$kernel = new Kernel('prod', false);
$kernel->boot();
oxphp_worker(function () use ($kernel) {
$request = Symfony\Component\HttpFoundation\Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
});
$kernel->shutdown();Рекомендации
- Задайте
WORKER_MAX_MEMORY_MIB(например,128), чтобы утекающий воркер перезапускался автоматически, а не поглощал ресурсы хоста. Дополните этоWorker::scheduleExit()для перезапуска, управляемого приложением. Воркер может расти, даже когда в приложении нет утечек: когда фатальная ошибка или отмена бросает запрос внутри внутренней функции, то, что эта функция выделила для себя, не возвращается. Например, запрос, отменённый посредиusort(), удерживает весь сортируемый массив, и воркер, выросший таким образом более чем на 4 МиБ, перезапускается, как описано в разделе Перезапуск. Меньшие утечки остаются, пока не будет превышенWORKER_MAX_MEMORY_MIBили, без него, пока запросы не начнут падать наmemory_limit. Фатальная ошибка или отмена, завершающая запрос внутри выполняемого имFiber(запущенного им самим или того, в котором PHP выполняет деструкторы при сборке мусора), точно так же оставляет выделенным то, что удерживали собственные кадры файбера, на всё время жизни воркера. - Закрывайте сессию до точки приостановки. Сессии принадлежат потоку воркера, поэтому сессия, оставленная открытой через
oxphp_async_await()илиoxphp_sleep(), разделяется со следующим запросом, который возьмёт воркер. См. Состояние сессии. - Не храните состояние отдельного запроса в статических свойствах или глобальных переменных. Поскольку они сохраняются между запросами, оставшееся состояние от одного запроса может протечь в другой.
- Проверяйте мягкий сброс на раннем этапе. Добавьте
Worker::current()->scheduleExit()в ваш обработчик под флагом разработки и прогоните приложение целиком, от начала до конца. Это ловит ошибки протекания состояния до того, как вы перейдёте на долгоживущие воркеры. - Обрабатывайте таймауты простоя базы данных. Если ваш драйвер базы данных отключается после периода простоя, перехватывайте исключение и переподключайтесь или используйте пул соединений, который обрабатывает переподключение автоматически.
- Держите внешнюю область видимости минимальной. Инициализируйте только то, что действительно должно сохраняться: автозагрузчики, конфигурацию и разделяемые сервисы. Отложите настройку, специфичную для запроса, в колбэк.
Промпты для вашего агента
Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.
Переведи моё приложение в режим воркеров OxPHP. Прочитай страницу `features/worker-mode` через MCP-сервер документации OxPHP.
Проверь мою инициализацию и обработчики запросов на состояние, которое переживает границу запроса или разделяется запросами, перекрывающимися на воркере (включая сессии, оставленные открытыми через точку приостановки), и покажи мне правки, после которых этот репозиторий безопасно запускать в долгоживущем воркере.См. также
- Маршрутизация — как режим воркеров встраивается в маршрутизацию URL
- Ранний ответ — отправить ответ немедленно и продолжить фоновую обработку
- Функции PHP — полный справочник по
oxphp_worker(),oxphp_is_worker()и другим встроенным функциям - Справочник по конфигурации — полный список переменных окружения