Skip to content

Pace the async pool by what it waits for, not by a fixed millisecond - #310

Merged
diolektor merged 1 commit into
mainfrom
fix/async-worker-busy-spin-parked-sleep
Aug 16, 2026
Merged

Pace the async pool by what it waits for, not by a fixed millisecond#310
diolektor merged 1 commit into
mainfrom
fix/async-worker-busy-spin-parked-sleep

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

The problem

The async task pool's driver loop is the only thing that moves task fibers: it ticks the scheduler, drains what completed, and re-issues cancellations. While any fiber is parked it does not block on the task queue — it polls, and between polls it waited a fixed 1 ms.

That interval was the wrong length in both directions.

Too long for work in flight. Whatever makes a parked fiber ready almost always happens on another thread — a promise settled by another worker, a value pushed into a Shared\Channel — and the driver finds out by looking rather than by being told. So the interval was also the delay between work becoming possible and being picked up. Two tasks passing values through a cap-1 channel cost 0.691 ms per item at n=5000 and 0.703 ms at n=20000, almost all of it that wait, whatever the values were. Handing 50 000 items across a channel therefore ran past the 30 s request deadline and died as Maximum execution time exceeded — a producer/consumer pair any other runtime finishes in about a second.

Too short at rest. A task parked in oxphp_sleep(20) has nothing to look for until its timer fires. A four-worker pool woke 777 times a second for the whole park, 0.80 % of a core, to confirm exactly that.

What changed

The interval starts at 50 µs, doubles to a ceiling of 10 ms, and returns to the floor whenever a turn of the loop moves something.

Three things make that work:

  1. A tick that resumed a fiber now counts as work. oxphp_async_sched_tick returned the number of fibers in flight, which its only caller discarded, so a tick that woke someone looked like an empty turn. It now reports whether it resumed a fiber, and only for a readiness — a settled promise, an elapsed await deadline, a fired timer, a ready descriptor. A resume that only cancellation asked for still reports nothing, because the driver re-issues cancellations every turn and counting them would let a task that catches the unwind and parks again hold the driver at its floor forever. This is what keeps the interval at the floor while a handoff is in flight, where nothing completes and nothing else would reset it.
  2. The idle wait is taken on the task queue rather than in thread::sleep, so a task dispatched to a worker whose fibers are all parked starts at once instead of at the end of the interval. A task taken off the queue that way is carried to the next turn of the loop — including when shutdown is signalled in between, since dropping it would close its result channel under an awaiter still waiting for an answer and lose the in-flight permit it holds.
  3. The wait never runs past a deadline the worker set itself: a sleep timer, the per-call timeout of oxphp_async_await($p, $timeout), a hooked socket's read or write deadline. The ceiling exists for events only another thread can announce, and none of those are. The first lives in the driver's own timer registry; the other two live on the fiber, which is what the new oxphp_bridge_async_next_deadline_ns exposes.

Measurements

Two images built from one tree with the legacy builder, worker profile, ASYNC_WORKERS=4, container restarted before every number.

before after
channel handoff, n=5000 3.455 s — 0.691 ms/item 0.693 s — 0.139 ms/item
channel handoff, n=20000 14.051 s — 0.703 ms/item 2.958 s — 0.148 ms/item
wakeups while a fiber is parked in oxphp_sleep(20) 777/s 92/s
pool CPU during that park 0.80 % of a core 0.35 %
oxphp_sleep(100 ms) ×10, max 101.3 ms 104.5 ms
oxphp_async_await($p, 0.1) ×8, max 101.1 ms 105.1 ms

Wakeup counts are per-thread /proc/<tid>/schedstat deltas for the async-worker-* threads over a 12 s window inside the park.

What this costs

The ceiling is how late the driver can notice something only another thread can tell it — a settled promise, or an awaiter that gave up on its timeout and wants its task unwound. The waits begin at 0, 0.05, 0.15, 0.35, 0.75, 1.55, 3.15, 6.35, 12.75 ms of idling, so an event in the first ~1.5 ms is noticed sooner than before, and from 12.75 ms on the worst case is 10 ms where it used to be 1 ms. A task that waits on something slow, then waits again, pays that once per round.

The last two rows of the table are the same cost in its other form: the wait before a deadline is now one longer piece rather than a millisecond at a time, which costs a few milliseconds of jitter. Without the clamp in point 3 those rows would read 110.0 ms and 111.7 ms — the full ceiling, measured.

Tests

Two new guards in the single-worker async profile, both with a red observed on a built image rather than argued from the code:

  • async/test_task_sleep_precision sums 20 sleeps of 100 ms inside a task and bounds the total at 2070 ms: 2126 ms on a build whose wait is not cut short at the deadline, 2019 ms with it. Its first draft used 13 ms sleeps and passed on that same build — the overshoot predicted from the interval sequence was 10 ms and the measured one 3.4 ms, so the bound had been fitted to arithmetic instead of to a measurement.
  • async/test_task_dispatched_into_idle_wait covers the task the idle wait takes off the queue. A build that drops it instead of carrying it answers the awaiter with promise channel closed unexpectedly.

