Skip to content

feat: frankenphp_send_task(), frankenphp_receive_task() and friends - #2636

Open
nicolas-grekas wants to merge 3 commits into
php:mainfrom
nicolas-grekas:bgworker-tasks
Open

feat: frankenphp_send_task(), frankenphp_receive_task() and friends#2636
nicolas-grekas wants to merge 3 commits into
php:mainfrom
nicolas-grekas:bgworker-tasks

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2617 and #2635: the first two commits are those PRs', review the third one (4c5af56).

The task half of #2319, rewritten on the background workers of #2617 and the shared vars of #2635. A request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(string $name, array $payload, ?float $timeout = 30.0): resource and reads what comes back with frankenphp_read_task($stream): ?array; the worker dequeues with frankenphp_receive_task(): ?array and answers with frankenphp_update_task($stream, array $data): void, fclose() completing the task. Names resolve like frankenphp_get_vars() does, payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side. Roughly 280 lines of Go and 330 of C.

The handle of #2617 is the only stream the worker needs: each task sent writes a task\n line to the handle of every thread of the worker, so the loop is fgets() on the handle, EOF still meaning drain, and stream_select() users keep a single stream. The line is a wake-up, not a count: in a pool the first thread back in its loop takes the task and the others get null from receive_task(), so the documented loop drains the queue on each wake-up.

