Skip to content

park a sleeping green task in the reactor instead of holding its worker - #627

Merged
kacy merged 4 commits into
mainfrom
green-sleep-parks-in-reactor
Jul 31, 2026
Merged

park a sleeping green task in the reactor instead of holding its worker#627
kacy merged 4 commits into
mainfrom
green-sleep-parks-in-reactor

Conversation

@kacy

@kacy kacy commented Jul 30, 2026

Copy link
Copy Markdown
Owner

summary

pith_sleep was std::thread::sleep, so under the green backend a task calling time.delay held its whole worker for the duration — and select with 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_task registers 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() is None (the same guard dns.rs uses) 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 sleep entry in docs/limitations.md.

what was tested

  • cargo build --release and cargo test --release -p pith-runtime (136 passed), including new unit tests for timer registration/expiry and the nudge-only-when-nearest decision
  • new regression case test_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 test and PITH_GREEN=0 make test, both fully green
  • verify-green-corpus and verify-osthread-corpus: 288 passed, 0 failed each
  • make run-examples (99 passed) and make docsite-check
  • examples/grpc_chat.pith and examples/grpc_reflect.pith to completion at default workers and at 1 worker
  • the deadline- and select-sensitive cases (test_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-thread
  • zero and negative delays return immediately from inside a green task

notes

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 DeadlineTarget variant lets the compiler rule that mixing out instead. no emitter changes — select's probe loop still emits sleep(1), it just parks now — so no bootstrap seed refresh is needed.

kacy added 4 commits July 30, 2026 21:00
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.
@kacy

kacy commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

pushed one more commit. while checking the edge cases the pr's own design notes
call out, i found time.delay with a negative argument hangs — the millisecond
count was cast straight to an unsigned width, so -5 asked for roughly 584
million years.

it predates this branch (the os-thread backend hangs on it identically on
main), but the reactor path inherited it, so the branch was not the fix it
claimed to be for negatives — zero was handled, negatives were not. clamping at
pith_sleep's entry covers both paths and keeps them consistent, which the
corpora require since they diff the two backends.

verified after the change: zero and negative return promptly, a solo sleeper
still wakes, and twelve concurrent sleepers all wake in ~201 ms rather than
serializing — on green at one and two workers and on os threads. the parking
measurement is unchanged: a cpu task shares a worker with a one-second sleeper
and finishes at 7 ms while the sleeper still sleeps a true 1001 ms.

@kacy
kacy merged commit b752b1c into main Jul 31, 2026
2 checks passed
@kacy
kacy deleted the green-sleep-parks-in-reactor branch July 31, 2026 00:56
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