park a sleeping green task in the reactor instead of holding its worker - #627
Conversation
pith_sleep from inside a green task used to block the whole worker os thread, stalling every task pinned to it -- and select's idle probe sleeps a millisecond per loop, so two idle selects could occupy a whole two-worker pool. the reactor already kept a deadline heap for fd waits with timeouts; a sleep is that wait minus the fd. deadline entries now carry a target (an fd wait or a pure timer), sleeping tasks live in their own seq-keyed map so nothing fd-shaped ever sees them, and the existing sweep wakes them through the same resolve-once outcome protocol. outside a green task (main, plain os threads, the os-thread backend) sleep still blocks the thread, which is correct there. on non-linux the fallback blocks the worker, the same degradation socket waits take.
two sleepers spawned ahead of a busy task on one worker: only a parked sleep lets the busy task finish first, and the sleepers overlapping (700ms total, not 950ms serialized) shows the timer heap serves concurrent sleeps. fails on the old runtime in exactly those two ways.
move sleep off the open-limitations list and describe the timer path in the concurrency doc, including the non-linux fallback where a sleep still blocks its worker like socket waits do.
…ears `pith_sleep` cast its millisecond count straight to an unsigned width, so `time.delay(-5)` asked for about 584 million years and was indistinguishable from a hang. it predates the timer work — the os-thread backend hangs on it too — and the reactor path inherited it, since a negative count reached `sleep_task` as well. clamping at the entry point fixes both paths at once and keeps the two consistent, which matters because the corpora compare their output. the case covers zero, a small negative and a large one, a sleeper that is the only live task, and a batch of eight sleepers whose elapsed time distinguishes overlapping from serializing.
|
pushed one more commit. while checking the edge cases the pr's own design notes it predates this branch (the os-thread backend hangs on it identically on verified after the change: zero and negative return promptly, a solo sleeper |
summary
pith_sleepwasstd::thread::sleep, so under the green backend a task callingtime.delayheld its whole worker for the duration — andselectwith no ready arm sleeps a millisecond per probe, so two idle selects could occupy a two-worker box doing nothing. the reactor already kept a deadline heap for socket waits with timeouts; a sleep is exactly that wait minus the fd.deadline heap entries now carry a target — an fd wait or a pure timer — and sleeping tasks live in their own seq-keyed map, so nothing fd-shaped (
reconcile_fd,desired_mask,on_close) can ever see a timer.sleep_taskregisters the timer, nudges the reactor only when the new deadline is the nearest one, and parks the coroutine; the existing expiry sweep resolves it through the same compare-exchange outcome protocol fd waits use, so a resolve can only ever happen once. because a sleep has no syscall to retry as a source of truth, the task re-parks on any wake that arrives before its outcome is terminal, which is what makes "delay(n) sleeps at least n" hold. a zero or negative duration registers nothing and returns immediately.outside a green task — main, plain os threads, the whole os-thread backend —
current_task()isNone(the same guarddns.rsuses) and sleep blocks the thread exactly as before, which is correct there. on non-linux the fallback blocks the worker, consistent with how socket waits already degrade without a reactor.measured with one worker, a task sleeping 1s next to a cpu-bound task: the cpu task used to finish at 1018ms (after the whole sleep), now at 23ms, with the sleeper still done at 1001ms. the idle-select case was worse before: the cpu task only ran once the select timed out at 3015ms; now it finishes at 20ms and the select answers its channel at 21ms.
this closes the
sleepentry in docs/limitations.md.what was tested
cargo build --releaseandcargo test --release -p pith-runtime(136 passed), including new unit tests for timer registration/expiry and the nudge-only-when-nearest decisiontest_green_sleep_parks: two sleepers spawned ahead of a busy task on one worker must let the busy task finish first, the sleeps must overlap rather than serialize, and the longest sleep must still take its full 700ms. fails on the old runtime in exactly the first two ways (verified against a pre-change build)make testandPITH_GREEN=0 make test, both fully greenverify-green-corpusandverify-osthread-corpus: 288 passed, 0 failed eachmake run-examples(99 passed) andmake docsite-checkexamples/grpc_chat.pithandexamples/grpc_reflect.pithto completion at default workers and at 1 workertest_grpc_deadline_reuse,test_grpc_metadata_deadline,test_select_runtime,test_grpc_interactive_stream,test_grpc_stream_serve) individually under green default, green 1 worker, and os-threadnotes
the timer map is keyed by the registration seq rather than a sentinel fd. a reserved fd would have worked, but every fd-shaped code path would then need to know about it; a distinct
DeadlineTargetvariant lets the compiler rule that mixing out instead. no emitter changes —select's probe loop still emitssleep(1), it just parks now — so no bootstrap seed refresh is needed.