Problem
Multi-process / multi-thread deployments today require the user to wire
up the worker pool outside the server. See examples/multi-worker.php:
$pool = new Async\ThreadPool($workers);
for ($i = 0; $i < $workers; $i++) {
$pool->submit($workerMain); // each worker re-builds HttpServer
}
while (true) { Async\delay(1000); } // main thread idles
Each worker has to redundantly:
- construct its own
HttpServerConfig,
addListener() the same port (relying on SO_REUSEPORT),
- re-register handlers,
- call
$server->start().
The user is responsible for fan-out, lifecycle, graceful stop, and
stats aggregation. The boilerplate is identical across every project
and obscures what is actually a one-line intent: "run this server on
N cores".
Proposed change
Move the worker pool inside HttpServer, as a thin wrapper over
the existing TrueAsync pool primitive (Async\ThreadPool / Async\Pool
already ship with true_async). No new pool infrastructure on the
server side — the C core just submits the per-worker entry point onto
Async\ThreadPool and awaits its completion.
API sketch
$config = (new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setWorkers(0); // 0 = auto (available_parallelism)
// 1 = single-threaded (current default)
// N = explicit count
$server = new HttpServer($config);
$server->addHttpHandler($handler); // handler is replicated to all workers
$server->start(); // spawns pool, blocks until all workers finish
$server->stop(); // broadcasts to workers, drains, returns
setWorkers(1) (or unset, default 1) keeps current behaviour bit-for-
bit — no pool spawned, just the single event loop on the calling
thread. Zero behavioural change for existing code.
Internal model
start() checks setWorkers(N). If N <= 1, identical to today.
- Otherwise the C core builds an
Async\ThreadPool(N) and submits
N copies of an internal worker entry point.
- Each worker thread:
- receives a transferred
HttpServerConfig snapshot (already
supported via the existing transfer_obj handler),
- receives the registered handler set (same mechanism — closures
with captures already cross threads),
- constructs a fresh
HttpServer instance bound to its own event
loop, replays listeners (relying on SO_REUSEPORT for kernel
load-balancing), and starts.
$server->start() on the parent awaits the pool via the pool's
built-in join semantics — no while (true) { delay(1000) } and no
bespoke wait-event. Worker failure surfaces through the same
exception path the user would get if they called the pool
themselves.
$server->stop() on the parent broadcasts a stop signal to each
worker through a control channel; each worker's own start()
returns, the pool task completes naturally, and the parent's
awaiting start() resolves.
$server->getStats() aggregates counters across all worker
instances (sum totals, max/avg sojourn, etc.).
Why this is mostly free
- Pool primitive:
Async\ThreadPool already exists.
- Config transfer:
HttpServerConfig::transfer_obj already exists
(used by today's user-driven multi-worker pattern).
- Handler transfer: closures with captures already cross threads
via the same machinery.
- start() blocking semantics: today blocks until
stop(). With
the pool inside, it blocks until every worker's start() returns.
Same shape — no API change to start().
API impact
| Surface |
Today |
After |
| User pool wiring |
Manual ThreadPool + N submit() calls |
One setWorkers() setter |
| Handler registration |
Per-worker addHttpHandler() inside the closure |
Once on the parent server, replicated automatically |
| Listener bind |
Per-worker addListener() with implicit REUSEPORT |
Declared once on the parent config |
| Lifecycle |
User loops delay(1000) to keep the main thread alive |
$server->start() blocks naturally on the pool |
| Stats |
Each worker's stats are isolated |
Single aggregated getStats() view |
Open questions
- Handler hot-reload. Should
addHttpHandler() after start() be
allowed (replicate to live workers) or rejected (current behaviour)?
Lean: keep frozen-at-start, leaves room to relax later.
- Worker isolation guarantees. Do we promise no-shared-state
between workers, or document it as "PHP threads share nothing by
default, see TrueAsync ThreadChannel for explicit transfer"?
- Stats aggregation cost. Per-worker counters live in worker-local
memory. getStats() would have to fan out + sum — acceptable given
it's a cold path, but worth measuring.
- Single-process fork model? Not in scope here — TrueAsync is
thread-based; fork-style preforking would be a separate issue.
- Windows. libuv on Windows doesn't implement
SO_REUSEPORT. The
multi-worker layout would need a single accepting thread + work
dispatch, or the feature is documented as Linux/macOS only.
Backwards compatibility
Default setWorkers(1) keeps current behaviour bit-for-bit. The
existing examples/multi-worker.php flow continues to work — users
who want explicit control over worker boot keep it. The new API is
purely additive.
Doc updates needed
docs/USAGE.md § "Multi-worker" — replace the current preforking
recipe with the one-liner.
examples/multi-worker.php — keep as-is (advanced control), but
add a sibling multi-worker-builtin.php that uses the new setter.
Problem
Multi-process / multi-thread deployments today require the user to wire
up the worker pool outside the server. See
examples/multi-worker.php:Each worker has to redundantly:
HttpServerConfig,addListener()the same port (relying onSO_REUSEPORT),$server->start().The user is responsible for fan-out, lifecycle, graceful stop, and
stats aggregation. The boilerplate is identical across every project
and obscures what is actually a one-line intent: "run this server on
N cores".
Proposed change
Move the worker pool inside
HttpServer, as a thin wrapper overthe existing TrueAsync pool primitive (
Async\ThreadPool/Async\Poolalready ship with
true_async). No new pool infrastructure on theserver side — the C core just submits the per-worker entry point onto
Async\ThreadPooland awaits its completion.API sketch
setWorkers(1)(or unset, default 1) keeps current behaviour bit-for-bit — no pool spawned, just the single event loop on the calling
thread. Zero behavioural change for existing code.
Internal model
start()checkssetWorkers(N). IfN <= 1, identical to today.Async\ThreadPool(N)and submitsN copies of an internal worker entry point.
HttpServerConfigsnapshot (alreadysupported via the existing
transfer_objhandler),with captures already cross threads),
HttpServerinstance bound to its own eventloop, replays listeners (relying on
SO_REUSEPORTfor kernelload-balancing), and starts.
$server->start()on the parent awaits the pool via the pool'sbuilt-in join semantics — no
while (true) { delay(1000) }and nobespoke wait-event. Worker failure surfaces through the same
exception path the user would get if they called the pool
themselves.
$server->stop()on the parent broadcasts a stop signal to eachworker through a control channel; each worker's own
start()returns, the pool task completes naturally, and the parent's
awaiting
start()resolves.$server->getStats()aggregates counters across all workerinstances (sum totals, max/avg sojourn, etc.).
Why this is mostly free
Async\ThreadPoolalready exists.HttpServerConfig::transfer_objalready exists(used by today's user-driven multi-worker pattern).
via the same machinery.
stop(). Withthe pool inside, it blocks until every worker's
start()returns.Same shape — no API change to
start().API impact
ThreadPool+ Nsubmit()callssetWorkers()setteraddHttpHandler()inside the closureaddListener()with implicit REUSEPORTdelay(1000)to keep the main thread alive$server->start()blocks naturally on the poolgetStats()viewOpen questions
addHttpHandler()afterstart()beallowed (replicate to live workers) or rejected (current behaviour)?
Lean: keep frozen-at-start, leaves room to relax later.
between workers, or document it as "PHP threads share nothing by
default, see TrueAsync ThreadChannel for explicit transfer"?
memory.
getStats()would have to fan out + sum — acceptable givenit's a cold path, but worth measuring.
thread-based; fork-style preforking would be a separate issue.
SO_REUSEPORT. Themulti-worker layout would need a single accepting thread + work
dispatch, or the feature is documented as Linux/macOS only.
Backwards compatibility
Default
setWorkers(1)keeps current behaviour bit-for-bit. Theexisting
examples/multi-worker.phpflow continues to work — userswho want explicit control over worker boot keep it. The new API is
purely additive.
Doc updates needed
docs/USAGE.md§ "Multi-worker" — replace the current preforkingrecipe with the one-liner.
examples/multi-worker.php— keep as-is (advanced control), butadd a sibling
multi-worker-builtin.phpthat uses the new setter.