TrueAsync ServerTrueAsync Server: multi-worker и bootloader

Multi-worker

(PHP 8.6+, true_async_server 0.6+)

TrueAsync Server по умолчанию работает в single-threaded режиме: один event-loop, один поток, весь pipeline (accept → parse → dispatch → respond) на одном CPU. Это самая быстрая модель для типичных IO-bound нагрузок, но не масштабируется по ядрам.

setWorkers(N) поднимает встроенный пул из N OS-потоков через Async\ThreadPool. Каждый воркер re-bind'ит те же listeners, ядро (Linux/BSD) распределяет accept через SO_REUSEPORT. У каждого воркера свой независимый event-loop, свой opcache, свои пулы соединений.

Базовый пример

php
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;

$server = new HttpServer(
    (new HttpServerConfig())
        ->addListener('0.0.0.0', 8080)
        ->setWorkers(4)
);

$server->addHttpHandler(function ($req, $res) {
    $res->json(['pid' => getmypid()]);
});

$server->start();   // блокирует, пока все воркеры не завершатся

HttpServer::start() в родителе:

  1. Спавнит Async\ThreadPool нужного размера.
  2. Через transfer_obj копирует config + набор обработчиков в каждый воркер.
  3. Внутри воркера запускает event-loop, который re-bind'ит listeners.
  4. Родитель awaitит завершение всех воркеров.

Graceful shutdown

HttpServer::stop() работает на pool-родителе. Он выводит из строя всю когорту и приостанавливается, пока сервер действительно не остановится: когда он возвращает управление, воркеры дренированы, пул разобран, а listen-сокеты закрыты. Вызывайте его из корутины; обычное место — обработчик сигнала:

php
use function Async\spawn;
use function Async\await;
use function Async\signal;
use Async\Signal;

spawn(function () use ($server) {
    await(signal(Signal::SIGTERM));

    $server->stop();       // возвращает управление, когда пул действительно остановлен
});

$server->start();

На standalone-сервере (setWorkers(1), значение по умолчанию) stop() не приостанавливается: его обычно вызывают из обработчика запроса, а shutdown-drain ждёт именно этот обработчик — так что блокирующий stop() там ждал бы сам себя.

Hot reload

HttpServer::reload() заменяет когорту воркеров без разрыва соединений: воркеры доделывают то, что держат, останавливаются и выходят, а свежие воркер-потоки заново прогоняют bootloader — подхватывая изменённый код — и берут работу на тех же listen-сокетах. Он приостанавливается, пока старая когорта не дренируется; start() всё это время продолжает работать. Только для pool-родителя.

Вручную его вызывают редко. Вместо этого подключите триггер:

php
$config
    ->setWorkers(4)
    ->setBootloader(function () {
        require __DIR__ . '/app/bootstrap.php';   // заново прогоняется в каждом свежем воркере
    })

    // разработка: следить за деревом и перезагружаться, когда изменения устаканятся
    ->enableHotReload([__DIR__ . '/app'], ['php'], debounceMs: 300, maxHoldMs: 2000)

    // production: перезагрузка по SIGHUP, который шлёт deploy-скрипт
    ->enableReloadOnSignal();

enableHotReload() следит за каждым путём рекурсивно. Устоявшийся всплеск изменений инвалидирует отслеживаемые деревья в opcache и вызывает reload(). debounceMs — окно тишины перед тем, как всплеск запустит один reload; maxHoldMs форсирует reload не позже, чем через это время после первого изменения, так что каталог, который никогда не затихает, всё равно перезагрузится. enableReloadOnSignal() ставит постоянный SIGHUP-обработчик (на Windows не поддерживается).

Оба — только в pool-режиме. Каким бы ни был триггер, код, который подхватят новые воркеры, — это то, что загружает bootloader, поэтому всё, что вы хотите перезагружать, должно загружаться там, а не в начале entry-скрипта, который выполняется один раз в родителе и больше никогда.

Если вы вызываете reload() вручную, сначала инвалидируйте изменённые файлы (opcache_invalidate()) или полагайтесь на timestamp-валидацию opcache — иначе свежие воркеры скомпилируют старый код.

Bootloader

Тяжёлая инициализация воркера (autoload, прогрев пулов, JIT-warmup) должна выполняться один раз при старте, а не на каждый запрос. Для этого есть setBootloader(?\Closure $cb):

