feat: add task API for bidirectional background worker communication - #2319
Closed
nicolas-grekas wants to merge 2 commits into
Closed
feat: add task API for bidirectional background worker communication#2319nicolas-grekas wants to merge 2 commits into
nicolas-grekas wants to merge 2 commits into
Conversation
nicolas-grekas
force-pushed
the
sidekicks-tasks
branch
4 times, most recently
from
March 29, 2026 12:59
beec9b4 to
8c6e7c2
Compare
nicolas-grekas
force-pushed
the
sidekicks-tasks
branch
from
March 30, 2026 06:47
8c6e7c2 to
adc504c
Compare
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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. 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. 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.
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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. 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.
Contributor
Author
|
Superseded by #2617, #2635, #2636 and #2637, which rebuild this on the |
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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. 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.
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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. 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.
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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.
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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.
nicolas-grekas
added a commit
to nicolas-grekas/frankenphp
that referenced
this pull request
Sep 7, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR builds on top of #2287 (background workers), which is the first commit.
The following description is only about what's currently the second commit.
An overview of both features combined together can be reviewed by reading the doc attached to this PR.
Summary
Adds a task API for bidirectional communication between HTTP workers and background workers. While
set_vars/get_varspushes config from background workers to HTTP workers, tasks enable the reverse: HTTP workers dispatch work to background workers and stream results back.PHP API
Sender-side:
frankenphp_worker_task_send(string $name, array $payload, float $timeout = 30.0): resource- sends a task to a named background worker, returns a readable stream for resultsfrankenphp_worker_task_read(resource $stream): ?array- reads the next update, returnsnullon clean completionReceiver-side:
frankenphp_worker_task_receive(): ?array- dequeues a pending task to process, returns[$stream, $payload]ornullfrankenphp_worker_task_update(resource $stream, array $data): void- sends a progress update or result back to the senderAll payloads and updates follow the same type constraints as
set_vars:null,bool,int,float,string,array(nested), and enums. Objects and resources are rejected.How it works
task_send('worker-name', $payload)- blocks until a background worker picks up the task"task\n"on the signaling stream, callstask_receive()to get[$stream, $payload]task_update($stream, $data)task_read($stream)- wakes up on each updatefclose($stream)when done - sender getsnullfromtask_read()fclose($stream)to acknowledge completionBlocking behavior
task_sendblocks until the background worker picks up the task (with timeout). Uses a buffered channel (size 1) so the sender can enqueue before the"task\n"signal reaches the receiver.task_readblocks until the next update arrives or the stream is closed. The task stream fromtask_sendisstream_select-compatible, so callers can check readability before callingtask_read.fclose()before the background worker completes. Cancelled tasks are detected attask_receive()time and skipped.Crash detection
fclose($stream)(crash,exit(), fatal error),task_read()throwsRuntimeExceptionfcloseby the background worker) returnsnullfromtask_read()EG(flags) & EG_FLAGS_IN_SHUTDOWNto distinguish explicitfclose()from resource cleanup during script exitPooling
Named background workers support
num > 1to run multiple threads. All threads share the same task channel - tasks are distributed automatically across the pool. The signaling stream fans out"task\n"to all threads.Example
Architecture
taskRequeststruct coordinates sender and receiver viaclosedSidesatomic counter - task slot is freed when both sides closetaskFIFOprovides bounded backpressure for updates (max 16 items, blocks on push when full)task_update, sender detects them viastream_selectbackgroundFdListstored onbackgroundWorkerStatefor O(1) lookup (no worker search)Test coverage
10 tests covering: basic task send/receive, progress streaming, non-worker sender, crash before receive, crash mid-task, cancellation, cancel-then-send recovery, cancel-then-crash, pooling (num=2).
Documentation
Task API section added to
docs/background-workers.md.