Architecture de TrueAsync Server
(PHP 8.6+, true_async_server 0.6+)
TrueAsync Server est une extension PHP native (C) qui fait tourner un serveur HTTP directement dans l'espace d'adressage du processus PHP. Architecturalement, c'est une event loop mono-thread avec un replicated worker pool optionnel pour le passage à l'échelle horizontal au sein d'un même processus.
Vue d'ensemble
┌────────────────────────────────────────────────────────────┐
│ Processus PHP │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Event-loop thread #0 │ │
│ │ │ │
│ │ libuv ──► accept ──► parse ──► dispatch ──► send │ │
│ │ ▲ ▼ │ │
│ │ │ ┌──── handler PHP (coroutine) ────┐ │ │
│ │ │ │ user code, DB, HTTP-client, … │ │ │
│ │ │ └─────────────┬───────────────────┘ │ │
│ │ └──────── yield ────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Event-loop thread #1 …N-1 │ │
│ │ (avec setWorkers(N>1), SO_REUSEPORT) │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘Un seul thread détient la connexion et la requête, de l'accept au final send. Pas de accept→worker handoff, pas de fork/cleanup par requête, pas de verrous globaux. Quand le handler doit attendre des E/S (BD, HTTP, fichier), la coroutine rend la main à l'event-loop, qui prend immédiatement le prochain événement prêt.
Couches
1. Reactor : libuv
Couche d'E/S de base : libuv via le TrueAsync ABI. TCP accept, UDP recvmmsg, opérations fichier, timers, sigwait — tout via la même interface zend_async_event_t. Le reactor connaît epoll/kqueue/IOCP, le serveur non.
API d'extension critique :
zend_async_io_*: lecture/écriture non bloquante de sockets et fichiers.zend_async_io_sendfile_t:uv_fs_sendfile(Linux/BSDsendfile, WindowsTransmitFile).zend_async_fs_open_t:open(2)async via le thread-pool libuv.udp_bindpour HTTP/3 / QUIC.
2. Parseurs de protocoles
- HTTP/1.1 :
llhttp9.3.0 vendored (le même parseur que Node.js). - HTTP/2 :
libnghttp2≥ 1.57 (floor pour CVE-2023-44487 rapid-reset). - HTTP/3 / QUIC :
libngtcp2+libnghttp3, API QUIC TLS OpenSSL 3.5 (backendlibngtcp2_crypto_ossl).
Détection de protocole au-dessus d'un seul socket TCP :
- plaintext : preface
PRI * HTTP/2.0\r\n...\r\n→ HTTP/2 (h2c), sinon → llhttp. - TLS : négociation ALPN au handshake.
HttpServer::addListener() lève un listener multi-protocole. Pour des ports protocol-restricted, utilisez addHttp1Listener / addHttp2Listener / addHttp3Listener.
3. Connection arena
http_connection_t : state par connexion (768 B). Stocké dans une slab-pool : chunks de CONN_ARENA_CHUNK_SLOTS (256) éléments. Live/free suivis par bitmap ; les chunks ne shrink jamais, donnant un arena hit chaud sans allocations.
Visible via HttpServer::getRuntimeStats() : conn_arena_live, conn_arena_slots, conn_arena_chunks, conn_arena_bytes.
4. Body pool
LIFO per-thread pour les buffers de request-body volumineux (≥ 1 MB). Les corps de cette classe sont alloués via zend_mm, mais renvoyés non pas dans l'allocateur, mais dans une LIFO par size-class. La requête suivante de la même size-class réutilise un slot, sans trafic mmap/munmap et sans contention mmap_lock, qui plombait la scalabilité multi-worker sur les charges upload-heavy.
Bench (W=8, c=128, body POST 2 MiB) : 1500 RPS / 370 % CPU → 3300 RPS / 720 % CPU (×2.2 sur le throughput ; le CPU monte enfin réellement avec les workers).
Drain sur HttpServer::stop() et RSHUTDOWN. En build debug, le leak detector zend_mm voit un clean slate au module unload.
5. Intégration coroutines
Chaque requête acceptée crée une nouvelle coroutine via ZEND_ASYNC_NEW_COROUTINE. La coroutine s'exécute dans un per-request scope, enfant du scope serveur. Cela donne deux effets :
Async\request_context()se résout en contexte commun à toute l'arborescence de coroutines de la requête.Async\current_context()reste per-coroutine.
Le cancel d'une requête (handler-coroutine annulée → limite parseur 4xx, peer reset sur stream, drain timeout) est propagé via la chaîne AsyncCancellation normale. TrueAsync\HttpException extends AsyncCancellation porte le statut HTTP, pour que le dispatcher sache quoi répondre au client.
6. Multi-worker (optionnel)
HttpServerConfig::setWorkers(N > 1) :
- Le parent spawn un
Async\ThreadPoolde taille N. - Config + ensemble de handlers sont copiés dans chaque worker via
transfer_obj(deep copy de tout le graphe, y compris les op_array des closures ; voir Thread snapshot). - Le worker re-bind les mêmes listeners avec
SO_REUSEPORT. - Le noyau (Linux/BSD) répartit uniformément l'accept entre les sockets d'un même reuse-port-group.
- Le
start()parent attend la fin de tous les workers.
Chaque worker a une event-loop, un opcache et un allocator indépendants. Aucun shared state, aucun verrou. Le bootloader (si défini) est exécuté dans chaque worker une seule fois avant la task-loop.
CoDel backpressure
Le serveur implémente CoDel, backpressure adaptatif basé sur le temps de sojourn :
- Chaque requête est marquée avec un timestamp enqueue → dequeue.
- Si le sojourn (queue-wait) reste au-dessus de
setBackpressureTargetMs()(défaut 5 ms) pendant 100 ms consécutives, le socket listen est mis en pause. - Dès que le sojourn redescend, le listen reprend.
Contrairement à un max_connections strict, CoDel suit la charge réelle sur le pipeline, pas juste le nombre de connexions concurrentes. C'est particulièrement important pour HTTP/2 où une seule connexion donne un nombre arbitraire de streams.
CoDel est désactivé par défaut pour des charges opt-in : après 0.3.0, les situations où CoDel se déclenchait à tort sur du muxed-h2 (de courts flux rapides poussaient la connexion en "overloaded" et parquaient des flux long-lived sans rapport) ont conduit au choix de ce défaut conservateur.
Bailout firewall
Les PHP fatal-errors du user handler (E_ERROR, OOM, uncaught au shutdown) ne tuent pas le serveur. Chaque protocol-entry-point (H1, H2, H3) emballe l'appel du handler dans un bailout-fence qui :
- Draine la coroutine en échec.
- Émet 500 vers le client (si les en-têtes ne sont pas encore partis).
- Rend le contrôle au listener, qui continue d'accepter.
Diagnostics : sur le failure-path, le serveur log la C-stack (si <execinfo.h> est dispo ; gardé par HAVE_EXECINFO_H) et l'erreur niveau PHP zend_error. Sur musl / Windows, le dump des frames C est silencieusement sauté.
Voir docs/118-tracing-jit-stale-fp-spill.md dans le dépôt pour un des premiers bugs bailout sous Tracing-JIT.
Connection draining (Step 8)
Le serveur implémente deux modèles de drain :
Proactif : setMaxConnectionAgeMs()
Après (age ± 10 % jitter) de lifetime, la connexion reçoit un signal :
- H1 : la prochaine réponse porte
Connection: close. - H2 : émission de
GOAWAY.
Équivalent du MAX_CONNECTION_AGE gRPC. Protège des connexions long-lived "collées" à un même worker derrière un LB L4.
Réactif : trip CoDel / transition hard-cap
Quand le serveur entre en overload (CoDel paused ou hit max_connections), l'effet de drain par connexion est étalé sur la fenêtre setDrainSpreadMs() (équivalent du close-spread-time HAProxy), pour éviter que les clients ne se reconnectent en thundering herd.
Le gap minimal entre déclenchements est donné par setDrainCooldownMs() (défaut 10 s).
Chemins chauds zero-copy
- H2 over TLS hybrid emit (0.6.2) : les petites réponses passent par le path DRAIN (mem_send +
BIO_write, sans allocation gather) ; les corps > 2 KiB ou streaming passent par GATHER (NO_COPY refs + un seulSSL_write_ex). Bench : best-of-three sur la matrice h2load. - Static small-file fast path (≤ 64 KiB) : le fichier est slurpé dans une
zend_stringet envoyé en un seulwritev(headers + body). Les fichiers > 64 KiB passent par sendfile. open/fstatinlinés pour la statique : sans futex round-trip via thread-pool libuv sur un dentry cache chaud.
Modèle mémoire
Le serveur minimise délibérément l'empreinte RAM :
- Asymmetric TLS BIO ring sizes (0.6.0) : CT-in 17 KiB, PT-app back-channel 17 KiB, le reste inchangé ; économie d'environ 62 KiB par connexion TLS.
- Body pool (voir plus haut) : réutilisation des corps volumineux.
- Streaming du corps de requête : peak RSS sur 50 POST parallèles de 20 MiB chute de 1170 MiB à 197 MiB.
- Static TSRMLS cache (ext/async 0.7.0) :
-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1transformeEG()/ASYNC_G()en un unique__thread-load au lieu d'unpthread_getspecific. +32 % RPS sur un handler HTTP minimal.
Conformité RFC
- HTTP/1.1 : RFC 9112 complète (
Connection: close→ reply mirror per §9.6 depuis 0.6.3). - HTTP/2 : RFC 9113, mitigation rapid-reset pour CVE-2023-44487.
- HTTP/3 : RFC 9114, QUIC RFC 9000 y compris rotation des connection ID et amplification limits.
- TLS : TLS 1.2/1.3 seulement, OpenSSL 3.x ; HTTP/3 nécessite OpenSSL 3.5+.
- WebSocket / SSE / gRPC : planifiés.