Skip to content

Return from a sleep as soon as a wake arrives - #377

Merged
jserv merged 2 commits into
sysprog21:mainfrom
xalestar:sleep-wake-signal-latency
Sep 13, 2026
Merged

Return from a sleep as soon as a wake arrives#377
jserv merged 2 commits into
sysprog21:mainfrom
xalestar:sleep-wake-signal-latency

Conversation

@xalestar

@xalestar xalestar commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

A guest thread parked in nanosleep does not notice a queued signal until its own 100 ms recheck quantum expires. sys_nanosleep splits the interval into 100 ms chunks and tests signal_pending between them, so the delay is uniform over 0 to 100 ms, about 50 ms on average, against 0.12 ms under Linux 6.12.

The wake those chunks stand in for already exists: queueing a guest signal, exit_group and futex_interrupt all reach wakeup_pipe_signal, and io.c and poll.c already join it. A pure timeout has no descriptor to watch alongside the pipe, so it parks on a condition variable added to that same call instead, which leaves all five existing callers of the signal covering it unchanged.

A signal, exit_group and an execve teardown end the sleep on the wake. An execve handed to the leader keeps the SLEEP_CHUNK_NS cadence: a handoff issues a stop request thousands of times a second, and answering every ring turns each into a guest-visible EINTR that the guest answers by reissuing the rest of its interval; the numbers that cost are below. One broadcast also wakes every sleeper, so the sleep claims a process-directed signal under sig_lock before reporting EINTR, reusing the pick rt_sigsuspend makes, moved into a helper in the first commit.

Reproduction

tests/test-nanosleep-signal-latency.c holds one thread in a sleep and times the gap between pthread_kill and the handler running, for both nanosleep and clock_nanosleep(TIMER_ABSTIME). The delay before the signal walks forward across iterations: a fixed delay phase-locks every iteration to one offset inside the quantum and reports that point as though it were the whole distribution.

On the parent commit the test fails with 60.6 ms and 28.9 ms medians against its 20 ms ceiling. With this change it passes, and it also passes under the QEMU reference lane.

tests/test-nanosleep-process-signal.c parks four threads in nanosleep and sends the group one kill(). Without the claim 2 to 4 sleepers return EINTR while the handler runs once; with it exactly one does, as under QEMU.

Measurements

before after Linux 6.12
signal to handler, median of 12 50 ms 0.069 ms 0.12 ms
1 us sleep, min/median of 200 7 / 8 us 4 / 5 us
10 us sleep 18 / 20 us 15 / 17 us
100 us sleep 132 / 135 us 130 / 133 us
1 ms sleep 1051 / 1273 us 1111 / 1274 us

nanosleep, pselect and pthread_cond_timedwait_relative_np are indistinguishable in resolution at 200 samples (3 / 2 / 3 us for a 1 us request), so the primitive swap costs nothing. Short sleeps come out slightly faster because the old path rebuilt a timespec and ran the saturating remainder arithmetic once per chunk.

The stop cadence is what the 400 ms guest sleep in test-exec-handoff measures, under handoff pressure. It only separates the two revisions inside the full sanitizer suite; run on its own the test sits at 1800 to 2400 ms either way.

Same tree built with and without the change, alternating.

under make check-tsan interruptions sleep
before the change 3 432 ms
handoff stop tested on every wake 29390 3150 ms
this revision 3 403 ms

Commands

  • make check, make check-tsan, make check-asan, make check-ubsan: all exit 0, with check-tsan green over four consecutive runs
  • make check-format, make lint: exit 0
  • make check-ascii: clean across 427 source files
  • test-wakeup-pipe-host goes from 5 to 9 checks, covering the counter advancing, a stale counter not parking, a wait holding until the wake, and a signal releasing a parked wait

Rebased on 4023647.


Summary by cubic

Guest threads parked in nanosleep or clock_nanosleep(TIMER_ABSTIME) used to notice queued signals only when their 100 ms recheck quantum expired, adding about 50 ms average signal-to-handler latency. They now wake immediately via wakeup_pipe_signal(), cutting that to about 0.07 ms without regressing short sleeps.

Bug Fixes

  • Pure timeout sleeps now park on a condition variable driven by wakeup_pipe_signal(), since they have no descriptor to watch; existing callers are unchanged.
  • Adds an atomic wake counter so a wake that lands between a caller's predicate check and its park is not lost.
  • Recomputes remaining sleep time from CLOCK_MONOTONIC, because the wait can return early on any process-wide wake.
  • Keeps stop-request checks on the old recheck cadence; testing them on every wake would make exec handoffs produce a guest-visible EINTR storm.
  • A process-directed signal is now claimed under sig_lock by one sleeper, so a single kill() interrupts one thread instead of every sleeper.
  • Adds latency and process-signal tests and expands the host wakeup-pipe test to cover the condvar wait.

Written for commit 76ba1ad. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

@xalestar
xalestar force-pushed the sleep-wake-signal-latency branch from 7b75c19 to e73d1f0 Compare September 12, 2026 18:25
cubic-dev-ai[bot]

This comment was marked as resolved.

@xalestar
xalestar force-pushed the sleep-wake-signal-latency branch from e73d1f0 to 9c92fe9 Compare September 13, 2026 03:00
@xalestar

Copy link
Copy Markdown
Collaborator Author

The TSAN red on the previous push came from moving both halves of the sleep's predicate onto the wake. They are not the same condition. A tagged counter over one run of test-exec-handoff under handoff pressure:

passes=45000 stop=22465 sig=20

Almost every early return was thread_stop_requested, not a queued guest signal. An exec handoff issues a stop request thousands of times a second, so testing it on every wake turns each ring into a guest-visible EINTR, and the guest answers each one by reissuing the rest of its interval.

This push tests signal_pending on every wake, which is what the change is for, and keeps thread_stop_requested on the SLEEP_CHUNK_NS cadence it had before, which is unchanged behaviour for that half.

The measurement only separates the two versions inside the full sanitizer suite. Run on its own the test sits at 1800 to 2400 ms either way; under make check-tsan:

interruptions sleep
before the change 3 432 ms
previous push 29390 3150 ms
this push 3 407 ms

make check-tsan is green over four consecutive runs here, as are check, check-asan, check-ubsan, check-format, lint and check-ascii. Signal to handler is unchanged at 0.053 to 0.074 ms.

@xalestar
xalestar force-pushed the sleep-wake-signal-latency branch from 9c92fe9 to 3fb4be8 Compare September 13, 2026 03:19
Comment thread src/syscall/wakeup-pipe.h Outdated
Comment thread src/syscall/time.c
* pressure taking 3150 ms across 29390 returns, against 432 ms across 3
* on this cadence.
*/
bool stop_due = elapsed_ns >= stop_due_ns;

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.

thread_stop_requested() is three conditions, and only one of them is the handoff the EINTR measurement above is about. thread_stop_is_leader_work_only() already separates them, so exit_group and an execve teardown could be answered on the wake that is now in hand while the handoff keeps its SLEEP_CHUNK_NS cadence: stop_due || !thread_stop_is_leader_work_only(). As written, a guest thread in a multi-second sleep still holds up teardown for up to 100 ms after the wake has already reached it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The sleep tests thread_stop_requested() && (stop_due || !thread_stop_is_leader_work_only()), so exit_group and an execve teardown end it on the wake while the handoff keeps the SLEEP_CHUNK_NS cadence. The stop test also runs before the signal test, because a thread an execve reaps would otherwise claim a process-directed signal into its private set and take it along. Under make check-tsan the handoff sleep in test-exec-handoff is 403 ms across 3 interruptions, the same as without the change.

Comment thread src/syscall/time.c Outdated
* signal that arrives first is answered by the check at the top of the
* sleep loop, which is fast whether or not the wait itself is joined.
*/
struct timespec settle = {0, (150 + i * 11) * 1000000L};

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.

The settle delay is a guess, not a handshake. If the sleeper has not reached the wait when pthread_kill fires, the signal is answered by the signal_pending() test at the top of the sleep loop and the iteration records a fast gap on the unfixed tree too. A slow pthread_create under the sanitizer lanes can put several iterations in that state, and once half of them land there the median stops measuring the thing this test exists for. Have the sleeper publish an atomic flag immediately before it calls nanosleep and spin on that flag before the settle delay.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The sleeper sets sleeper_armed immediately before nanosleep or clock_nanosleep, and median_gap spins on it, bounded at SLEEP_SEC, before the settle delay.