Each passes on the build that fails the other, so neither is guarding the other's mechanism.

The headline behaviour is guarded by the existing shared/test_channel_async_usevar_stress, which goes from failing on the request deadline to passing — though only against a full rollback, since a partial slowdown still fits inside 30 s.

Verification

Host: cargo fmt --check, cargo clippy --no-default-features -D warnings, cargo clippy --features php --tests -D warnings, and 988 tests (956 unit + 32 integration) — all clean. The --features php row matters here because the driver loop is behind that feature and is compiled by neither the default host build nor CI.

Suites on the final image: worker 56, default 242 (which carries the cancel and shared sets), async 36, async1 7, asynccap 2, asyncob 1, fibers 32, hooks 54 — 430 tests, 0 failures.

hooks/tick_path_fiber_reuse failed on two runs during this work and was checked rather than waved through: the failure is an inner request being served before the one sent ahead of it, a paired run with both images up at once gave 1/25 against 0/25, full-profile runs gave 4 clean out of 6 against 2 out of 2, and this test never dispatches a task, so the pool sits in the untouched blocking-receive branch throughout. It is the flake already tracked for that profile.

Performance:
  - The async task pool's driver waited a fixed 1 ms whenever a turn of its loop moved nothing and fibers were still parked. That interval was the wrong length in both directions. Whatever makes a parked fiber ready usually happens on another thread and is found by looking rather than announced, so the interval was also the delay between work becoming possible and being picked up: two tasks passing values through a `Shared\Channel` cost 0.691 ms per item at n=5000 and 0.703 ms at n=20000, almost all of it that wait, and 50 000 items ran past the 30 s request deadline. It was equally too short at rest: a task parked in `oxphp_sleep(20)` cost 777 wakeups a second across the pool, 0.80 % of a core, to confirm a timer that could not have fired. The interval now starts at 50 µs, doubles to a 10 ms ceiling, and returns to the floor on any progress. Measured after: 0.139 and 0.148 ms per item, and 92 wakeups a second at 0.35 % of a core. `shared/test_channel_async_usevar_stress` passes instead of dying on the deadline.
  - The idle wait is taken on the task queue instead of `thread::sleep`, so a task dispatched to a worker whose fibers are all parked starts at once rather than at the end of the interval — without this the raised ceiling would have made that up to 10 ms. A task taken off the queue that way is carried to the next turn of the loop, including when shutdown is signalled in between: dropping it would close its result channel under an awaiter still waiting for an answer and lose the in-flight permit it holds.
  - The wait never runs past a deadline the worker set itself — a sleep timer, an `oxphp_async_await($p, $timeout)` timeout, a hooked socket's read or write deadline. The ceiling exists for events only another thread can announce, and those are not among them. Without the clamp a 100 ms sleep returned in up to 110 ms and a 100 ms await timeout in up to 112 ms; with it, 105 ms for either, against 101 ms before the change.

API:
  - `oxphp_async_sched_tick` returned the number of fibers in flight, which the sole caller discarded, so a tick that resumed a fiber counted as an empty turn. It now reports whether it resumed one, and only for a readiness — a settled promise, an elapsed await deadline, a fired timer, a ready descriptor. A resume that only cancellation asked for still reports nothing: the driver re-issues cancellations every turn, so counting them would let a task that catches the unwind and parks again hold the driver at its floor forever. This is what keeps the backoff at the floor while a handoff is in flight, where no task completes and nothing else would reset it.
  - New `oxphp_bridge_async_next_deadline_ns`, registered by the extension like the descriptor-aware backoff before it, so a deadline living on a fiber rather than in the timer registry can bound the driver's wait.

Tests:
  - Two guards in the single-worker async profile, both with an observed red on a built image rather than a reasoned one. `task_sleep_precision` sums 20 sleeps of 100 ms inside a task: 2126 ms on a build whose idle wait is not cut short at the deadline, against a 2070 ms bound and 2019 ms with it. Its first draft used 13 ms sleeps and passed on that same build — the overshoot predicted from the interval sequence was 10 ms and the measured one 3.4 ms, so the bound had been fitted to the arithmetic instead of to a measurement. `task_dispatched_into_idle_wait` covers the task the idle wait takes off the queue: a build that drops it instead of carrying it answers the awaiter with `promise channel closed unexpectedly`. Each passes on the build that fails the other.
  - The unit test for the nearest timer deadline gained a lower bound; without it `Some(Instant::now())` satisfied the assertion.

Docs:
  - The stream-hook section stated the async pool examines a socket deadline at best once per millisecond and that the gap between ticks widens to 10 ms. Both are wrong for that paragraph's own case: a socket's read deadline is one of the deadlines the wait is cut short at, so widening never delays it.
  - The comment on the descriptor-aware backoff claimed its interval is unchanged across callers, which held only while every caller passed a constant. Worker mode still passes a fixed 100 µs; the async driver now passes an interval that widens and clamps itself.

988 tests (956 unit + 32 integration).
@diolektor
diolektor merged commit 5006d70 into main Aug 16, 2026
7 checks passed
@diolektor
diolektor deleted the fix/async-worker-busy-spin-parked-sleep branch August 16, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant