Skip to content

feat: background workers and FrankenPHP\WorkerHandle - #2617

Open
nicolas-grekas wants to merge 9 commits into
php:mainfrom
nicolas-grekas:bgworker-server
Open

nicolas-grekas wants to merge 9 commits into
php:mainfrom
nicolas-grekas:bgworker-server

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Background workers: PHP scripts FrankenPHP runs in a loop outside the request cycle, on their own thread, for what lives as long as the server: a queue consumer, a scheduler, a metrics pusher.

php_server {
	worker {
		file jobs.php
		background
	}
}
$handle = new FrankenPHP\WorkerHandle();

$poll = new Io\Poll\Context();
$poll->add($handle, [Io\Poll\Event::Read]);

while ($handle->tick()) {
    $poll->wait();
    doSomeWork();
}
  1. tick() is the background analog of frankenphp_handle_request(): its first call marks the worker ready, which the server start waits for, and it returns false once FrankenPHP drains the worker on shutdown, reboot or restart.
  2. The handle is an Io\Poll\Handle on every supported version, so the loop above runs on 8.2 with symfony/polyfill-io-poll; getStream() is there for Revolt, amphp or a plain stream_select().
  3. The lifecycle mirrors HTTP workers: an exit re-runs the script, a crash restarts it with a capped backoff, and max_consecutive_failures and boot_timeout fail Init() during startup only.
  4. num defaults to 1, and threads, names and metrics work as for HTTP workers: the threads come on top of num_threads and count within max_threads, and name is optional.

Shared state is #2635, tasks #2636.

@henderkes

Copy link
Copy Markdown
Contributor

Please rewrite the PR description to not be LLM slop reasoning with itself about what it did and why. I've tried reading this three times and I just can't.

@nicolas-grekas

nicolas-grekas commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Sure, I'll let you know when I'm done, for now I just let it do the rebase 😅

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens here when a global background worker and a php_server scoped background worker share the same name and are both eligible for the same source file?

Comment thread frankenphp.c Outdated
Comment thread threadbackgroundworker.go Outdated
Comment thread phpthread.go Outdated

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found another one, anyway, have you tested this on windows?

Comment thread frankenphp.c Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds declared background PHP workers with graceful stop-stream handling and Caddy configuration support.

Changes:

  • Adds background-worker lifecycle, validation, and thread allocation.
  • Exposes frankenphp_get_worker_handle().
  • Adds Caddy integration, documentation, fixtures, and tests.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
worker.go Registers and validates background workers.
threadbackgroundworker.go Implements background-worker lifecycle.
requestoptions.go Rejects background workers for HTTP requests.
phpthread.go Drains handlers during shutdown and transitions.
phpmainthread.go Drains handlers during reboot.
options.go Adds WithWorkerBackground().
frankenphp.go Reserves background-worker threads.
frankenphp.c Implements stop pipes and PHP API.
frankenphp.h Declares C primitives.
frankenphp.stub.php Declares the PHP function.
frankenphp_arginfo.h Registers generated arginfo.
docs/config.md Documents background configuration.
caddy/workerconfig.go Parses background worker blocks.
caddy/config_test.go Tests Caddy parsing and validation.
bgworker_test.go Tests lifecycle, restart, scope, and validation.
testdata/bgworker/basic.php Provides lifecycle fixture.
testdata/bgworker/crash.php Provides restart fixture.
testdata/bgworker/early-return.php Provides startup-failure fixture.
testdata/bgworker/named.php Provides named-worker fixture.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread frankenphp.c
Comment thread threadbackgroundworker.go Outdated
Comment thread threadbackgroundworker.go Outdated
Comment thread frankenphp.go Outdated
Comment thread testdata/bgworker/crash.php Outdated
@nicolas-grekas
nicolas-grekas force-pushed the bgworker-server branch 7 times, most recently from ac0896d to 3e93a24 Compare September 6, 2026 16:35
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Two review-level items.

Name collision between a global and a php_server background worker: names are now scoped like paths, unique within a php_server or among global workers, so both start. Each script sees jobs in FRANKENPHP_WORKER, WithWorkerName() resolves within the request's server first, and metrics and logs report the scoped one as <server name>:jobs (server names get a numeric suffix when two blocks resolve to the same one). Two workers with the same name inside one php_server fail Init(). TestBackgroundWorkerOnServer and same_worker_name_in_two_servers cover both directions.

Windows: the Windows workflow runs the full suite on PRs and it passes here on 8.5.10, background worker tests included. It also surfaced that stream_select() on a pipe would have spun on 8.4 and older, since php_select() only waits properly on sockets before the GH-16889 fix, which is 8.5 only. The handle is therefore backed by a socket pair on every platform, which takes the version-independent Winsock path.

