Архітектура TrueAsync Server
(PHP 8.6+, true_async_server 0.6+)
TrueAsync Server — нативне PHP-розширення (C), яке крутить HTTP-сервер прямо в адресному просторі PHP-процесу. Архітектурно це single-threaded event loop з опціональним replicated worker pool для горизонтального масштабування всередині одного процесу.
Big picture
┌────────────────────────────────────────────────────────────┐
│ PHP-процес │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Event-loop потік #0 │ │
│ │ │ │
│ │ libuv ──► accept ──► parse ──► dispatch ──► send │ │
│ │ ▲ ▼ │ │
│ │ │ ┌──── PHP-обробник (корутина) ───┐ │ │
│ │ │ │ user code, DB, HTTP-client, … │ │ │
│ │ │ └─────────────┬───────────────────┘ │ │
│ │ └──────── yield ────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Event-loop потік #1 …N-1 │ │
│ │ (при setWorkers(N>1), SO_REUSEPORT) │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘Один потік тримає з'єднання і запит від accept до final send. Немає accept→worker handoff, немає per-request fork/cleanup, немає глобальних блокувань. Коли обробнику потрібно зачекати I/O (БД, HTTP, файл), корутина уступає event-loop'у, той одразу підхоплює наступну готову подію.
Шари
1. Reactor: libuv
Базовий I/O-шар: libuv через TrueAsync ABI. TCP accept'и, UDP recvmmsg, файлові операції, таймери, sigwait — все через однаковий інтерфейс zend_async_event_t. Реактор знає про epoll/kqueue/IOCP, сервер не знає.
Critical extension API:
zend_async_io_*— non-blocking читання/запис сокетів і файлів.zend_async_io_sendfile_t—uv_fs_sendfile(Linux/BSDsendfile, WindowsTransmitFile).zend_async_fs_open_t— asyncopen(2)через libuv thread-pool.udp_bindдля HTTP/3 / QUIC.
2. Protocol parsers
- HTTP/1.1: vendored
llhttp9.3.0 (той самий парсер, що у Node.js). - HTTP/2:
libnghttp2≥ 1.57 (floor для CVE-2023-44487 rapid-reset). - HTTP/3 / QUIC:
libngtcp2+libnghttp3, OpenSSL 3.5 QUIC TLS API (бекендlibngtcp2_crypto_ossl).
Protocol-detection поверх одного TCP-сокета:
- plaintext: preface
PRI * HTTP/2.0\r\n...\r\n→ HTTP/2 (h2c), інакше → llhttp. - TLS: ALPN-negotiation на handshake.
HttpServer::addListener() піднімає multi-protocol listener. Для протокол-restricted портів використовуйте addHttp1Listener / addHttp2Listener / addHttp3Listener.
3. Connection arena
http_connection_t — per-connection state (768 B). Зберігається в slab-pool: чанки по CONN_ARENA_CHUNK_SLOTS (256) штук. Live/free відстежується через bitmap; chunks ніколи не shrink'аються, що дає гарячий arena hit без алокацій.
Видно через HttpServer::getRuntimeStats(): conn_arena_live, conn_arena_slots, conn_arena_chunks, conn_arena_bytes.
4. Body pool
Per-thread LIFO для великих request-body буферів (≥ 1 MB). Тіла цього класу алокуються через zend_mm, але повертаються не в алокатор, а в per-size-class LIFO. Наступний запит того самого size-class переуживає слот, без mmap/munmap traffic і без mmap_lock contention, яка капала multi-worker scaling на upload-heavy навантаженнях.
Бенч (W=8, c=128, 2 MiB POST body): 1500 RPS / 370% CPU → 3300 RPS / 720% CPU (×2.2 throughput; CPU тепер реально масштабується з воркерами).
Drain'иться на HttpServer::stop() і RSHUTDOWN. У debug-збірці zend_mm leak detector бачить clean slate на module unload.
5. Coroutine integration
Кожен прийнятий запит породжує нову корутину через ZEND_ASYNC_NEW_COROUTINE. Корутина виконується в per-request scope, дочірньому до серверного scope. Це дає два ефекти:
Async\request_context()резолвиться в спільний для всієї корутини-під-дерева запиту контекст.Async\current_context()лишається per-coroutine.
Cancel request'а (handler-coroutine cancelled → 4xx parser limit, peer reset на стрімі, drain timeout) пробрасується через нормальний AsyncCancellation-ланцюжок. TrueAsync\HttpException extends AsyncCancellation несе HTTP-status, щоб dispatcher знав, що відповісти клієнту.
6. Multi-worker (опціонально)
HttpServerConfig::setWorkers(N > 1):
- Батько спавнить
Async\ThreadPoolрозміру N. - Конфіг + handler set копіюються в кожен воркер через
transfer_obj(deep copy всього графа, включно з op_array замикань; див. Thread snapshot). - Воркер re-bind'ить ті самі listeners з
SO_REUSEPORT. - Ядро (Linux/BSD) рівномірно розподіляє accept по сокетах в одній reuse-port-групі.
- Батьківський
start()чекає завершення всіх воркерів.
Кожен воркер має незалежний event-loop, opcache і allocator. Жодного shared state, жодних блокувань. Bootloader (якщо заданий) виконується в кожному воркері один раз перед task-loop'ом.
CoDel backpressure
Сервер реалізує CoDel, adaptive backpressure за sojourn-часом:
- Кожен запит позначається timestamp'ом enqueue → dequeue.
- Якщо sojourn (queue-wait) тримається вище
setBackpressureTargetMs()(дефолт 5 ms) поспіль 100 ms, listen-сокет ставиться на паузу. - Як тільки sojourn падає назад, listen відновлюється.
На відміну від жорсткого max_connections, CoDel відстежує реальне навантаження на pipeline, а не просто число конкурентних connections. Це особливо важливо на HTTP/2, де одне connection дає довільне число streams.
CoDel вимкнено за замовчуванням для опт-ін робочих навантажень: після 0.3.0 ситуації, де CoDel помилково спрацьовував на muxed-h2 (короткі швидкі потоки штовхали connection в "overloaded" і паркували unrelated long-lived потоки), призвели до вибору conservative-default.
Bailout firewall
PHP fatal-errors з user handler (E_ERROR, OOM, uncaught на shutdown) не валять сервер. Кожен protocol-entry-point (H1, H2, H3) обгортає виклик handler'а в bailout-fence, що:
- Дренує failing-корутину.
- Емітує 500 клієнту (якщо headers ще не на дроті).
- Повертає control listener'у, який продовжує приймати.
Diagnostics: на failure-path сервер логує C-stack (якщо <execinfo.h> доступний; gated через HAVE_EXECINFO_H) і PHP-рівневу zend_error. На musl / Windows C-frame dump silently пропускається.
Див. docs/118-tracing-jit-stale-fp-spill.md у репозиторії для одного з ранніх bailout-bug'ів під Tracing-JIT.
Connection draining (Step 8)
Сервер реалізує дві моделі drain:
Proactive: setMaxConnectionAgeMs()
Після (age ± 10% jitter) lifetime з'єднання отримує signal:
- H1: наступна відповідь несе
Connection: close. - H2: emit
GOAWAY.
Аналог gRPC MAX_CONNECTION_AGE. Захищає від long-lived з'єднань, "прилиплих" до одного воркера за L4-LB.
Reactive: CoDel trip / hard-cap transition
Коли сервер заходить в overload (CoDel paused або hit max_connections), per-connection drain-effect розподіляється по вікну setDrainSpreadMs() (аналог HAProxy close-spread-time), щоб клієнти не перепідключались thundering herd'ом.
Мінімальний gap між тригерами задає setDrainCooldownMs() (дефолт 10 с).
Zero-copy hot paths
- H2 over TLS hybrid emit (0.6.2): малі відповіді йдуть по DRAIN path (mem_send +
BIO_write, без gather-алокації); тіла > 2 KiB або streaming ідуть по GATHER (NO_COPY refs + одинSSL_write_ex). Bench: best-of-three на h2load matrix. - Static small-file fast path (≤ 64 KiB): файл слурпається в
zend_stringі віддається однимwritev(headers + body). Файли > 64 KiB ідуть через sendfile. - Inline
open/fstatдля статики: без futex-round-trip через libuv thread-pool на warm dentry cache.
Memory model
Сервер цілеспрямовано мінімізує RAM footprint:
- Asymmetric TLS BIO ring sizes (0.6.0): CT-in 17 KiB, PT-app back-channel 17 KiB, решта без змін; економія ~62 KiB на TLS-connection.
- Body pool (див. вище): переуживання великих тіл.
- Streaming request body: peak RSS на 50 паралельних 20-MiB POST'ах падає з 1170 MiB до 197 MiB.
- Static TSRMLS cache (ext/async 0.7.0):
-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1перетворюєEG()/ASYNC_G()в один__thread-load замістьpthread_getspecific. +32% RPS на мінімальному HTTP-handler.
Відповідність RFC
- HTTP/1.1: повне RFC 9112 (
Connection: close→ reply mirror per §9.6 з 0.6.3). - HTTP/2: RFC 9113, rapid-reset mitigation для CVE-2023-44487.
- HTTP/3: RFC 9114, QUIC RFC 9000 включно з ротацією connection ID і amplification limits.
- TLS: TLS 1.2/1.3 only, OpenSSL 3.x; HTTP/3 потребує OpenSSL 3.5+.
- WebSocket / SSE / gRPC: у планах.