Pace the async pool by what it waits for, not by a fixed millisecond - #310
Merged
Conversation
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).
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.
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 asMaximum 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:
oxphp_async_sched_tickreturned 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.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.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 newoxphp_bridge_async_next_deadline_nsexposes.Measurements
Two images built from one tree with the legacy builder,
workerprofile,ASYNC_WORKERS=4, container restarted before every number.oxphp_sleep(20)oxphp_sleep(100 ms)×10, maxoxphp_async_await($p, 0.1)×8, maxWakeup counts are per-thread
/proc/<tid>/schedstatdeltas for theasync-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_precisionsums 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_waitcovers the task the idle wait takes off the queue. A build that drops it instead of carrying it answers the awaiter withpromise 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 phprow 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:
worker56,default242 (which carries thecancelandsharedsets),async36,async17,asynccap2,asyncob1,fibers32,hooks54 — 430 tests, 0 failures.hooks/tick_path_fiber_reusefailed 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.