The branch is squashed to 3e93a24; sha references in earlier replies predate the squash.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Since the replies above, a self-review pass amended into the single commit (2f9c5b6):

  • a worker scoped to a Server never passed to WithServer() panicked on a nil map; Init() now rejects it
  • failureCount reset at the ready point, like HTTP workers: a crash after it restarts right away, only boot failures count toward max_consecutive_failures
  • the drain race check in setupScript also covers TransitionRequested
  • Windows: PHP's socketpair() emulation listens on INADDR_ANY and accepts the first peer, so the pair is verified with getpeername/getsockname; SOCK_CLOEXEC where available
  • a warning after 10s when a script has not waited on its handle, since Init() and Shutdown() wait for that point; docs say to block on the handle, feof() polling is not a wait
  • max_threads is rejected on background workers instead of ignored, and the Caddyfile requires num
  • untyped resource return in the stub, dead zend_unset_timeout() between requests removed, background threads no longer reported busy by the threads endpoint
  • tests: parking on a blocking read, RestartWorkers() draining a parked script, the handle throwing outside a background worker, the new validations

CI: all test jobs pass. The Windows job's caddy-suite timeout (POSTed configuration isn't active, then an admin GET hangs) is the same flake main's nightly runs hit (33416988237, 33302318173) and passes on rerun. The Docker matrix fails on the Go 1.27 runtime/cgo link error that #2634 and main's nightly show too.

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.
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Round addressed: 2617 6014ea1, 2635 e1b8064, 2636 0246633.

The handle built behind the constructor was real and took the process down, unserialize() then tick() panicking the Go side. The class is @not-serializable now, Reflection was already refused for an internal final class with a create_object handler, and tick(), getStream(), setVars() and receive() check the thread themselves instead of trusting the constructor, with a test over the three construction paths.

Crash pacing: crashCount resets only on a clean exit now, so a script failing after 150ms is paced by the backoff instead of restarting seven times a second. SendRequest() checks the server like SendMessage() does. The Windows pair and the num_threads remark have their own answers in the threads, and the unbounded Init() wait is on #2657.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

HTTP workers are untouched, FRANKENPHP_WORKER=1 exactly as before, so the line you linked behaves as it does today. What changed is that a background worker doesn't get it, and that line is the reason: with it set, a Symfony app declared as a background worker would pick FrankenPhpWorkerRunner and loop on frankenphp_handle_request(), which throws there. Nothing existing can break either, background workers are new.

The name is what lets one entry script serve several background workers, switching on which one it is, and that's the shape to push for in Symfony: one script and one bootstrap, the way an app has a single front controller. A script per worker is the alternative and it's supported for those who'd prefer this style. #2635 itself never reads the name, setVars() writes the worker's own slot and frankenphp_get_vars() names another one, but the dispatch in userland does.

The surface is already smaller than it was: the earlier iteration exposed FRANKENPHP_WORKER_BACKGROUND, FRANKENPHP_WORKER_NAME and filled argv/argc with the script and the name. It's one variable now.

nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 21, 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() 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.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 21, 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() 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.
nicolas-grekas added a commit to nicolas-grekas/frankenphp that referenced this pull request Sep 21, 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() 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.
@AlliBalliBaba

AlliBalliBaba commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

The name is what lets one entry script serve several background workers, switching on which one it is, and that's the shape to push for in Symfony: one script and one bootstrap, the way an app has a single front controller. A script per worker is the alternative and it's supported for those who'd prefer this style. #2635 itself never reads the name, setVars() writes the worker's own slot and frankenphp_get_vars() names another one, but the dispatch in userland does.

Hmm so the idea is that Symfony can easily register new background processes to pull data from. Can you give some more examples on what other processes you were thinking about (so far I only remember the redis/connection handling)? Since the worker is in principle async would it make sense to potentially pull everything from a single worker thread? I think doing this by name would lock the api into one task per thread.

I know this discussion belongs in #2635, but maybe there's a more general abstraction than doing this via names, in the end we're building some kind of specialized channel.

@AlliBalliBaba

Copy link
Copy Markdown
Contributor

WDYT about the callback based approach? Just brainstorming

# in the HTTP thread
frankenphp_get_var('redis', Runtime::BG_WORKER_NAME);


# in the background worker
$handle = new WorkerHandle();
$handle->onGetVar(function(string $varName){
    if($varName === 'redis'){
        $handle->setVar($varName, [...]);
        // add the streampolling to the event loop
    }
});

// callback is executed at $handle->tick

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Vars and tasks are not the same tool. A var slot belongs to the worker, not to a thread, so the last snapshot keeps being served while the script crashes or restarts, and reading it costs a copy, from a classic-mode request that starts from nothing as well as from the HTTP workers, which would otherwise each do the round trip: wake a thread, wait for the pickup, wait for the answer, and while the worker restarts the senders wait with it. Your onGetVar is a task with a memo on the worker's side; setVars() + frankenphp_get_vars() is the memo alone, published once and read everywhere at memory speed.

The channel you describe is #2636, a SentTaskHandle to a named worker and its ReceivedTaskHandle on the other end.

On one worker multiplexing everything: it's possible today, the payload is an array and the script switches on it. Forcing it would push a dispatcher into every userland script, and it would forbid specializing a pool per kind of work, with its own num, boot timeout and max_consecutive_failures: a scheduler that must not queue behind a slow consumer, a consumer on 4 threads next to a pusher on 1. Both designs have to stay achievable, and it's not for FrankenPHP to pick one today. Names are what keep both open: one script serving several names, or one name per script.

