Files
smarm/src/timer.rs
T

237 lines
9.9 KiB
Rust

//! Sleep + wait-with-timeout timers.
//!
//! A min-heap of `(deadline, seq, reason)` entries lives on `SchedulerState`.
//! When an actor sleeps or starts a bounded wait (e.g. `mutex.lock()` with a
//! timeout), the runtime inserts an entry, marks the actor parked, and yields.
//! On every scheduler loop iteration the runtime pops all entries whose
//! deadline has passed and dispatches each according to its `Reason`:
//!
//! - `Sleep`: unpark the actor.
//! - `WaitTimeout`: call `on_timeout` on the registered target. The target
//! (e.g. a `Mutex`) decides whether the actor was actually still waiting
//! (timer fires first → unpark with error) or had already been granted
//! what it was waiting for (lock granted first → no-op).
//!
//! `BinaryHeap` is a max-heap; entries are wrapped in `Reverse` to get
//! min-heap behaviour.
//!
//! Cancellation is selective. A `Sleep` / `WaitTimeout` entry is left in the
//! heap on a non-timer wakeup (lock granted before timeout): it is popped
//! eventually and no-ops because a stale unpark fails its epoch CAS — cheap
//! (~32 bytes per stale entry plus a few cycles on pop), bounded by one entry
//! per parked actor. A `Send` entry is different: running its thunk delivers a
//! real message, so a stale one is *not* inert. `send_after` therefore carries
//! true cancellation via the `armed` set keyed on the entry's `seq`; `pop_due`
//! fires a `Send` only while it is still armed, and `cancel` removes the arm.
//!
//! Stale pids (slot reused since the timer was inserted) are filtered on
//! pop by the scheduler — same convention as the run queue.
use crate::pid::Pid;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// What to do when a timer entry's deadline arrives.
///
/// Held inside `Entry`, dispatched by the scheduler in `pop_due`.
pub enum Reason {
/// `sleep(d)`. Wake `pid` via the epoch-matched unpark: if anything
/// else (necessarily a terminal wake) already consumed the wait, the
/// entry is stale and no-ops at the CAS.
Sleep { epoch: u32 },
/// A bounded wait (`Mutex::lock_timeout`, `Receiver::recv_timeout`,
/// `select_timeout`). On expiry the scheduler calls
/// `target.on_timeout(pid, epoch)`. The target then decides whether
/// `pid` was actually still waiting (registration still present under
/// its lock), and if so takes the registration and unparks via
/// `unpark_at`. The epoch is the slot-word park-epoch — the runtime-wide
/// wait identity — so a stale entry is doubly inert: the registration
/// check misses, and even a racing unpark fails the word's epoch CAS.
WaitTimeout {
target: Arc<dyn TimerTarget>,
epoch: u32,
},
/// `send_after`: deliver a message to an address at the deadline,
/// cancellable. The destination (a `Pid<A>` / `Name<M>`) and the message
/// are captured inside `fire`, which resolves the address through the
/// registry and sends *when run* — so a target that died or, for a name,
/// was restarted is observed at fire time, not arm time. A failed resolve
/// or send is dropped (Erlang `erlang:send_after` semantics).
///
/// Unlike `Sleep` / `WaitTimeout`, a stale `Send` is **not** inert — running
/// the thunk delivers a real message — so these are the only timers that
/// carry true cancellation (the `armed` set on [`Timers`], keyed by the
/// entry's `seq`). `pop_due` fires the thunk only for an entry still armed.
Send { fire: Box<dyn FnOnce() + Send> },
}
/// Opaque handle to an armed `send_after` timer, returned by
/// [`Timers::insert_send`] and consumed by [`Timers::cancel`]. The inner value
/// is the entry's insertion `seq`; callers must treat it as opaque so the
/// backing structure can change (e.g. a future hierarchical timing wheel) with
/// no API churn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TimerId(u64);
impl TimerId {
/// Wrap a raw value. Crate-internal: the gen_server timer layer mints its
/// own loop-local `TimerId`s (the public ids it hands out, decoupled from
/// the per-re-arm substrate `seq`) and maps them to live substrate ids.
/// These local ids are only ever resolved through that layer's registry —
/// never passed back to [`Timers::cancel`] — so the two id roles do not mix.
pub(crate) fn from_raw(v: u64) -> Self {
TimerId(v)
}
}
/// Callback the scheduler invokes when a `WaitTimeout` entry pops.
///
/// Implementors: do not touch `SchedulerState` other than via the public
/// `unpark` / channel APIs. The scheduler is mid-iteration when this fires.
pub trait TimerTarget: Send + Sync {
fn on_timeout(&self, pid: Pid, epoch: u32);
}
pub struct Entry {
pub deadline: Instant,
/// Insertion order, used purely as a tiebreaker so `Entry: Ord` works
/// without having to compare the `Reason` payload (which contains an
/// `Rc<dyn TimerTarget>` and isn't `Ord`).
seq: u64,
pub pid: Pid,
pub reason: Reason,
}
impl PartialEq for Entry {
fn eq(&self, other: &Self) -> bool {
self.deadline == other.deadline && self.seq == other.seq
}
}
impl Eq for Entry {}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Earlier deadline first; ties broken by insertion order so the
// ordering is total. `Reason` and `Pid` deliberately don't
// participate.
self.deadline.cmp(&other.deadline).then_with(|| self.seq.cmp(&other.seq))
}
}
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>>,
/// Monotonic counter for the tiebreaker `seq` field (and the `TimerId` of a
/// `Send` timer — the two are the same value).
next_seq: u64,
/// Presence set of *live* `Send` timers, keyed by `seq`. Populated on
/// `insert_send`, removed on fire (in `pop_due`) and on `cancel`. A `Send`
/// entry fires only while present, so a `cancel` that lands before the
/// entry pops prevents delivery; a `cancel` after it has fired finds
/// nothing (the race signal). Bounded by armed-but-not-yet-resolved timers
/// and self-collecting — no sweep. `Sleep` / `WaitTimeout` never touch it.
armed: std::collections::HashSet<u64>,
}
impl Timers {
pub fn new() -> Self {
Self { heap: BinaryHeap::new(), next_seq: 0, armed: std::collections::HashSet::new() }
}
/// Insert a `Sleep` timer. Convenience for the common case.
pub fn insert_sleep(&mut self, deadline: Instant, pid: Pid, epoch: u32) {
self.insert(deadline, pid, Reason::Sleep { epoch });
}
/// Arm a cancellable `send_after` timer: run `fire` at `deadline` unless
/// [`cancel`](Self::cancel)led first. `pid` is informational only (the
/// destination, or who armed it — useful for introspection); it is *not*
/// used to wake anyone, the delivery lives entirely inside `fire`. Returns
/// a [`TimerId`] for cancellation.
pub fn insert_send(
&mut self,
deadline: Instant,
pid: Pid,
fire: Box<dyn FnOnce() + Send>,
) -> TimerId {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.armed.insert(seq);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason: Reason::Send { fire } }));
TimerId(seq)
}
/// Cancel an armed `send_after` timer. Returns `true` if the timer was
/// still armed (delivery is now prevented), `false` if it had already
/// fired or been cancelled. The heap entry, if still pending, is left to be
/// discarded when its deadline passes — `pop_due` drops any `Send` entry
/// whose `seq` is no longer armed.
pub fn cancel(&mut self, id: TimerId) -> bool {
self.armed.remove(&id.0)
}
/// Insert an arbitrary timer entry.
pub fn insert(&mut self, deadline: Instant, pid: Pid, reason: Reason) {
let seq = self.next_seq;
self.next_seq = self.next_seq.wrapping_add(1);
self.heap.push(Reverse(Entry { deadline, seq, pid, reason }));
}
pub fn is_empty(&self) -> bool {
self.heap.is_empty()
}
/// Drop all pending entries. Called by the scheduler when it has decided
/// no actor is live: any remaining timer is orphaned and exists only to be
/// discarded so it can't keep the runtime alive.
pub fn clear(&mut self) {
self.heap.clear();
self.armed.clear();
}
/// 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 every entry whose deadline is ≤ `now`, in deadline order.
/// The scheduler dispatches each entry by inspecting `entry.reason`.
///
/// A due `Send` entry is returned only if it is still armed; a cancelled
/// one is silently dropped here (its `seq` was already removed from
/// `armed` by [`cancel`](Self::cancel)). Returning it removes it from
/// `armed`, so a later `cancel` of a fired timer reports `false`.
pub fn pop_due(&mut self, now: Instant) -> Vec<Entry> {
let mut out = Vec::new();
while let Some(r) = self.heap.peek() {
if r.0.deadline <= now {
let entry = self.heap.pop().unwrap().0;
if matches!(entry.reason, Reason::Send { .. }) && !self.armed.remove(&entry.seq) {
// Cancelled before it came due: discard, do not deliver.
continue;
}
out.push(entry);
} else {
break;
}
}
out
}
}
/// Wall-clock duration helper exposed for `sleep` and `lock_timeout`.
pub fn deadline_from_now(duration: Duration) -> Instant {
Instant::now()
.checked_add(duration)
.unwrap_or_else(Instant::now)
}