Skip to content

x86: a dispatched thread with a published wake returns the CPU without consuming it, 5-6 times per specimen #772

Description

@ryanbreen

Split out of the #764 round-2 review
(docs/planning/green-program/sockets/764-RCA-2026-09-03.md), which measured
but did not explain a sub-mechanism inside #766's dispatch-latency mechanism:
a thread that has already been woken and dispatched sometimes returns the CPU
without consuming its own wake, several times in a row, before a later
dispatch finally does. This is not the #766 round-robin wait itself -- it is
extra turns inside that wait that #766's "one round robin after a tail
re-enqueue" model does not predict.

The two specimens

repro-boot-057 (docs/planning/green-program/sockets/serials/764-rca/repro-boot-057/serial_kernel.txt):
the reader (thread 30) is woken at line 2291 (unblock(30): Added to per_cpu_queues[0]). It is then restored -- a full dispatch, not merely
enqueued -- six times before it observes the wake:

2313, 2349, 2407, 2490, 2558, 2620  <- "Restored kernel context for thread 30: RIP=0x80002f7912 RSP=0xffffc90000282978"
2622  <- "TCP_BLOCK: Thread 30 woken from recv blocking"

Five of those six restores (2313/2349/2407/2490/2558) are immediately
followed by a CR3 switch and a "Saved kernel context for blocked thread 30"
at an identical RIP/RSP, with zero intervening log lines -- e.g. 2313 (restore)
/ 2314 (CR3) / 2315 (save). Only the sixth (2620) reaches the wake check at
2622 and makes progress.

Re-derived in this slot:

grep -n 'unblock(30)' repro-boot-057/serial_kernel.txt
  -> 2123, 2291
grep -n 'Restored kernel context for thread 30' repro-boot-057/serial_kernel.txt
  -> 2203(pre-wake), 2313, 2349, 2407, 2490, 2558, 2620
grep -n 'woken from recv blocking' repro-boot-057/serial_kernel.txt
  -> 2622

707/boot01 (docs/planning/green-program/sockets/serials/707-2026-09-02/x86-battery-r2/boot01/serial_kernel.txt,
the issue's own #764 specimen): the reader (also thread 30) is woken at
2303, then restored seven times before it observes the wake:

2363, 2489, 2545, 2656, 2705, 2749, 2755  <- restores
2757  <- "TCP_BLOCK: Thread 30 woken from recv blocking"

Six of those seven restores are wasted; the seventh (2755) reaches the check
at 2757 and the read completes.

Re-derived in this slot:

grep -n 'unblock(30)' boot01/serial_kernel.txt
  -> 2210, 2303
grep -n 'Restored kernel context for thread 30' boot01/serial_kernel.txt
  -> 2287(pre-wake), 2363, 2489, 2545, 2656, 2705, 2749, 2755
grep -n 'woken from recv blocking' boot01/serial_kernel.txt
  -> 2757

The 707-battery turn census

docs/planning/green-program/sockets/serials/764-rca/summarize.py, run over
all 25 boots of 707-2026-09-02/x86-battery-r2/, counts a thread's restores
between its wake and the line where its sys_read wait loop observes it
(resumes column). 24 of 25 boots have a countable value (boot14 has no
accept-block line for the port and is excluded):

turns boots data_latency_ms
1 20 318 - 1480
2 1 2258
3 1 2768
4 1 3155
7 1 4740 -- the battery's exit 13

Re-derived in this slot:

python3 docs/planning/green-program/sockets/serials/764-rca/summarize.py \
        docs/planning/green-program/sockets/serials/707-2026-09-02/x86-battery-r2/boot* \
  | awk -F'\t' 'NR>1 && $NF!="" {print $NF}' | sort | uniq -c
  -> 20 x "1", 1 x "2", 1 x "3", 1 x "4", 1 x "7"

data_latency_ms tracks this turn count, not queue length or a uniform
per-pass cost -- 39 of 46 boots in the separate 76-boot battery's census
consume the wake on turn one, as expected if a dispatched reader normally
exits its wait loop immediately. The multi-turn boots are the outliers this
issue is about.

Why this isn't already explained

A dispatch of the reader is a full context-switch cycle: a restore
(kernel/src/interrupts/context_switch.rs:962), set_running()
(kernel/src/task/scheduler.rs:2103), and a fresh quantum
(kernel/src/interrupts/context_switch.rs:436). sys_read's wait loop
breaks the moment thread.state != Blocked
(kernel/src/syscall/handlers.rs:1403-1410):

crate::task::scheduler::yield_current();
crate::arch_halt_with_interrupts();

let still_blocked = crate::task::scheduler::with_scheduler(|sched| {
    if let Some(thread) = sched.current_thread_mut() {
        thread.state == crate::task::thread::ThreadState::Blocked
    } else {
        false
    }
})
.unwrap_or(false);

if !still_blocked {
    // ... consumes the wake

So once unblock() has run (set_ready() at scheduler.rs:2897, the
Added to per_cpu_queues log at :2951), a subsequent restore of the same
thread should read Blocked as false and take the !still_blocked arm on
its very first dispatch. It does not, five and six times respectively on the
two specimens above -- and a restore followed immediately by a save at the
same RIP/RSP looks identical in the serial log whether still_blocked ran
and (correctly or incorrectly) read Blocked, or the thread executed no
instruction from still_blocked at all before yielding again. The serials
alone cannot distinguish those two cases.

Candidate reading

Not concluded, just the shape worth checking: set_ready() at
scheduler.rs:2897 and set_running() at scheduler.rs:2103 are two
separate writes to thread state, on two different sides of a dispatch. If
there is a window in which a thread is dispatched (restored, set_running()
called) but its state field has not yet settled to a value distinguishable
from Blocked at the point still_blocked reads it -- or conversely, if
still_blocked can read a state that flips back to Blocked between the
read and the yield_current()/halt on some other iteration -- either would
produce exactly this shape: N restores that make no progress, then one that
does.

Proposed experiment (per CLAUDE.md: tracing framework, not log lines)

A trace_count! (or trace_event!) on the still_blocked == true branch of
the loop at handlers.rs:1403-1410, read back through the lock-free tracing
framework (kernel/src/tracing/, trace_dump() / trace_dump_counters()
via GDB or /proc/trace/*) rather than through a log line, so the hot
context-switch/syscall-wait path is not disturbed. If the counter increments
on the wasted restores, still_blocked is reading Blocked correctly on
each of them, and the question becomes why the scheduler keeps
re-dispatching a thread whose own wait loop has no work to do until turn N;
if it does not increment on some of the wasted restores, the thread is
failing to reach or execute that check at all on those restores, which
points at the dispatch path instead.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions