Decision records
ADR-0032: FastCGI connection drivers for managed PHP-FPM pools
Status: accepted (2026-10-06)
Context
Managed PHP-FPM pools use persistent FastCGI connections (FCGI_KEEP_CONN, see
docs/benchmarks/2026-10-05-php-profile.md), at most max_children of them, because a
kept-alive connection pins an FPM worker. Until now every request acquired a semaphore
permit, was run by a freshly spawned task that took an idle connection (from its own
event loop’s shard, else another loop’s, re-registering the socket), and released the
permit when the response ended.
Measured on the development server (4 static FPM workers on 2 CPUs, 64 clients):
| req/s | FPM workers busy | FPM CPU per request | |
|---|---|---|---|
| scalws, persistent connections | 19.9–23.0k | 1.19–1.34 CPUs | ~59 µs |
| scalws, connection per request | 21.3–23.5k | 1.55–1.63 CPUs | ~71 µs |
| nginx (connection per request) | 24.9–27.3k | 1.73–1.82 CPUs | ~67 µs |
Persistent connections are the cheapest per request, but the workers idled for about a third of the time: after a response ended, the next request reached the connection only after a permit release, a task wake-up, a spawn and possibly a re-registration on another event loop. Behind nginx the next request is already waiting in the socket backlog.
Decision
A managed pool is served by up to max_children connection drivers: tasks that each
own one connection and take requests from a shared FIFO queue. When a response ends the
driver writes the next queued request immediately, in the same task. Drivers start on
demand (on the event loop of the request that needed them) and stop after 10 s without
work. Stale-connection detection, the single replay of buffered requests on a fresh
connection, fresh connections for streaming bodies and the max_children bound are
unchanged. External pools keep one connection per request.
Alternatives measured or rejected
- Drivers on a dedicated runtime thread (to react faster than a busy event loop): measured worse in four alternating runs; reverted.
- Pipelining (writing the next request while the worker still runs the current one): rejected without implementation. A request written behind a slow script waits for it even when other workers are free — head-of-line blocking that nginx does not have, and a written request cannot be taken back.
Consequences
Measured A/B (same binary otherwise, four alternating rounds): 17.2–20.4k → 23.6–26.9k req/s, workers busy 1.20–1.31 → 1.40–1.50 CPUs, FPM CPU per request 64–70 → 54–60 µs. A request now waits in the pool queue instead of on a semaphore; the handler’s response timeout still bounds the wait (an abandoned request is skipped by the driver). Driver output reaches the request’s task over the same bounded channel as before.