@AlliBalliBaba

Copy link
Copy Markdown
Contributor

No I mean, you can still keep the caching and all. It would only trigger the callback on the first getVar. This way we can potentially handle everything in 1 thread and just pass the "name" directly to the callback (does not necessarily need to be a string even). Also allows immediately throwing an error if the callback is not implemented or none is implemented for the specific getVar.

We could also always fallback to the first bgworker if no worker name is passed (aka just frankenphp_get_var('redis')).

@AlliBalliBaba

Copy link
Copy Markdown
Contributor

These generated responses are hard to fully read through...

On one worker multiplexing everything: it's possible today, the payload is an array and the script switches on it.

The current api needs the name to be passed to decide what to do and there is only 1 name per worker, aka one thing to do per thread.

Forcing it would push a dispatcher into every userland script and it would forbid specializing a pool per kind of work

Why would you need a pool of workers to set a single var?

with its own num, boot timeout and max_consecutive_failures: a scheduler that must not queue behind a slow consumer, a consumer on 4 threads next to a pusher on 1.

I'm not arguing against having the response cached, you should keep that logic. I'm just saying these threads should be able to do more than 1 thing as they are async.

Both designs have to stay achievable, and it's not for FrankenPHP to pick one today. Names are what keep both open:

Ofc it's up to us to pick a design, that's the whole point of this discussion.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

I'm pausing on this PR a bit to come back with a quieter mind about your points, and also because it'd be cool to have my other PRs that target main directly reviewed/merged before this one ;)

threads should be able to do more than 1 thing as they are async.

I understand that I've been pushing for one thread per "setVars()" namespace. Is the number of threads something to optimize for? We could try to find a way for a worker to manage several namespaces if yes.

On the other end of my thoughts: not every "setVars()" worker will need to be async. I definitely see simple implementations that just poll periodically some remote endpoint. That might be good enough for many use cases.

(Noted on the generated text, I'll do better)

@AlliBalliBaba

AlliBalliBaba commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Or let's maybe clarify on the api:

Each worker has it's own "vars". In principle a process-global hashmap that only the worker can write to, but everone else can also read from.

When an HTTP thread calls frankenphp_get_var('something') on the worker, it will first look into the global hashmap if the var already exists and take it from there. If it does not exist, trigger the worker's setVar('something') callback. At the end of the callback, the var should be set, otherwise throw an error in the HTTP thread.

Would allow doing multiple setVars per thread, which makes sense as we're running async anyways.

Edit: just read your message, it's fine take your time 👍

@AlliBalliBaba

AlliBalliBaba commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

On the other end of my thoughts: not every "setVars()" worker will need to be async.

They kind of need to be because of the shutdown signal, or at least they should not be blocking most of the time. Not sure if there's a good middleground between sync and async, but yeah ideally we shouldn't lock out either.

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

What about separating the two roles names have today? A worker name identifies a pool, and what requests address becomes a namespace declared on the workers:

worker {
    file index.php
    background
    vars redis feature_flags
    tasks image.resize
}

One worker can own many namespaces, and an app splits them across pools only when it wants a separate num or failure cap. Requests name namespaces, never workers: frankenphp_get_vars('redis'). Declared upfront, they let Validate() refuse conflicts and the first tick() check every vars namespace was published, so requests still never wait. That's setVars($namespace, $vars), namespaces as task destinations, and optional names. WDYT?

@AlliBalliBaba

AlliBalliBaba commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Hmm so you mean that every get_vars('redis') would automatically be routed to this worker? Yeah that's actually a lot cleaner than routing by names, I like it 👍

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Yes! Happy you like it, I updated the PR!

Names were scoped per server so a script could address a background worker
by name within its php_server. The namespaces of php#2635 and php#2636 do that
now and no PHP API takes a worker name, so naming goes back to what main
does: Caddy makes generated names unique, and the server label and the
ServerMetrics interface are gone. The extension worker fixes leave too,
main has the same bugs and they get a pull request of their own.
Since php#2660 worker threads come on top of num_threads and max_threads
bounds the whole process. Background threads were the one exception, which
took a reservation through calculateMaxThreads(), initPHPThreads() and the
automatic max_threads. They now follow the same rule as HTTP workers.
HTTP workers wait for their ready point without a bound too, on builds
without Zend max execution timers, so the bound belongs with both kinds
of worker. Background workers keep the bound of max_execution_time where
those timers exist, and the warning logged after 10 seconds elsewhere.
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, keeps 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.
The timeout reported through a blocking send, so a second timeout could
wait for room in startupFailChan for good, Init() with it. It now reports
like the handlers do and moves the thread to ShuttingDown, so no run starts
after the one that overstayed, whether the timeout lands in a run or
between two failed boots.

The 30s default now only applies where Zend max execution timers are
missing, as the docs said: elsewhere max_execution_time bounds the
bootstrap, and a set_time_limit() raising it is no longer cut short.
Comments and docs that drifted are updated, and the thread budget and the
Caddy directive get the tests they lacked.

This branch has not been deployed

No deployments
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.

6 participants