send_task() blocks until a thread has picked the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; null waits forever. Tasks queued while a thread restarts are signaled again on its next run, and the wait ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. The sender gets a socket stream over a pair opened per task (the Windows php_select() rule of #2617), one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks and a blocking read parks as well; closing it abandons the task, which the worker's stream reports as EOF to stream_select() and feof(), and the worker's next update throws. The receiver's stream completes the task on close, unless that close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The queue mutex is never held across a syscall and taken once per wake-up, since a thread inside a cgo callback that loses it parks the same expensive way. Benchmarked in the Docker builder image (the harness stayed out of the PR): a task went from 549 to 285碌s with one thread, a pool of 8 from 1745 to 320碌s, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement.

Tasks are referenced from the streams through cgo handles and freed once both sides closed, or by the sender when nobody picked the task up. The stop sockets of a worker's threads are now guarded by its task-queue mutex, since senders write to them.

Compared to #2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table, no metrics yet.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Pushed 23d3094 after benchmarking the task path in the Docker builder image (harness kept out of the PR). Three changes: a send wakes one parked thread instead of every thread of the pool; the sender waits for the pickup in the kernel, on the task's socket, instead of in a Go select (waking a thread parked inside a cgo callback costs the scheduler a P hand-off); and the queue mutex is no longer held across a syscall nor taken twice per wake-up. One thread: 549 to 285碌s per task. A pool of 8: 1745 to 320碌s. 8 senders on 8 threads: 2k to 32k tasks/s. Absolute numbers are that VM's, the ratios are the point.

@nicolas-grekas
nicolas-grekas force-pushed the bgworker-tasks branch 3 times, most recently from f3254fa to 50e4418 Compare September 7, 2026 13:54
Background workers run a script in a loop outside the HTTP request
cycle, sharing the PHP runtime with the request threads. Rebuilt on
Server from php#2499: a background worker attaches to a php_server through
WithWorkerServerScope() like any other worker.

Declared with "background" in a worker block (php_server or global) or
WithWorkerBackground() in Go. name is required, match is rejected,
num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative
exit, restart with quadratic backoff on a crash, max_consecutive_failures
fails Init() during startup only. drain() runs on shutdown, reboot and
handler transitions so a parked script wakes up instead of waiting out
the force-kill grace period.

Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'],
HTTP workers included: the documented contract is to test its presence,
not its value. Background workers also get
$_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles
can tell them apart with isset().

The script gets one handle, frankenphp_get_worker_handle(), a stream
that reaches EOF when the worker is drained, meant to carry control
messages later. It is backed by a socket pair, not a pipe: on Windows
PHP's php_select() only waits properly on sockets before 8.5. Streams
do not own the socket (php_sockop_close() would shutdown() it on
Windows), so every call returns a fresh stream and closing one never
affects another; the read timeout is infinite so a blocking read parks
as well as stream_select() does. Both ends are non-inheritable.

A worker counts as ready on its first wait on the handle (select cast
or read), the background analog of frankenphp_handle_request(): Init()
waits for it, ready_workers counts from it, and an exit before it is a
boot failure. The handle's stream ops, copied from the socket ops at
MINIT, report it once per run. A run gets one stream: every call returns
the same resource until the script closes it, so fetching the handle in a
loop does not grow the resource list of a request that never ends.

Worker names are scoped like paths: unique within a php_server or
among global workers. The script sees the declared name; metrics and
logs report a scoped worker as "<server name>:<name>", with a numeric
suffix on server names when two blocks resolve to the same one, never a
name another block configured. The collision-driven renaming in the Caddy
module is gone, and WithWorkerName() resolves within the request's server
first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a
php_server block were reported under their bare name unless it collided:
both changes are called out in the docs.

Supersedes php#2543 and php#2398.
The shared-state half of php#2287, on top of the background workers: a
worker publishes a snapshot with frankenphp_set_vars(), requests and
other workers read it with frankenphp_get_vars(). The persistent-zval
toolkit from php#2366 does the cross-thread copies; this adds the two
functions and a per-worker slot.

set_vars() validates the tree, persists it and swaps it into the slot
under a write lock; readers copy it into request memory under the read
lock, so the previous table is only freed once no reader is on it. The
slot belongs to the worker rather than a thread: it survives script
restarts, serving the last snapshot meanwhile, and several threads of one
worker simply publish last-writer-wins. The tables are freed in
drainPHPThreads() once every PHP thread is gone and before the engine is,
since freeing walks string headers.

get_vars() resolves the name the way requests do, within the caller's
server then among global workers. It blocks until the worker reached its
ready point once: activateServers() runs after initWorkers(), so requests
never wait, and a blocked caller is another background worker still
booting. Those waits form a graph and a cycle is refused with an
exception instead of deadlocking Init(); the wait also aborts on
shutdown. A ready worker that never published throws. Publishing before
the first wait on the handle therefore guarantees the snapshot exists
before the server accepts requests.

Being the first consumer keeping persistent trees across requests and
exposing them repeatedly, this also fixes two fast paths of the toolkit:
opcache-immutable arrays were exposed through refcounted zvals, and
opcache only keeps their refcount at 2, so the second reader's release
destroyed shared memory; and every interned string was shared by
pointer, while only permanent ones (opcache, startup) outlive the
request that interned them, so trees built from request-interned
literals dangled once that request ended (the Windows job runs the embed
without opcache). Immutable arrays now go through zvals without type
flags, as php-src does for literals, and sharing a string requires
IS_STR_PERMANENT.

Left out on purpose, see php#2287: the per-request cache with === identity,
the unchanged-data skip in set_vars(), ensure_background_worker() and
lazy or catch-all workers, CLI hiding of the functions.
The task half of php#2319, on top of the background workers and their shared
vars: a request, an HTTP worker or another background worker hands work to
a named background worker with frankenphp_send_task(), which returns a
stream carrying the updates the worker sends back with
frankenphp_update_task(). The worker dequeues tasks with
frankenphp_receive_task() after reading a "task\n" line on its handle: the
one handle of php#2617 carries both the drain EOF and the wake-ups, so a
script keeps a single stream_select() loop. The line is a wake-up, not a
count: every thread of a pool gets one per task, the first one back in its
loop takes the task and the others get null.

send_task() blocks until a thread of the worker picks the task up and
throws on timeout, so a busy worker pushes back on its senders instead of
queueing without bounds; tasks queued while a thread restarts are signaled
again on its next run. The wait also ends when the sender's own thread is
drained for a restart or the shutdown, since the target's threads are
drained too. Names resolve like frankenphp_get_vars() does.

Each task gets a socket pair. The sender's stream is a socket stream over
one end, one byte per update and EOF at completion, so stream_select()
bounds the wait or multiplexes tasks, and a blocking read parks as well;
closing it abandons the task. The receiver's stream is a socket stream
over the other end: updates go through update_task(), the stream itself
reports the sender's close as EOF to stream_select() and feof(), so a long
task learns that nobody waits for its result, and update_task() throws.
Closing it completes the task, unless the close is the resource cleanup of
request shutdown, which means the script ended with the task open: the
sender's next read throws instead of returning null. Sixteen updates are
buffered per task, past that update_task() waits for the sender to read.

Waking threads is what a task costs, so wake-ups are kept to a minimum. A
send wakes one parked thread of the worker, round-robin, with the line on
its handle; a thread that reads its handle while tasks are queued gets the
line from the read op itself, so no wake-up is lost whichever loop shape
the script uses, and after 10ms without pickup every thread is woken as a
fallback. The sender waits for the pickup in the kernel, on its end of the
task's pair, rather than in a Go select: waking a PHP thread parked inside
a cgo callback costs Go a P hand-off, a byte on a socket does not. The
thread taking the task writes that byte, a watcher goroutine does when the
wait must end without a pickup. The queue mutex is never held across a
syscall and taken once per wake-up, as a thread inside a cgo callback that
loses it parks the same expensive way. In the Docker builder image this
takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to
320us, and 8 senders on 8 threads from 2k to 32k tasks/s.

Each side of a task waits on its own descriptor of the task's channel,
an eventfd on Linux, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are what stream_select()
waits on and what fclose() ends, the Go side holds the state the functions
report. The descriptors belong to the task until both sides closed, then
the pair is drained and pooled, so a task costs no socketpair, fcntl or
close: about 12 syscalls instead of 18, 13% off the latency and up to a
third more throughput under load in the same measurement.

Payloads and updates follow the set_vars() whitelist and travel as
persistent tables through the Go side, which owns them until they are
copied into request memory. The streams reference their task through a
cgo handle; the task is freed once both sides closed, or by the sender
when no thread picked it up. The stop sockets of a worker's threads are now
guarded by its task queue mutex, since senders write to them.

Compared to php#2319: no queue ahead of pickup and no cancellation before it,
no dedicated signaling stream, no global task table.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant