Multi-worker
(PHP 8.6+, true_async_server 0.6+)
Per impostazione predefinita TrueAsync Server funziona in modalità single-threaded: un event loop, un thread, tutta la pipeline (accept → parse → dispatch → respond) su un'unica CPU. È il modello più veloce per i tipici carichi IO-bound, ma non scala sui core.
setWorkers(N) avvia un pool integrato di N thread OS tramite Async\ThreadPool. Ogni worker rifa il bind degli stessi listener; il kernel (Linux/BSD) distribuisce gli accept tramite SO_REUSEPORT. Ciascun worker ha il proprio event loop indipendente, il proprio opcache e i propri pool di connessioni.
Esempio di base
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(); // blocca finché tutti i worker non terminanoHttpServer::start() nel processo padre:
- Crea un
Async\ThreadPooldella dimensione richiesta. - Tramite
transfer_objcopia config + set degli handler in ogni worker. - All'interno del worker avvia l'event loop, che rifa il bind dei listener.
- Il padre fa
awaitdel completamento di tutti i worker.
Arresto graceful
HttpServer::stop() funziona sul processo padre di un pool. Ritira l'intera coorte e sospende finché il server non è davvero spento — quando ritorna, i worker hanno drenato, il pool è smantellato e i listen socket sono chiusi. Chiamalo da una coroutine; un signal handler è il posto abituale:
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(); // ritorna una volta che il pool è davvero spento
});
$server->start();Su un server standalone (setWorkers(1), il default) stop() non sospende: viene normalmente chiamato da un request handler, e il drain di shutdown attende proprio quell'handler — quindi uno stop() bloccante lì aspetterebbe se stesso.
Hot reload
HttpServer::reload() sostituisce la coorte di worker senza far cadere una connessione: i worker finiscono ciò che stanno tenendo, si fermano ed escono, e nuovi thread worker rieseguono il bootloader — raccogliendo il codice modificato — e subentrano sugli stessi listen socket. Sospende finché la vecchia coorte non ha drenato; start() continua a girare per tutto il tempo. Solo processo padre del pool.
Raramente lo chiami tu stesso. Collega piuttosto un trigger:
$config
->setWorkers(4)
->setBootloader(function () {
require __DIR__ . '/app/bootstrap.php'; // rieseguito in ogni worker nuovo
})
// sviluppo: osserva l'albero e ricarica quando si assesta
->enableHotReload([__DIR__ . '/app'], ['php'], debounceMs: 300, maxHoldMs: 2000)
// produzione: ricarica su SIGHUP, che è ciò che invia uno script di deploy
->enableReloadOnSignal();enableHotReload() osserva ricorsivamente ogni percorso. Una raffica di modifiche assestata invalida gli alberi osservati in opcache e chiama reload(). debounceMs è la finestra di quiete prima che una raffica scateni un reload; maxHoldMs forza un reload al più dopo quel tempo dalla prima modifica, così una directory che non si acquieta mai si ricarica comunque. enableReloadOnSignal() arma un handler SIGHUP persistente (non supportato su Windows).
Entrambi sono solo per la modalità pool. Qualunque sia il trigger, il codice che i nuovi worker raccolgono è quello che il bootloader carica — quindi tutto ciò che vuoi ricaricare deve essere caricato lì, non in cima allo script di ingresso, che gira una sola volta nel padre e mai più.
Se chiami
reload()a mano, invalida prima i file modificati (opcache_invalidate()) oppure affidati alla validazione dei timestamp di opcache — altrimenti i worker nuovi compilano il vecchio codice.
Bootloader
L'inizializzazione pesante del worker (autoload, riscaldamento dei pool, JIT warmup) va eseguita una sola volta all'avvio, non a ogni richiesta. A questo serve setBootloader(?\Closure $cb):
$config
->setWorkers(4)
->setBootloader(function () {
// eseguito in ogni worker una sola volta prima del task loop
require __DIR__ . '/vendor/autoload.php';
// riscaldamento del pool di connessioni
Database::initPool(min: 4, max: 16);
// precompilazione delle route critiche
Router::compile();
});La closure viene deep-copiata una volta e avviata in ogni worker prima che inizi ad accettare task. Un'eccezione nel bootloader fa fallire l'intero pool: il worker non parte.
Si applica solo quando setWorkers() > 1. null rimuove il bootloader.
Richiede TrueAsync ABI v0.15+. Test:
server/core/021-bootloader.phpt.
Scope per richiesta
Dal 0.6.5 ogni coroutine handler viene eseguita nel proprio scope, figlio dello scope del server. Questo dà due semantiche importanti:
Async\request_context()fornisce un contesto comune a tutto l'albero di coroutine della richiesta (handler espawnfigli).Async\current_context()resta per coroutine.
use function Async\spawn;
use function Async\await;
use function Async\request_context;
$server->addHttpHandler(function ($req, $res) {
// Il contesto è visibile a tutto il ramo di coroutine della richiesta
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 visibile qui
spawn(fn() => fetchPosts()), // e qui
]));
$res->json(['user' => $user, 'posts' => $posts]);
});Confronto: current_context() crea valori visibili solo nella coroutine corrente; request_context() fornisce un sottoinsieme comune, legato allo scope della richiesta.
Lo scope figlio costa due allocazioni per richiesta. setRequestScope(false) lo elimina e riusa direttamente lo scope della connessione — ma allora request_context() restituisce null, quindi ricorri a ?-> se lo disattivi.
SO_REUSEPORT e bilanciamento
Su Linux/BSD il kernel distribuisce in modo uniforme (ma non deterministico) le connessioni in ingresso tra tutti i socket aperti con SO_REUSEPORT sulla stessa coppia (host, port). Ogni worker apre il proprio; non serve un bilanciatore userspace, niente lock.
Su Windows l'equivalente di SO_REUSEPORT è meno prevedibile; sposta il bilanciamento più a monte (LB) oppure usa single-worker + N processi su porte diverse.
Trasferimento cross-thread degli handler
Se la configurazione viene preparata in un thread e il server avviato in un altro, HttpServer supporta il trasferimento. Dal 0.2.0 il percorso di trasferimento porta correttamente le maschere di protocollo (il bug "silently dropped every request" è risolto; vedi CHANGELOG core/007-server-transfer-handler-dispatch.phpt).
Debug della modalità multi-thread
Il logging rumoroso sulla terminazione inattesa di un worker è stato aggiunto nel 0.6.3. Le eccezioni non catturate da $server->start() e i clean return mentre il loop di await sta ancora aspettando i worker sono ora visibili in stderr (prima ogni caso faceva cadere silenziosamente 1/N della capacità di accept senza segnalarlo all'operatore).
Abilita il logging INFO:
use TrueAsync\LogSeverity;
$config->setLogSinks([
['type' => 'stderr', 'format' => 'pretty', 'level' => LogSeverity::INFO],
]);Non usare
setLogStream()sotto un pool di worker. Una risorsa stream PHP aperta dal padre non può passare in un thread worker: il sink resta attivo sul padre e viene saltato nei worker, con un avviso all'avvio. Usa un sink che ogni worker può aprire da sé —stderr,stdoutofile(ogni worker riapre il percorso in modalità append). Vedi Osservabilità.
Quanti worker?
Regola pratica:
- IO-bound (web standard con DB/HTTP): partire da
available_parallelism()e guardare l'utilizzo CPU. - CPU-bound (rendering, compression-heavy, JSON grandi):
available_parallelism()o meno, guardare la p99 della latenza. - Misto: overcommit di 1–2 worker (
N+1oN+2) dà spesso un miglior utilizzo dei core sugli stalli IO.
$config->setWorkers(\Async\available_parallelism());
Async\available_parallelism()restituisce il numero di CPU disponibili al processo (tiene conto delle quote cgroup e dell'affinity). Si basa suuv_available_parallelismcon fallback suuv_cpu_info.
Vedi anche
HttpServerConfig::setWorkers()HttpServerConfig::setBootloader()- Osservabilità: statistiche cross-worker, logging sotto un pool
Async\ThreadPool: internals del poolAsync\request_context()- Contropressione / drain