Skip to content

Built-in worker pool inside HttpServer (replace user-level ThreadPool boilerplate) #11

Description

@EdmondDantes

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

  1. start() checks setWorkers(N). If N <= 1, identical to today.
  2. Otherwise the C core builds an Async\ThreadPool(N) and submits
    N copies of an internal worker entry point.
  3. 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.
  4. $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.
  5. $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.
  6. $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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Fields

No fields configured for Feature.

Projects

Status
Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions