timer: sleep(duration) via min-heap of (deadline, pid)

Adds a BinaryHeap of timer entries on SchedulerState. sleep() inserts
an entry and parks; schedule_loop pops due entries each iteration and
unparks them. When the run queue is empty but timers are pending, the
OS thread sleeps until the soonest deadline.

Single-threaded only; thread::sleep is fine because no other thread
can wake us. The IO thread coming next will need a Condvar or pipe
wakeup to break this OS-sleep early.
This commit is contained in:
Claude
2026-05-22 05:22:55 +00:00
parent 6c48caecab
commit 2cf75febdc
4 changed files with 253 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ pub mod actor;
pub mod channel;
pub mod scheduler;
pub mod supervisor;
pub mod timer;
// ---------------------------------------------------------------------------
// Global allocator
@@ -36,5 +37,5 @@ static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
pub use channel::{channel, Receiver, RecvError, Sender};
pub use pid::Pid;
pub use scheduler::{run, self_pid, spawn, spawn_under, yield_now, JoinError, JoinHandle};
pub use scheduler::{run, self_pid, sleep, spawn, spawn_under, yield_now, JoinError, JoinHandle};
pub use supervisor::Signal;

View File

@@ -100,6 +100,8 @@ struct SchedulerState {
/// The root supervisor's PID. Children spawned at the top level are
/// supervised by this. Set by `run()`.
root_pid: Option<Pid>,
/// Pending sleep timers. Min-heap keyed by deadline.
timers: crate::timer::Timers,
}
impl SchedulerState {
@@ -109,6 +111,7 @@ impl SchedulerState {
free_list: Vec::new(),
run_queue: VecDeque::new(),
root_pid: None,
timers: crate::timer::Timers::new(),
}
}
@@ -331,6 +334,16 @@ pub fn park_current() {
unsafe { crate::context::switch_to_scheduler() };
}
/// Park the current actor for at least `duration`. A zero duration behaves
/// like `yield_now` (the deadline is immediately in the past, so the timer
/// pops on the next scheduler iteration).
pub fn sleep(duration: std::time::Duration) {
let me = current_pid().expect("sleep() called outside an actor");
let deadline = crate::timer::deadline_from_now(duration);
with_sched(|s| s.timers.insert(deadline, me));
park_current();
}
/// Wake a parked actor. If the actor isn't parked (already runnable or done)
/// this is a no-op — that's important; channel and join can both fire
/// spurious unparks under some orderings and we want them to be cheap.
@@ -417,9 +430,41 @@ pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX);
fn schedule_loop() {
loop {
// 1. Drain due timers into the run queue.
let now = std::time::Instant::now();
let due = with_sched(|s| s.timers.pop_due(now));
for pid in due {
// Same idempotency as `unpark`: only re-queue if still parked.
with_sched(|s| {
if let Some(slot) = s.slot_mut(pid) {
if matches!(slot.state, State::Parked) {
slot.state = State::Runnable;
s.run_queue.push_back(pid);
}
}
});
}
// 2. Pop a runnable actor. If none, sleep on the soonest timer or
// exit if there isn't one.
let pid = match with_sched(|s| s.run_queue.pop_front()) {
Some(p) => p,
None => return,
None => {
let next = with_sched(|s| s.timers.peek_deadline());
match next {
Some(deadline) => {
let now = std::time::Instant::now();
if deadline > now {
// No other thread can wake us; plain sleep is
// correct. When the IO thread lands in v0.2
// this becomes a Condvar / pipe wakeup.
std::thread::sleep(deadline - now);
}
continue;
}
None => return, // no runnables, no timers — done.
}
}
};
// Look up sp; skip stale or already-reaped pids.

89
src/timer.rs Normal file
View File

@@ -0,0 +1,89 @@
//! Sleep timers.
//!
//! A min-heap of `(deadline, Pid)` entries lives on `SchedulerState`. When
//! an actor calls `sleep`, the runtime inserts the entry, marks the actor
//! parked, and yields. On every scheduler loop iteration the runtime pops
//! all entries whose deadline has passed and unparks them. When the run
//! queue is empty but the heap is not, the runtime sleeps the OS thread
//! until the soonest deadline, then re-checks.
//!
//! `BinaryHeap` is a max-heap, so entries are stored with their deadline
//! wrapped in `Reverse` to get min-heap behaviour.
//!
//! Stale pids (slot reused since the timer was inserted) are detected on
//! `due_pids` pop and silently dropped — same convention as the run queue.
use crate::pid::Pid;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::time::{Duration, Instant};
#[derive(PartialEq, Eq)]
pub struct Entry {
pub deadline: Instant,
pub pid: Pid,
}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Only `deadline` matters for ordering; pid is a tiebreaker so the
// type is Ord, but the order among same-deadline entries is
// irrelevant.
self.deadline
.cmp(&other.deadline)
.then_with(|| self.pid.index().cmp(&other.pid.index()))
.then_with(|| self.pid.generation().cmp(&other.pid.generation()))
}
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Default)]
pub struct Timers {
/// Reverse-wrapped so the smallest deadline is at the top.
heap: BinaryHeap<Reverse<Entry>>,
}
impl Timers {
pub fn new() -> Self {
Self { heap: BinaryHeap::new() }
}
pub fn insert(&mut self, deadline: Instant, pid: Pid) {
self.heap.push(Reverse(Entry { deadline, pid }));
}
pub fn is_empty(&self) -> bool {
self.heap.is_empty()
}
/// Soonest pending deadline, or `None` if the heap is empty.
pub fn peek_deadline(&self) -> Option<Instant> {
self.heap.peek().map(|r| r.0.deadline)
}
/// Pop and return every pid whose deadline is ≤ `now`.
pub fn pop_due(&mut self, now: Instant) -> Vec<Pid> {
let mut out = Vec::new();
while let Some(r) = self.heap.peek() {
if r.0.deadline <= now {
let e = self.heap.pop().unwrap().0;
out.push(e.pid);
} else {
break;
}
}
out
}
}
/// Wall-clock duration helper exposed for `sleep`.
pub fn deadline_from_now(duration: Duration) -> Instant {
Instant::now()
.checked_add(duration)
.unwrap_or_else(Instant::now)
}