feat: SentTaskHandle and ReceivedTaskHandle - #2636
Open
nicolas-grekas wants to merge 46 commits into
Open
nicolas-grekas wants to merge 46 commits into
nicolas-grekas wants to merge 46 commits into
Conversation
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
2 times, most recently
from
September 7, 2026 07:56
c0eccbe to
6e571ec
Compare
This was referenced Sep 7, 2026
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
from
September 7, 2026 09:32
6e571ec to
23d3094
Compare
Contributor
Author
|
Pushed |
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
5 times, most recently
from
September 9, 2026 17:53
4c5af56 to
2b77a5b
Compare
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
5 times, most recently
from
September 14, 2026 06:16
e0be4ac to
fd177fc
Compare
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
11 times, most recently
from
September 18, 2026 16:58
a8f1a7f to
a9d72df
Compare
… fire The limit is PHP's: on Windows CI the busy bootstrap ran its full five seconds without the timer ending it, so the test now runs only with the Zend max execution timers of ZTS builds on Linux, where it passes.
A background worker serves no requests, so it does not scale with the CPUs like an HTTP one and nearly every declaration wrote "num 1". It is now the default, and declaring "background" is enough; a pool still asks for the threads it wants.
The missing stop socket of a background worker is an invariant, not a runtime error: the pair is opened before the script starts and closed at the next run setup, so the two guards are asserts now. A thread reaching the ready callback without a background handler would wait out Init() silently, so it panics instead. The context of a background run is not a dummy request, and the field says so. The scope of a name collision is a local variable rather than a method, and the Caddyfile reference keeps the short version of the "background" line, the long one lives in the worker documentation.
A loop selecting on the handle blocks rather than spinning, because the tick consumed the wake-ups, the one sent at start included. The fixture polls the handle before and after a tick and the docs say so.
Two workers on one script, told apart by a matcher, are the documented way to give slow endpoints their own thread pool. The Caddy module used to make their generated names unique, this PR moved the collision check into the core and dropped that, so the configuration stopped booting. A name generated from the script path is not a declaration: it gets a numeric suffix, as before. A declared name still collides, which is what a background worker needs to keep its identity.
Packing the server into the worker name changed every label value of a php_server worker, which breaks the dashboards and alerts built on them. The two are separate labels now, worker="<name>" and server="<name>", empty for a global worker, so a query on the worker name alone selects that worker in every server and the values are the ones FrankenPHP always reported.
- pace a run that ends right after its ready point, clean or crashed, and cut the wait short on drain - route extension SendRequest() to its worker directly and make SendMessage() fail after Shutdown() - keep the declared path as the default name of a global Caddy worker - guard the background run context with contextMu - one drain owner on phpThread, one background TLS reset in C, the public read-timeout stream option - docs: metric labels, the platform condition of the bootstrap bound, stream_select() and FD_SETSIZE
A worker without a name is reported under the path of its script, which newWorker() resolved through symlinks. Deployments that publish releases behind a symlink then move every worker label at each deploy, since the resolved path names the release directory. The default is now the path as declared, made absolute, for the Caddy module and the Go API alike, so naming them in the module is no longer needed; it would also make two workers sharing a script collide, where an undeclared name gets a numeric suffix instead.
Every exit past the ready point was counted toward the restart backoff unless the run outlived its one second cap, so a worker processing a batch and returning, which is how a script keeps its memory fresh, was throttled to one run per second after four of them. A run now counts only when it ended too fast to have done anything, a tenth of a second, which is what tells a spinning script from a working one. A script returning at once is still paced the same way.
Splitting the identity of a worker into two labels changed the signature of every worker method of the Metrics interface, which an implementation living outside this repository has to follow. Those methods keep the single identifier they always took. One method carries the labels instead: DeclareWorker() names them once, before anything else mentions the worker, and the Prometheus implementation resolves the identifier through them. An outside implementation adds that method, empty when it has no use for the labels, and keeps the rest untouched. The identifier is the qualified name again, so two workers that would report under one are rejected at startup, as before.
… interface DeclareWorker() restored the signatures of the worker methods but still added a method to Metrics, which an implementation outside this repository has to grow before it compiles again. Metrics is now the interface it was, a worker scoped to a server being reported as "<server name>:<name>" as before. An implementation that also satisfies ServerMetrics receives the two names apart, which is what PrometheusMetrics does to label its series; the runtime picks the right shape once, in WithMetrics().
frankenphp_get_worker_handle() and frankenphp_worker_tick() are gone, replaced by FrankenPHP\WorkerHandle: tick() is the ready point and the liveness check, getStream() the stream to wait on, isValid() whether the run still holds its socket. One object instead of two global functions, and a place for the task API to land. Only waiting on the stream is supported, what it carries is not part of the contract and tick() consumes it, so the descriptor stays out of the contract. On PHP 8.6 the class can implement Io\Poll\Handle without moving anything else, which is what the polling API discussion asked for. A run still has one stream whatever the number of handles, so a script may take one wherever it needs it.
The cache that keeps a loop from growing the resource list of a run moves from a thread-local slot to the handle that hands the stream out, where the rest of a handle's state already lives. A handle gives the same stream every time, a fresh one once the script closed it, and another handle has its own over the same socket, which is harmless since the stream does not own it and the drain reaches every one of them. Nothing of it survives the run any more: the handle takes its stream with it, so the thread no longer carries one to reset between runs. Asking a throwaway handle for a stream in a loop stays flat, the object frees its stream as it goes.
…ake one The waiting a script does is shown with an Io\Poll\Context first, which is what a background worker should reach for on 8.6 and, through the polyfill, below it. The stream keeps its paragraph, as what an event loop takes and as the fallback for a script with no loop of its own, with the FD_SETSIZE ceiling of stream_select() named there rather than in the middle of the explanation.
unserialize('O:23:"FrankenPHP\WorkerHandle":0:{}') builds one on a request
thread without calling the constructor, and tick() on it then panics
go_frankenphp_background_worker_ready() from a cgo callback, taking the
process down: the ZEND_ASSERT that stood there is compiled out of release
builds. getStream() handed out a stream over fd -1 the same way.
The class is now @not-serializable, which refuses that reconstruction, and
being internal and final with a create_object handler it was already out
of newInstanceWithoutConstructor()'s reach. The methods no longer take the
constructor's word for it either: both check the thread they run on and
throw, so the Go side keeps its invariant with nothing able to break it.
crashCount was reset by any run longer than 100ms, so a script exiting non-zero after, say, 150ms restarted with no backoff at all, some seven times a second, each one logging a warning and counting a crash. Only a clean exit resets it now: a worker processing a batch and returning still starts fresh, a crashing one is paced by the backoff however long it took to fail.
SendMessage() refused a server that is not registered while SendRequest() left it to Server.ServeHTTP(). Same ErrNotRunning either way, one less thing to wonder about when reading the two next to each other.
PHP's socketpair() emulation is not one: it binds a listener to INADDR_ANY, so the port is reachable from off the machine while the pair forms, and hands back whichever connection arrives first. The pair is built here instead, the way libevent and Tor do it: the listener takes the loopback address alone and SO_EXCLUSIVEADDRUSE, and a connection is kept only when its peer is the socket we connected with. Another process racing a connect is dropped and the next one accepted, where the check we had before failed the whole pair and left the worker to retry.
Init() waits for a background worker to reach its ready point, its first WorkerHandle::tick(). On a build with Zend max execution timers, max_execution_time ends a bootstrap that overstays; without them FrankenPHP disables that limit, so a script that parks before ticking, or one whose wake-up at start is lost, kept the server start waiting for ever with a warning as the only trace. boot_timeout bounds that wait, 30 seconds by default, the same figure PHP's own max_execution_time uses for the bootstrap it does bound. The worker is then drained and stopped through the boot-failure path it already has, and Init() returns the name of the worker that never ticked. Zero waits for ever, for whoever wants the old behaviour, and HTTP workers are untouched.
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 constructor is not a gate, see the handle's other methods: a caller that got hold of one some other way would otherwise publish vars from a thread that is not a background worker, where the Go side has no slot to put them in.
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() once frankenphp_worker_tick() returned: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: 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. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. 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. Two more savings on the wake-ups. The sender polls a first 10ms slice on its own: only a pickup that outlasts it brings the Go side in, to wake every thread of the worker and to start the goroutine that ends the wait on a drain or the shutdown, so the common case, a pickup within microseconds, spawns no goroutine and wakes no M. And a side's descriptor gets one signal per sleep: an event signals it only while the other side sleeps on it with no signal outstanding, that one is consumed on the next event and the rest is read from the task's state, so a completion behind an update, or a burst of updates, costs no syscall. Together they take a task from 24 to 15 syscalls and its futexes from 4.7 to 0.6: 15% off a plain task, 29% off one carrying 16 updates, and 5 to 15% more throughput under load. The existing worker metrics carry over: busy_workers counts a thread holding a task, from pickup to the close of the task's stream, and worker_queue_depth counts the tasks waiting for a thread, the only queue a background worker has. Two new ones break tasks down: worker_task_count{worker,server,outcome} with completed, aborted (the script ended with the task open), abandoned (the sender gave up first) or timeout (no thread picked the task up in time), settled by whichever side closes first so every task counts once, and worker_task_time, the seconds spent on tasks. The threads endpoint follows: a background thread is busy while it holds a task and waiting otherwise, counted per thread since a script may hold several. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
The state of each side, the descriptor of the task's channel and what a wait on it costs, moves from the stream to the handle that owns it, and the task settles in the handle's cleanup rather than in the stream's close. getStream() then builds the stream on demand and keeps it, so a script waiting through an Io\Poll\Context allocates neither a stream nor a resource per task, while a stream_select() one gets what it always got. A stream the script took still ends the task when it closes, and past the end of a task it comes back closed; asking for a first one then throws, since there is nothing left to wait on.
A socket pair costs 16.1us per wake-up round trip on a macos-latest runner, against 11.7us for a kqueue descriptor carrying an EVFILT_USER event, the same quarter the eventfd takes off the pair on Linux (23.1 -> 17.5us there). Each side of a task gets one, signaled by the other with NOTE_TRIGGER and consumed by a kevent() that does not wait. A user event coalesces where an EFD_SEMAPHORE eventfd counts, which is exact here because a side never has more than one signal outstanding: the sender's is dropped when it consumes it, and the receiver's single event is the sender giving up. The descriptors are pooled as the eventfds are, and Windows keeps the socket pair its php_select() needs.
The worker loop of the task section no longer repeats a stream_select(), it continues the loop of the section above, and the paragraph names the context first and getStream() as what a library that takes a stream uses. The channel note gains macOS.
It read one update from the slow task and ended, so the worker's complete(), which sends that update and closes the task in one go, raced the end of the request: the sender's handle abandoned a task the worker was about to complete, and the metrics test then saw an abandoned task where it expected a completed one, about once in four runs. Reading to the end settles the task before the script returns.
Same reason as setVars(): the constructor is not a gate, and dequeuing a task on a thread that is not a background worker has nothing to dequeue from.
nicolas-grekas
force-pushed
the
bgworker-tasks
branch
from
September 21, 2026 17:10
0bdbcff to
8925005
Compare
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.
Stacked on #2617 and #2635, themselves on #2664: review the last six commits, from
00f32de.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 by constructing a
FrankenPHP\SentTaskHandle(string $worker, array $payload, ?float $timeout = 30.0)and reads what comes back withread(): ?array; the worker dequeues aFrankenPHP\ReceivedTaskHandlewithWorkerHandle::receive()and answers withupdate(array $data): void,complete(?array $data = null): voidending the task. One class per side, so each script only meets the one it uses and a wrong-end call cannot be written. Both areIo\Poll\Handle, like the worker handle of #2617, so a context follows several tasks without touching a stream. Names resolve likefrankenphp_get_vars()does, payloads and updates follow thesetVars()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 wakes a parked thread of the worker through it, so the loop of #2617 stays as it is,
tick()consuming the wake-up andreceive()dequeuing after it, EOF still meaning drain, andstream_select()users keep a single stream. A wake-up is not a count: in a pool the first thread back in its loop takes the task and the others getnullfromreceive().Constructing a
SentTaskHandleblocks 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;nullwaits 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. Each handle exposes the stream of its side throughgetStream(), built on demand so a script waiting through a context allocates none, over a pair opened per task (the Windowsphp_select()rule of #2617), one byte per update and EOF at completion, sostream_select()bounds the wait or multiplexes tasks and a blocking read parks as well;abandon(), or dropping the handle, gives up on the task, which the worker's stream reports as EOF tostream_select()andfeof(), and the worker's nextupdate()throws. The receiver's handle completes the task when it is closed or dropped, unless that is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's nextread()throws instead of returningnull. Past the end of a task,getStream()hands the stream back closed, the way the poll hooks report an invalid descriptor, so a context that still watches it reads the end fromis_resource()rather than an exception; anything else throws, closing twice does not, and the payload outlives it. Sixteen updates are buffered per task, past thatupdate()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, through its handle;
tick()parks the thread for the senders and, when tasks are already queued, makes the coming wait return at once, 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, a kqueue descriptor carrying a user event on macOS, one end of a socket pair elsewhere for Windows's
php_select(): the streams carry no data, they are whatstream_select()waits on and whatfclose()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 nosocketpair,fcntlorclose: 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.
Its metrics come with it, since the existing ones would otherwise report a background worker as idle while it works:
frankenphp_busy_workerscounts a thread holding a task, from pickup to the close of the task's stream, andfrankenphp_worker_queue_depththe tasks waiting for a thread, the only queue a background worker has. Two counters break tasks down,frankenphp_worker_task_count{worker,server,outcome}withcompleted,aborted(the script ended with the task open),abandoned(the sender gave up first) andtimeout(no thread picked the task up in time), settled by whichever side closes first so every task counts exactly once, andfrankenphp_worker_task_time{worker,server}, the seconds spent on tasks. The/frankenphp/threadsendpoint follows, a background thread being busy while it holds a task, and theServerMetricsinterface of #2617 gainsStartWorkerTask,StopWorkerTaskandWorkerTaskOutcome.Compared to #2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table, no metrics yet.