diff --git a/src/runtime.rs b/src/runtime.rs index e1a2541..19e8e0d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -594,10 +594,31 @@ fn schedule_loop(inner: &Arc, slot: usize) { // losers skip immediately and proceed to step 2. // ---------------------------------------------------------------- if let Ok(_drain_guard) = inner.drain_lock.try_lock() { - let now = std::time::Instant::now(); - - // Drain due timers. - let due = inner.with_shared(|s| s.timers.pop_due(now)); + // Drain due timers and IO completions in a SINGLE shared-lock + // acquisition. Previously this took two separate `with_shared` + // calls (one for timers, one for IO) plus an unconditional + // `Instant::now()` every loop iteration. On the pure-yield / + // pure-compute hot path there are never any timers, so the clock + // read and the extra lock were pure per-yield overhead. We now + // read the clock only when the timer heap is non-empty and fold + // both drains into one lock hold. + // + // IO completions are still drained unconditionally while the IO + // subsystem is present: the IO/pool threads push completions onto + // their own mutex (not `shared`), so the scheduler can't cheaply + // know in advance whether any arrived — it must look. That look is + // a single empty-VecDeque check on the empty path. + let (due, completions) = inner.with_shared(|s| { + let due = if s.timers.is_empty() { + Vec::new() + } else { + s.timers.pop_due(std::time::Instant::now()) + }; + let completions = s.io.as_mut() + .map(|io| io.drain_completions()) + .unwrap_or_default(); + (due, completions) + }); for entry in due { match entry.reason { crate::timer::Reason::Sleep => { @@ -631,10 +652,6 @@ fn schedule_loop(inner: &Arc, slot: usize) { } } - // Drain IO completions. - let completions = inner.with_shared(|s| { - s.io.as_mut().map(|io| io.drain_completions()).unwrap_or_default() - }); for completion in completions { match completion { crate::io::Completion::Blocking { pid, result } => {