php
$config
    ->setWorkers(4)
    ->setBootloader(function () {
        // выполняется в каждом воркере один раз перед таск-loop
        require __DIR__ . '/vendor/autoload.php';

        // прогрев пула соединений
        Database::initPool(min: 4, max: 16);

        // прекомпиляция критических роутов
        Router::compile();
    });

Замыкание deep-copy'ится один раз и запускается на каждом воркере до того, как тот начинает принимать задачи. Брошенное в bootloader исключение фейлит весь пул: воркер не стартует.

Применяется только при setWorkers() > 1. null снимает bootloader.

Требует TrueAsync ABI v0.15+. Тест: server/core/021-bootloader.phpt.

Per-request scope

С 0.6.5 каждая handler-корутина выполняется в собственном scope, дочернем для серверного scope. Это даёт две важные семантики:

php
use function Async\spawn;
use function Async\await;
use function Async\request_context;

$server->addHttpHandler(function ($req, $res) {
    // Контекст видит вся ветка корутин запроса
    request_context()->set('request_id', $req->getHeader('X-Request-Id') ?? bin2hex(random_bytes(8)));
    request_context()->set('user_id', authUser($req));

    // Fan-out
    [$user, $posts] = await(\Async\await_all([
        spawn(fn() => fetchUser()),   // request_id виден здесь
        spawn(fn() => fetchPosts()),  // и здесь
    ]));

    $res->json(['user' => $user, 'posts' => $posts]);
});

Сравните: current_context() создаёт значения, видимые только в текущей корутине; request_context() даёт общий sub-tree, привязанный к scope запроса.

Дочерний scope стоит двух аллокаций на запрос. setRequestScope(false) убирает его и переиспользует scope соединения напрямую — но тогда request_context() возвращает null, так что если вы его отключаете, тянитесь к ?->.

SO_REUSEPORT и балансировка

На Linux/BSD ядро равномерно (но недетерминированно) распределяет входящие соединения по всем сокетам, открытым с SO_REUSEPORT на тот же (host, port). Каждый воркер открывает свой; никакой userspace-load-balancer не нужен, никаких блокировок.

На Windows SO_REUSEPORT-эквивалент менее предсказуем; перенесите балансировку выше (LB) либо используйте single-worker + N процессов с разными портами.

Cross-thread transfer обработчиков

Если конфигурация поднимается в одном потоке, а сервер запускается в другом, HttpServer поддерживает transfer. С 0.2.0 transfer-путь корректно переносит маски протоколов (баг "silently dropped every request" исправлен; см. CHANGELOG core/007-server-transfer-handler-dispatch.phpt).

Отладка многопоточного режима

Loud-логирование на неожиданный exit воркера добавлено в 0.6.3. Uncaught $server->start() исключения и clean returns пока await-loop ещё ждёт воркеров теперь видны в stderr (раньше каждый случай тихо ронял 1/N accept-capacity без сигнала оператору).

Включите INFO-логирование:

php
use TrueAsync\LogSeverity;

$config->setLogSinks([
    ['type' => 'stderr', 'format' => 'pretty', 'level' => LogSeverity::INFO],
]);

Не используйте setLogStream() под пулом воркеров. PHP stream-ресурс, открытый родителем, не может перейти в воркер-поток: sink остаётся активным на родителе и пропускается в воркерах, с уведомлением при старте. Используйте sink, который каждый воркер может открыть сам — stderr, stdout или file (каждый воркер переоткрывает путь в режиме append). См. Наблюдаемость.

Сколько воркеров?

Правило большого пальца:

  • IO-bound (стандартный web с БД/HTTP): начинать с available_parallelism(), смотреть на CPU util.
  • CPU-bound (рендеринг, compression-heavy, big JSON): available_parallelism() или меньше, смотреть на p99 latency.
  • Mixed: оверкоммит на 1–2 воркера (N+1 или N+2) часто даёт лучшую утилизацию ядер на IO-stall.
php
$config->setWorkers(\Async\available_parallelism());

Async\available_parallelism() возвращает число CPU, доступных процессу (учитывает cgroup quota и affinity). Backed by uv_available_parallelism с fallback на uv_cpu_info.

См. также