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, свои пулы соединений.
Базовый пример
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() в родителе:
- Спавнит
Async\ThreadPoolнужного размера. - Через
transfer_objкопирует config + набор обработчиков в каждый воркер. - Внутри воркера запускает event-loop, который re-bind'ит listeners.
- Родитель
awaitит завершение всех воркеров.
Graceful shutdown
HttpServer::stop() работает на pool-родителе. Он выводит из строя всю когорту и приостанавливается, пока сервер действительно не остановится: когда он возвращает управление, воркеры дренированы, пул разобран, а listen-сокеты закрыты. Вызывайте его из корутины; обычное место — обработчик сигнала:
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-родителя.
Вручную его вызывают редко. Вместо этого подключите триггер:
$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):
$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. Это даёт две важные семантики:
Async\request_context()общий контекст по всему дереву корутин запроса (handler и дочерниеspawn'ы).Async\current_context()остаётся per-coroutine.
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-логирование:
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.
$config->setWorkers(\Async\available_parallelism());
Async\available_parallelism()возвращает число CPU, доступных процессу (учитывает cgroup quota и affinity). Backed byuv_available_parallelismс fallback наuv_cpu_info.
См. также
HttpServerConfig::setWorkers()HttpServerConfig::setBootloader()- Наблюдаемость: cross-worker статистика, логирование под пулом
Async\ThreadPool: внутренности пулаAsync\request_context()- Backpressure / drain