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

Режим воркерів запускає постійні PHP-воркери, які ініціалізуються один раз, а потім обробляють багато запитів, тож ціну запуску PHP ви сплачуєте один раз, а не на кожному запиті. Замість того щоб руйнувати й заново відбудовувати стан PHP для кожного запиту, ваш застосунок один раз завантажує автозавантажувач, конфігурацію та зʼєднання з базою даних і використовує їх упродовж усього життя воркера.

Як це працює

  1. Увімкніть режим воркерів. Задайте WORKER_MODE_ENABLED=true і вкажіть в ENTRY_FILE шлях до свого скрипта ініціалізації. Це вмикає режим воркерів для всіх PHP-воркерів пулу.
  2. Ініціалізація один раз. PHP запускається й один раз виконує зовнішню область видимості. Реєстрація автозавантажувача, завантаження конфігурації, зʼєднання з базою даних та будь-який інший код ініціалізації виконуються лише один раз.
  3. Вхід у цикл запитів. Викличте oxphp_worker(callback). OxPHP починає передавати вхідні HTTP-запити вашому колбеку.
  4. Скидання між запитами. Суперглобальні змінні ($_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, $_REQUEST, php://input), буфери виводу, заголовки відповіді, сесія, яку відкрив запит, і ini-директиви, які запит змінив, скидаються автоматично. Що саме це охоплює і де воно закінчується, описано в розділі Що скидається і що зберігається. Мʼяке скидання очищує стан окремого запиту, зберігаючи ініціалізовані ресурси в зовнішній області видимості. $_ENV — навмисний виняток: за типового для PHP auto_globals_jit=1 вона не скидається (див. Суперглобальні змінні).
  5. Зовнішня область видимості зберігається. Змінні, визначені до oxphp_worker(), статичні властивості, зʼєднання з базою даних і автозавантажувачі лишаються доступними для всіх запитів, які обробляє цей воркер.
Note

Режим воркерів змінює поведінку маршрутизації. Усі запити, які не збігаються зі статичним файлом на диску, передаються воркеру, а не отримують 404. Докладніше — у розділі Маршрутизація.

Налаштування

Змінна Типове значення Опис
WORKER_MODE_ENABLED false Вмикає постійний режим воркерів. Приймає true, 1, yes. Потребує, щоб ENTRY_FILE вказувала на скрипт .php
ENTRY_FILE (не задано) Шлях до скрипта ініціалізації воркера. Відносний шлях розвʼязується відносно DOCUMENT_ROOT; сегменти .. та абсолютні шляхи дозволені (скрипти ініціалізації воркера поза публічним коренем документів — підтримувана структура)
WORKER_MAX_MEMORY_MIB 0 Максимальний обсяг памʼяті PHP на воркер у MiB, після якого воркер перезапускається. 0 — без обмежень
Перехід з WORKER_FILE

Застаріла змінна WORKER_FILE досі розбирається (з попередженням WARN під час запуску) і діє як WORKER_MODE_ENABLED=true ENTRY_FILE=$WORKER_FILE. У нових розгортаннях використовуйте явну пару змінних; застарілу форму буде вилучено в одному з майбутніх випусків.

Щоб перезапуском керував сам застосунок, викличте OxPHP\Server\Worker::scheduleExit() з обробника запиту. Воркер коректно завершиться після того, як поточний запит буде оброблено.

Написання скрипта воркера

Скрипт воркера складається з двох частин: зовнішньої області видимості, яка виконується один раз під час запуску, і колбека, переданого в oxphp_worker(), який виконується для кожного запиту.

worker.php
<?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: після завершення запиту значення повертаються. Більшість директив також належать запиту, поки він призупинений на очікуванні, сні чи очікуванні на сокеті: їх знімають із воркера, тож запити, які воркер тим часом виконує, бачать базові значення, а призупинений запит продовжується зі своїми. 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 MiB
  • Застосунок запросив завершення — обробник викликав Worker::scheduleExit(). Корисно для гарячого перезавантаження під керуванням застосунку, перезавантаження за mtime файлів або повторного виконання ініціалізації для кожного запиту
  • Послідовні помилки — воркер узяв 3 послідовні запити, які розвалилися: фатальна помилка, вичерпання памʼяті, переповнення стека або одна з двох збоїв, повʼязаних із fiber, у якому виконується запит. Три поспіль свідчать, що винен воркер, а не якийсь окремий запит, і воркер, який постійно розвалюється, замінюють, а не латають знову. Що зараховується, а що ні, описано нижче
  • Запит, завершений усередині внутрішньої функції, лишив по собі багато — фатальна помилка або скасування (скажімо, коли минув max_execution_time), що завершує запит усередині внутрішньої функції, лишає по собі те, що ця функція виділила для себе, і воркер не може цього повернути: usort() сортує копію масиву, і ця копія тримає живим кожен елемент. Воркер порівнює використання памʼяті між двома моментами, коли жоден запит не в роботі. Якщо в проміжку якийсь запит було завершено з викликом внутрішньої функції на стеку, а використання зросло більш ніж на 4 MiB, воркер перезапускається з попередженням у журналі, яке називає причину. Запит, завершений саме тоді, коли внутрішня функція на кшталт curl_exec() повернула керування, теж зараховується, бо ліміт часу помічається, поки цей виклик ще на стеку. Стеки fiber, які воркер створив за цей час, не враховуються, оскільки він тримає їх, доки живе. Сміття, яке може звільнити лише збирач циклів, збирається спершу, але тільки те, про що збирач уже знав: цикл, який звільнило лише прибирання після bailout, йому ніколи не передається, лишається виділеним, враховується й спричиняє перезапуск воркера. У цей момент жоден запит не в роботі, тож жоден ним не завершується, а перезапуск враховується в oxphp_worker_recycles_by_reason_total{reason="scheduled"}. Він не залежить від WORKER_MAX_MEMORY_MIB. Вирішує зростання, а не виклик, тож враховується й усе інше, що збільшило памʼять воркера за той самий проміжок: кеш, заповнений уперше, велике тіло запиту, ще не звільнене. Чого ця перевірка не ловить: витоку, меншого приблизно за 4 MiB, який накопичується, доки не буде перевищено WORKER_MAX_MEMORY_MIB або, без нього, доки запити не почнуть падати на memory_limit PHP; витоку в перший проміжок, коли воркер зайнятий, — його не оцінюють, бо інакше те, що свіжий воркер будує для себе в перших запитах, перезапускало б по черзі кожного замінника, поки тайм-аути й далі надходять; будь-чого під час завершення роботи сервера; і воркера, у якого ніколи не буває моменту без запиту в роботі, — там перевірка чекає на перший такий момент
  • Помилка вичерпання памʼяті лишила у використанні більше за memory_limit — PHP будує звіт Allowed memory size … exhausted, відклавши ліміт убік, бо звіту потрібна власна памʼять, а в PHP 8.5 цей звіт містить трасування стека з кожним фреймом (fatal_error_backtraces, за замовчуванням увімкнено). Після неконтрольованої рекурсії таке трасування може бути більшим за сам ліміт: рекурсія глибиною 400 000 викликів за memory_limit=128M лишила близько 290 MiB, облікованих як використані. Поки алокатор вважає використаним більше за ліміт, він нічого не відхиляє — для жодного запиту на воркері. 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-функцій Зараховується
Fiber, у якому виконується запит, не запустився: рушій не зміг виділити для нього C-стек, тож обробник так і не виконався Зараховується
Запит не припиняв призупиняти цей fiber з userland, і сервер його завершив Зараховується
Неперехоплений виняток (відповідь 500) — з обробника запиту або з shutdown-функції Нейтрально
Скасований запит: минув max_execution_time, сервер завершує роботу, або клієнт потокової відповіді, яка не викликала ignore_user_abort(true), відʼєднався Нейтрально
Запит завершено, включно з exit()/die() Скидає лічильник

«Нейтрально» означає саме це: такий результат посеред серії фатальних помилок не збільшує лічильник і не скидає його, тож fatal, exception, fatal, fatal усе одно перезапускає воркер. Збій у shutdown-функції оцінюється так само, як збій в обробнику запиту. PHP виконує shutdown-функції під власним захистом, тож воркер бачить запит, що зазнав збою в одній із них, як такий, що повернувся нормально. Фатальна помилка там — це однаково запит, що розвалився сам, тож вона зараховується так само, а виняток там розгортається так само чисто, як з обробника, тож він так само нейтральний.

Лічильник визначається тим, чому запит завершився, а не тим, що він по собі залишив. Він відповідає на питання, чи винен цей воркер, а запит, який обірвав сам сервер, не є доказом проти нього. Тому скасування нейтральне, хоч би де воно сталося — в обробнику (дедлайн, що минув посеред рендерингу, завершення роботи сервера) чи в shutdown-функції, — за умови, що запит ще не зазнав фатальної помилки. Дедлайн, що минає в shutdown-функції обробника, який уже розвалився, лишає той збій зарахованим. Завершення роботи сервера — єдине скасування, нейтральне безумовно, бо воркер, що йде з роботи, не оцінюється.

Поза потоковою відповіддю відʼєднання клієнта в режимі воркерів не є окремим результатом. Воно не завершує запит, який може спокійно закінчити роботу й виконати власне прибирання, а потім запит оцінюється за тим, як він завершився: дійти до кінця — скидає лічильник, фатальна помилка — зараховується, неперехоплений виняток — нейтральний. Це зроблено навмисно. Запит, що виконав свій обробник до кінця, доводить, що воркер здатен обслуговувати, незалежно від того, чи лишився хтось, щоб прочитати відповідь, а воркер, що падає з фатальною помилкою на кожному запиті, впаде й на цьому, тож клієнт, що пішов, не може зберегти такому воркеру життя.

Два рядки про fiber зараховуються з тієї ж причини, що й фатальна помилка: запит не дійшов до кінця власної роботи, і сервер його не обривав. Fiber не запускається, коли рушій не може виділити C-стек, на якому той працює (fiber.stack_size задано нижче мінімуму платформи у дві сторінки памʼяті або процес не може відобразити більше памʼяті), і обробник так і не виконується. Інший випадок — справа рук самого застосунку: сервер сам керує fiber запиту, тож Fiber::suspend() з userland у ньому відхиляється з FiberError, а обробник, що перехоплює це й повторює спробу в циклі, завершують, а не дозволяють повторювати без кінця. Таке завершення не можна перехопити з userland, але воно все одно виконує блоки 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 окремо, незалежно від того, чи воркер узяв його, не маючи нічого іншого в роботі, чи тоді, коли вже ніс інші. За мультиплексування fiber воркер виконує кілька запитів в одному потоці, і кожен із них усе одно отримує свій ліміт повністю — і не більше:

  • Ліміт запиту відлічується від моменту, коли воркер його бере. Він починається зі значення 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-функціями. Виклик, що чекає один раз (сон, одне читання), повертається, коли його відновлено; той, що чекає знову й знову всередині, як-от stream_get_contents(), що читає потік до кінця, повертається лише тоді, коли завершиться повністю. Запит відновлюється, коли надходить те, на що він чекає (проміс виконується, сон минає, сокет стає доступним для читання, або для запису — коли запис чекає на місце в буфері, або підключеним — коли встановлюється зʼєднання), коли очікування досягає свого тайм-ауту або коли сервер його скасовує. Для читання чи запису цей тайм-аут — власний тайм-аут потоку (default_socket_timeout, якщо stream_set_timeout() не задав іншого); для встановлення зʼєднання — тайм-аут, переданий у stream_socket_client() або fsockopen(). Завершення роботи сервера, яке минає свій дедлайн дренування, і перезапуск воркера скасовують кожен запит, призупинений у цей момент, і запит, чий ліміт уже минув, завершується цим скасуванням так само, як інші: 503, із виконаними shutdown-функціями, які вичерпаний ліміт не обриває.
  • Те, що воркер виконує між запитами, не обмежене жодним лімітом. Сесія, яку воркер повертає, коли в ній уже немає жодного запиту, записується через обробник збереження поза будь-яким запитом, і деструктори обʼєктів, які звільняє періодичне збирання сміття воркера, теж виконуються поза будь-яким запитом. Жоден ліміт часу нічого з цього не обмежує.

Ніщо інше призупинений запит за вас не завершить. Відʼєднання клієнта не завершує непотоковий запит, який за задумом виконується до кінця (див. Перезапуск), а про запит, що працює довше за поріг супервізора, повідомляє oxphp_worker_stuck_total, але його не скасовують.

Tip

Ставте ліміт на саме очікування: передавайте тайм-аут в oxphp_async_await() та його варіанти й задавайте тайм-аути для сокетів і клієнтів баз даних.

Перезавантаження під час розробки

Режим воркерів зберігає стан ініціалізації (автозавантажувач, DI-контейнер, зʼєднання з БД) у памʼяті, тож самого opcache.validate_timestamps=1 недостатньо, щоб підхопити зміни в коді, який виконався в зовнішній області видимості. Для циклу розробки є два варіанти:

  • Перезапуск після кожного запиту. Викликайте OxPHP\Server\Worker::current()->scheduleExit() наприкінці кожного виклику обробника (наприклад, за прапорцем середовища 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. Кожна попередня перевірка бачила використання в межах ліміту, тож різниця між цими двома значеннями — щонайбільше те, на скільки воркер виріс від останньої перевірки перед цією: невелика відповідає поступовому зростанню, велика — одному стрибку. На воркері, що перезапускається після першого запиту, цей стрибок включає все, що виділила ініціалізація.

bash
docker logs <container> 2>&1 | grep '"reason":"max_memory"'
Воркер перезапускається одразу (ліміт помилок)

Три послідовні зараховані збої спричиняють перезапуск. Зазвичай це фатальна помилка в колбеку запиту, тож насамперед шукайте їх у журналах застосунку. Неперехоплені винятки не зараховуються, а скасовані запити зараховуються, лише якщо для скасованого запиту також було зафіксовано фатальну помилку.

Перезапуск без фатальної помилки за ним — це один із двох збоїв fiber, і рядок WARN називає, який саме: fiber would not start вказує на fiber.stack_size або на хост (цей C-стек відображається з операційної системи, а не з купи PHP, тож ні memory_limit, ні WORKER_MAX_MEMORY_MIB на нього не впливають), а fiber refused to park вказує на Fiber::suspend(), який ваш власний код раз у раз повторює.

Сервер записує один рядок WARN на кожен зарахований збій, називаючи воркер, джерело збою й кількість збоїв поспіль, і ще один — для перезапуску, що настає після третього. Лічильник ведеться для кожного воркера окремо: три рядки від одного воркера означають перезапуск, три рядки від трьох воркерів — ні.

Перевірка: шукайте помилки й попередження в структурованому виводі журналу:

bash
docker logs <container> 2>&1 | grep -E '"level":"(ERROR|WARN)"'
Зʼєднання з базою даних обривається після простою

Якщо сервер бази даних закриває неактивні зʼєднання, спроби перепідключитися на наступному запиті можуть завершитися невдачею. Використовуйте пул зʼєднань, який сам обробляє перепідключення, або перехоплюйте виняток і перепідключайтеся вручну.

Приклад для Docker

compose.yaml
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=128

PHP API

Інтроспекцію воркера та точку входу воркера надає клас OxPHP\Server\Worker.

php
<?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 MiB або 0, якщо без обмежень
$worker->isExitScheduled(): bool Чи очікує на виконання вихід із причиною "scheduled": викликано scheduleExit() або воркер сам вивів себе з роботи
$worker->exitReason(): ?string null, поки воркер працює; "scheduled", "max_memory" або "error", щойно воркер завершується

Повні сигнатури та робочі приклади — на сторінці OxPHP\Server\Worker.

Приклади на PHP

Визначення режиму воркерів

Використовуйте OxPHP\Server\Worker::isWorkerMode(), щоб перевірити, чи працює поточний процес у режимі воркерів. Це корисно для коду, який має працювати і в традиційному режимі, і в режимі воркерів.

php
<?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

worker.php
<?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 MiB, перезапускається, як описано в розділі Перезапуск. Менші витоки лишаються, доки не буде перевищено WORKER_MAX_MEMORY_MIB або, без нього, доки запити не почнуть падати на memory_limit. Фатальна помилка або скасування, що завершує запит усередині Fiber, який він виконує (запущеного ним самим або того, в якому PHP виконує деструктори збирання сміття), так само лишає виділеним те, що утримували власні фрейми цього fiber, до кінця життя воркера.
  • Закривайте сесію перед точкою призупинення. Сесії належать потоку воркера, тож сесія, лишена відкритою через oxphp_async_await() або oxphp_sleep(), стає спільною з наступним запитом, який візьме воркер. Див. Стан сесії.
  • Не зберігайте стан окремого запиту в статичних властивостях чи глобальних змінних. Оскільки вони зберігаються між запитами, залишковий стан одного запиту може протекти в інший.
  • Перевіряйте мʼяке скидання якомога раніше. Додайте Worker::current()->scheduleExit() до свого обробника під прапорцем розробки й проженіть застосунок від початку до кінця. Так ви виявите помилки з витоком стану, перш ніж перейти на довготривалі воркери.
  • Обробляйте тайм-аути простою бази даних. Якщо драйвер бази даних відʼєднується після періоду простою, перехоплюйте виняток і перепідключайтеся або використовуйте пул зʼєднань, який обробляє перепідключення автоматично.
  • Тримайте зовнішню область видимості мінімальною. Ініціалізуйте лише те, що справді має зберігатися: автозавантажувачі, конфігурацію та спільні сервіси. Налаштування, специфічні для запиту, перенесіть у колбек.

Промпти для вашого агента

Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.

prompt
Переведи мій застосунок у режим воркерів OxPHP. Прочитай сторінку `features/worker-mode` через MCP-сервер документації OxPHP. Перевір мою ініціалізацію та обробники запитів на стан, який переживає запити або є спільним для запитів, що перекриваються на одному воркері (включно із сесіями, лишеними відкритими через точку призупинення), а потім покажи мені зміни, які зроблять цей репозиторій безпечним для роботи в постійному воркері.

Див. також