Skip to content

Commit e0be4ac

Browse files
feat: frankenphp_send_task(), frankenphp_receive_task() and friends
The task half of #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 line on its handle: the one handle of #2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop. That line is a wake-up rather than a description, so its content is unspecified and must not be inspected: it says something may be pending, the script finds out what by polling. It is not a count either, since a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. 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 #2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
1 parent 2ece59c commit e0be4ac

18 files changed

Lines changed: 1629 additions & 53 deletions

docs/worker.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,34 @@ $vars = frankenphp_get_vars('config');
253253

254254
`frankenphp_get_vars()` blocks until the worker reached its ready point, which can only happen between background workers reading each other while booting; a cycle between them throws instead of hanging. It also throws when the name is unknown, or when the worker is ready but has not published anything.
255255

256+
### Sending tasks to background workers
257+
258+
A request, an HTTP worker or another background worker hands work to a background worker with `frankenphp_send_task()`, by worker name, resolved like `frankenphp_get_vars()` does. The payload follows the same rules as `frankenphp_set_vars()`: null, scalars, arrays or enums. The call blocks until a thread of the worker picks the task up and throws if none did before the timeout, so a busy worker pushes back on its senders instead of queueing without bounds. It returns a stream: `frankenphp_read_task()` blocks for the next update and returns `null` once the worker completed the task, and `stream_select()` works on the stream to wait on several tasks or to bound the wait. Closing the stream abandons the task.
259+
260+
On the worker side, each task sent wakes one parked thread of the worker with a line on its handle, so the loop reads the handle: `fgets()` returns a line when there is work and `false` once the worker is drained. The line is a wake-up rather than a description, so its content is unspecified and must not be inspected: it does not say what is pending, nor how much of it, and in a pool it may belong to a task a sibling thread took. `frankenphp_receive_task()` dequeues a task without blocking, `[$stream, $payload]`, or `null` when another thread of the pool got there first, so the example below drains the queue on each wake-up and treats `null` as the normal outcome. A thread that reads its handle while tasks are queued gets a line at once, whichever loop shape it uses. `frankenphp_update_task()` sends progress or a result back and closing the stream completes the task; a script that ends with the stream still open, a close from a destructor or a shutdown function at that point included, makes the sender's next `frankenphp_read_task()` throw. When the sender closes its stream instead, the worker's stream reaches EOF, so `stream_select()` or `feof()` on it tell a long task that nobody waits for its result, and `frankenphp_update_task()` throws.
261+
262+
```php
263+
// background worker
264+
$handle = frankenphp_get_worker_handle();
265+
266+
while (false !== fgets($handle)) {
267+
while ($task = frankenphp_receive_task()) {
268+
[$stream, $payload] = $task;
269+
frankenphp_update_task($stream, ['progress' => 50]);
270+
frankenphp_update_task($stream, ['result' => process($payload)]);
271+
fclose($stream);
272+
}
273+
}
274+
275+
// request, HTTP worker or another background worker
276+
$task = frankenphp_send_task('jobs', ['file' => 'photo.jpg']);
277+
while (null !== $update = frankenphp_read_task($task)) {
278+
// ['progress' => 50], then ['result' => ...]
279+
}
280+
```
281+
282+
Sixteen updates are buffered per task; past that, `frankenphp_update_task()` waits for the sender to read, and it throws once the sender closed its stream. The streams of a task are backed by eventfd descriptors on Linux, pooled between tasks, and by a socket pair elsewhere.
283+
256284
## Superglobals behavior
257285

258286
[PHP superglobals](https://www.php.net/manual/language.variables.superglobals.php) (`$_SERVER`, `$_ENV`, `$_GET`...)

0 commit comments

Comments
 (0)