wakeup_wait_ns(5000000000LL, before_ring);
long long waited_ms = now_ms() - t0;
pthread_join(ringer, NULL);
host_check(waited_ms < 1000, "a signal releases a parked wait",

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.

Both new waits assert only an upper bound, so a wakeup_wait_ns that returns at once and never parks passes them: it would report about 0 ms, well under the 1000 ms ceiling, and the stale-counter check above passes for the same reason. Add the lower bound that separates the two, something like waited_ms >= 40, since the ringer sleeps 50 ms before it signals.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added waited_ms >= 40. t0 also moved ahead of pthread_create: taken after it, a ringer already partway through its 50 ms could put a correct wait under the bound on a slow start.

rt_sigsuspend picks the first signal that would reach the guest, from
the private set and then the shared set, and moves a shared one into
the caller's private set under sig_lock so no sibling vCPU delivers it
first. A sleep woken on the same broadcast as its siblings needs the
same pick, so it moves to signal_claim_waking_locked, with no change to
what rt_sigsuspend does.
@xalestar
xalestar force-pushed the sleep-wake-signal-latency branch from 2d6482a to fbe1446 Compare September 13, 2026 15:08
cubic-dev-ai[bot]

This comment was marked as resolved.

A guest thread that sleeps is a host thread parked in the sleep handler,
outside hv_vcpu_run, where hv_vcpus_exit cannot reach it. sys_nanosleep
answered that by splitting the interval into 100 ms chunks and testing
signal_pending between them, so a queued signal waited out the rest of a
chunk: uniform over 0 to 100 ms, 50 ms on average, against 0.12 ms on
Linux.

The wake those chunks stand in for already exists. Queueing a guest
signal, exit_group and futex_interrupt all reach wakeup_pipe_signal, and
io.c and poll.c already join it. A pure timeout has no descriptor to
watch alongside it, so it takes the condition variable this adds to that
same call rather than the pipe, which leaves every existing caller of
the signal covering it unchanged.

One broadcast wakes every sleeper at once, and signal_pending shows the
shared set to each of them, so a single kill() would return EINTR from
all of them while only one runs the handler. The sleep claims the signal
under sig_lock through signal_claim_interruption instead, and only the
claiming thread is interrupted, as complete_signal picks one thread on
Linux. The claim passes over a signal that delivery would discard, so
SIG_IGN and default-ignored signals do not interrupt the sleep.

A signal, exit_group and an execve teardown end the sleep on the wake.
An execve handed to the leader keeps the chunk cadence: a handoff rings
thousands of times a second, and answering every ring turns each into a
guest-visible EINTR that the guest answers by reissuing the rest of its
interval. Teardown is tested before the claim, so a thread an execve
reaps does not take a process-directed signal with it.

The wake counter is what makes the park safe. A caller tests its
predicate outside wake_lock, so a wake landing between the test and the
park would otherwise be broadcast to a thread that has not arrived yet.
Reading the counter first turns that case into a park that returns
immediately. The counter is atomic so that read takes no lock, since a
caller reads it once per pass and the ringer holds the lock to bump it.

The remaining interval comes from CLOCK_MONOTONIC rather than from the
chunk, because the park returns early on another thread's wake and a
condition variable may return spuriously.

Measured: signal to handler 50 ms median before, 0.069 ms after, over 12
trials with the delay before the signal walked across the quantum. One
kill() aimed at four sleeping threads interrupted 2 to 4 of them without
the claim and exactly 1 with it, as on Linux. Short sleeps do not
regress: 1 us requested takes 5 us against 8 us before, and 1 ms takes
1274 us against 1273 us, median of 200. The 400 ms guest sleep
test-exec-handoff runs under handoff pressure takes 403 ms across 3
returns under a TSAN build of the whole sanitizer suite, against 432 ms
across 3 before the change.
@xalestar
xalestar force-pushed the sleep-wake-signal-latency branch from fbe1446 to 76ba1ad Compare September 13, 2026 15:19
@jserv
jserv merged commit 4872bb3 into sysprog21:main Sep 13, 2026
16 checks passed
@henrybear327

Copy link
Copy Markdown
Collaborator

This unblocks the LTP test! Thanks for this improvement! :)

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.

4 participants