The swap (RFC 018). Schedulers no longer sleep on a shared level-triggered wake pipe — the herd source that made the default 8-thread config 7x slower than 2 threads (E1). They park on per-thread futex parkers via the coordination layer; IO backends become producers behind a two-call contract (make runnable, then the enqueue tail wakes exactly one parked scheduler). Deleted: the drain lock and the one-winner phase-1 drain; the shared completions VecDeque; the wake pipe fds, poll_wake, drain_wake_pipe, wake_scheduler, the FdReady/Blocking Completion enum; the 100us idle nap; the per-pop io.lock liveness read; io.rs's as_millis timeout truncation. Added: - enqueue wake tail (fixes the silent enqueue): wake_one_if_idle, a fence + one Relaxed mask load when everyone is busy — the pure-compute hot path pays almost nothing. - driver-enqueues: the pool thread stashes its result in the slot, decrements io_outstanding, unparks; the epoll thread removes+DELs the waiter under the waiters lock and unparks. Both reach the runtime via a Weak (no Arc cycle). The waiters map moves behind its own Arc<Mutex> so the epoll thread never takes the runtime io lock (teardown holds it while joining that thread). - io_outstanding / io_fd_waiters atomics: the termination verdict reads two atomics instead of taking io.lock on every pop. - timekeeper idle path: at most one parked scheduler holds the timer deadline (an expiry wakes one, not a herd); everyone else parks indefinitely and is woken by the enqueue tail. - busy-path timer due-check (ratified design point (a)): under saturation nobody parks and no timekeeper exists, yet due timers must still fire — one Relaxed load of the earliest-deadline snapshot per loop, clock read only when a timer is armed. Maintained under the timers mutex. - chain rule: a scheduler that pops with more work queued and a sibling parked wakes one, so surplus runs in parallel rather than behind it. tests/park_wake.rs pins the two new observable properties: timers fire under full scheduler saturation, and sub-ms sleeps are prompt (the as_millis truncation regression). Full suite + all loom models green; clippy --lib clean.
70 lines
2.7 KiB
Rust
70 lines
2.7 KiB
Rust
//! RFC 018 scheduler park/wake — observable-behavior guards.
|
|
//!
|
|
//! These pin the two timer-latency properties the park/wake swap must
|
|
//! preserve or introduce:
|
|
//!
|
|
//! - `sleep_fires_under_saturation`: due timers fire even when every
|
|
//! scheduler is busy (nobody parked ⇒ no timekeeper) — the busy-path
|
|
//! due-check, ratified design point (a). The old drain phase gave this
|
|
//! for free (timers drained every loop iteration); the new design must
|
|
//! not lose it.
|
|
//! - `submillisecond_sleep_is_prompt`: a sub-ms sleep completes promptly.
|
|
//! Under the old wake pipe, `poll_wake`'s `as_millis` truncation turned
|
|
//! sub-ms deadlines into 0ms busy-polls (correct wall time, pathological
|
|
//! CPU); under park/wake the futex timespec carries full nanosecond
|
|
//! precision.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[test]
|
|
fn sleep_fires_under_saturation() {
|
|
let rt = smarm::runtime::init(smarm::runtime::Config::exact(4));
|
|
rt.run(|| {
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let mut spinners = Vec::new();
|
|
// 8 spinners over 4 schedulers: the run queue never empties, so no
|
|
// scheduler ever parks and no timekeeper exists. Only the busy-path
|
|
// due-check can fire the sleeper's timer before the spinners quit.
|
|
for _ in 0..8 {
|
|
let stop = stop.clone();
|
|
spinners.push(smarm::spawn(move || {
|
|
let t0 = Instant::now();
|
|
while !stop.load(Ordering::Relaxed) && t0.elapsed() < Duration::from_secs(5) {
|
|
smarm::yield_now();
|
|
}
|
|
}));
|
|
}
|
|
let t0 = Instant::now();
|
|
smarm::sleep(Duration::from_millis(10));
|
|
let dt = t0.elapsed();
|
|
stop.store(true, Ordering::Relaxed);
|
|
for s in spinners {
|
|
let _ = s.join();
|
|
}
|
|
assert!(
|
|
dt < Duration::from_millis(500),
|
|
"10ms sleep took {dt:?} under scheduler saturation — busy-path \
|
|
timer firing is broken (timekeeper-only firing stalls under load)"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn submillisecond_sleep_is_prompt() {
|
|
let rt = smarm::runtime::init(smarm::runtime::Config::exact(2));
|
|
rt.run(|| {
|
|
// Warm one iteration, then measure.
|
|
smarm::sleep(Duration::from_micros(500));
|
|
let t0 = Instant::now();
|
|
smarm::sleep(Duration::from_micros(500));
|
|
let dt = t0.elapsed();
|
|
assert!(dt >= Duration::from_micros(400), "woke early: {dt:?}");
|
|
assert!(
|
|
dt < Duration::from_millis(100),
|
|
"500µs sleep took {dt:?} — sub-ms deadline handling is broken"
|
|
);
|
|
});
|
|
}
|