Files
smarm/src/slot_state.rs
T
smarm 00128f32a2 feat(channel): select — ready-index wait over multiple receivers
select(&[&dyn Selectable]) -> usize registers (pid, epoch) in every arm
under one wait epoch, parks once, and returns the first ready index in
documented priority order (BEAM-style; no fairness promise). A closed arm
counts as ready — and stays ready forever, so callers drop it from the set
once observed. Losing arms need no cancellation pass: the winning wake
consumed the epoch, so their registrations die at their wakers' failed CAS
or are overwritten by the receiver's next wait on that channel (the
single-receiver debug_asserts relax to 'none or own pid' accordingly).

The one genuinely new protocol piece is the no-park exit: returning with an
arm ready at registration time leaves earlier arms holding LIVE-epoch
registrations, whose wakes could fault a later one-shot park as a pending
notification. scheduler::retire_wait closes it — bump the epoch (in-flight
wakes die at their CAS), eat a notification that already landed
(StateWord::clear_notify), then re-observe the stop flag, in that order.
Proved by the new loom theorem retire_eats_late_arm_notification; the
integration probes in tests/select.rs fire stale loser-arm wakes and assert
a subsequent sleep's one-shot park holds its full duration.
2026-06-10 07:45:00 +00:00

625 lines
24 KiB
Rust

//! The per-slot scheduling state machine, as a standalone unit.
//!
//! One atomic word packs `(generation << 32) | (epoch << 8) | state`; every
//! transition is a CAS on the packed word, so the generation check is atomic
//! with the transition — no ABA, no acting on a recycled slot. The diagram
//! and the full protocol rationale live in `runtime.rs`; this module is the
//! mechanism, factored out so that:
//!
//! - loom can model-check the production transitions directly (see the
//! `loom_tests` module; built with `RUSTFLAGS="--cfg loom"`), and
//! - every method asserts the precondition it relies on (`debug_assert!` —
//! these are hot paths), per the assert-the-invariants house rule.
//!
//! ## The park-epoch (wait identity)
//!
//! The middle 24 bits carry the slot's *park-epoch*: the identity of the
//! actor's current (or most recent) wait. The rules:
//!
//! - [`begin_wait`](StateWord::begin_wait) bumps the epoch and returns it;
//! the actor calls it once per wait, *before* registering itself with any
//! waker. Registrations carry `(pid, epoch)`.
//! - A wake may be **epoch-matched** (`unpark(gen, Some(epoch))`): it lands
//! only if the word still carries that epoch. Wakers whose registration
//! handle can outlive the wait it was created for (channel senders, mutex
//! grants, wait-timers) MUST use this form.
//! - Every successful wake **consumes** the epoch — `Parked(e) → Queued(e+1)`,
//! `Running(e) → RunningNotified(e+1)` — so at most one wake can ever land
//! per wait, by construction. A loser in a multi-waker race (e.g. the
//! non-winning arms of a `select`) fails the epoch check and no-ops; it can
//! neither steal a future wait's wake nor leave a pending notification that
//! would fault a later one-shot park (`Mutex::lock_timeout`, `sleep`,
//! `block_on_io`, `wait_fd` all rely on wakes being *meaningful*).
//! - The wildcard form (`unpark(gen, None)`) also consumes, and is reserved
//! for terminal wakes — `request_stop` — which never return control to the
//! code that parked.
//!
//! Epoch wrap (24 bits = 16.7M waits) is harmless: a collision would require
//! a taken registration to stay in flight across a full wrap of the *same
//! actor's* waits, and registrations are consumed at take-time under their
//! primitive's lock — the exposure is the taker's instruction window.
//!
//! Atomics come from `sync_shim` (std normally, `loom::sync` under
//! `cfg(loom)`).
use crate::sync_shim::{AtomicU64, Ordering};
pub(crate) const ST_VACANT: u64 = 0;
pub(crate) const ST_QUEUED: u64 = 1;
pub(crate) const ST_RUNNING: u64 = 2;
pub(crate) const ST_RUNNING_NOTIFIED: u64 = 3;
pub(crate) const ST_PARKED: u64 = 4;
pub(crate) const ST_DONE: u64 = 5;
/// Park-epoch width: 24 bits, packed at word bits 8..32.
pub(crate) const EPOCH_MASK: u32 = 0x00FF_FFFF;
#[inline]
pub(crate) const fn pack(gen: u32, epoch: u32, st: u64) -> u64 {
debug_assert!(epoch & !EPOCH_MASK == 0);
((gen as u64) << 32) | ((epoch as u64) << 8) | st
}
#[inline]
pub(crate) const fn word_gen(w: u64) -> u32 {
(w >> 32) as u32
}
#[inline]
pub(crate) const fn word_epoch(w: u64) -> u32 {
((w >> 8) as u32) & EPOCH_MASK
}
#[inline]
pub(crate) const fn word_state(w: u64) -> u64 {
w & 0xFF
}
/// What an unpark amounted to. The caller owns the side effects (enqueue,
/// trace events) — this module is pure state.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) enum Unpark {
/// Parked → Queued: the caller must enqueue the pid.
Enqueue,
/// Running → RunningNotified: the scheduler's park-return will re-queue.
Notified,
/// Stale generation, stale epoch, already queued/notified, done, or
/// vacant.
Noop,
}
/// A pid's-eye view of the slot, for cold paths that hold the slot lock.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) enum Status {
/// The generation no longer matches: the slot was reclaimed (and possibly
/// reused) — the pid is stale.
Stale,
/// The actor terminated; its outcome is (or was) in the slot.
Done,
/// Alive in some scheduling state (Queued / Running / Notified / Parked).
Live,
}
pub(crate) struct StateWord(AtomicU64);
impl StateWord {
pub(crate) fn new() -> Self {
Self(AtomicU64::new(pack(0, 0, ST_VACANT)))
}
#[inline]
pub(crate) fn load(&self) -> u64 {
self.0.load(Ordering::Acquire)
}
#[inline]
pub(crate) fn generation(&self) -> u32 {
word_gen(self.load())
}
#[inline]
pub(crate) fn status_for(&self, gen: u32) -> Status {
let w = self.load();
if word_gen(w) != gen {
return Status::Stale;
}
match word_state(w) {
ST_DONE => Status::Done,
// A matching generation on a Vacant slot is unreachable for any
// ISSUED pid — reclaim bumps the generation in the very store
// that vacates, and install publishes Queued before the pid
// escapes. But `Pid::new` is public, so a forged / never-issued
// pid (e.g. `Pid::new(5, 0)` against a fresh slab) can land
// here; for those, "no such actor" is the correct total answer.
ST_VACANT => Status::Stale,
_ => Status::Live,
}
}
/// Spawn-side publish: Vacant → Queued. The caller owns the vacant slot
/// exclusively (it popped the index from the free list), so this is a
/// plain Release store; it is the moment the actor becomes visible to
/// pops, unparks, and stops. The epoch starts at 0 for each occupancy
/// (`set_done` zeroes it; wait identity never crosses a lifetime).
pub(crate) fn publish_queued(&self, gen: u32) {
debug_assert_eq!(
self.load(),
pack(gen, 0, ST_VACANT),
"publish over a non-vacant slot"
);
self.0.store(pack(gen, 0, ST_QUEUED), Ordering::Release);
}
/// Scheduler pop-side claim: Queued → Running, epoch preserved. `false`
/// means the popped pid is stale — by the at-most-once-enqueued
/// invariant, a generation mismatch is the only possible failure
/// (asserted). Nothing can move a matching-gen word off Queued (wakes
/// no-op on Queued), so the CAS loop is single-shot in practice.
#[must_use]
pub(crate) fn try_claim(&self, gen: u32) -> bool {
loop {
let w = self.load();
if word_gen(w) != gen {
return false;
}
debug_assert_eq!(
word_state(w),
ST_QUEUED,
"queued pid found in unexpected state {} — double enqueue?",
word_state(w)
);
if self
.0
.compare_exchange(
w,
pack(gen, word_epoch(w), ST_RUNNING),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return true;
}
}
}
/// Yield return path: Running | RunningNotified → Queued, epoch
/// preserved. A notification that arrived mid-run coalesces into the
/// re-queue. Caller must enqueue. CAS loop because a notify can bump the
/// epoch between the read and the exchange.
pub(crate) fn yield_return(&self, gen: u32) {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
"yield return from invalid word {w:#x}"
);
if self
.0
.compare_exchange(
w,
pack(gen, word_epoch(w), ST_QUEUED),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return;
}
}
}
/// Park return path. `true` = actually parked. `false` = an unpark landed
/// in the prep-to-park window (RunningNotified); the word is already back
/// to Queued and the caller must enqueue — the lost-wakeup window,
/// closed. Epoch preserved on both paths (the notify already consumed
/// it).
#[must_use]
pub(crate) fn park_return(&self, gen: u32) -> bool {
loop {
let w = self.load();
debug_assert_eq!(word_gen(w), gen, "park return with stale gen");
let target = match word_state(w) {
ST_RUNNING => ST_PARKED,
ST_RUNNING_NOTIFIED => ST_QUEUED,
st => unreachable!("park return from invalid state {st}"),
};
if self
.0
.compare_exchange(
w,
pack(gen, word_epoch(w), target),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return target == ST_PARKED;
}
}
}
/// Open a new wait: bump the park-epoch and return it. Called by the
/// waiting actor itself (so the state is Running, or RunningNotified if
/// a terminal wake is already pending — the bump preserves the pending
/// notification), once per wait, BEFORE registering `(pid, epoch)` with
/// any waker.
#[must_use]
pub(crate) fn begin_wait(&self, gen: u32) -> u32 {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
"begin_wait from invalid word {w:#x}"
);
let next = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
if self
.0
.compare_exchange(
w,
pack(gen, next, word_state(w)),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return next;
}
}
}
/// The unpark protocol — the one way anything outside the scheduler makes
/// an actor runnable. See [`Unpark`] for the caller's obligations.
///
/// `want = Some(epoch)` is the epoch-matched form: lands only if the word
/// still carries that epoch (i.e. the wait it was registered for is still
/// the current, un-woken wait). `want = None` is the wildcard, reserved
/// for terminal wakes. Both forms CONSUME the epoch on success.
#[must_use]
pub(crate) fn unpark(&self, gen: u32, want: Option<u32>) -> Unpark {
loop {
let w = self.load();
if word_gen(w) != gen {
return Unpark::Noop;
}
if let Some(e) = want {
if word_epoch(w) != e {
return Unpark::Noop;
}
}
let bumped = word_epoch(w).wrapping_add(1) & EPOCH_MASK;
match word_state(w) {
ST_PARKED => {
if self
.0
.compare_exchange(
w,
pack(gen, bumped, ST_QUEUED),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return Unpark::Enqueue;
}
}
ST_RUNNING => {
if self
.0
.compare_exchange(
w,
pack(gen, bumped, ST_RUNNING_NOTIFIED),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return Unpark::Notified;
}
}
_ => return Unpark::Noop, // Queued | Notified | Done | Vacant
}
}
}
/// Eat a pending notification: RunningNotified → Running, epoch
/// preserved; no-op on Running. Called by the RUNNING actor itself, on
/// the no-park exit of a wait it registered for but never parked on
/// (`select` returning a ready arm at registration time), AFTER bumping
/// the epoch and BEFORE re-checking its stop flag:
///
/// - post-bump, the only wakers that can have set RunningNotified are
/// ones stamped with the just-retired epoch (a select arm) or a
/// terminal wildcard (`request_stop`);
/// - the caller's stop-flag check AFTER the clear catches the terminal
/// case (the flag is set before the wake fires), so eating its
/// notification loses nothing — and a stop arriving later re-notifies
/// a Running word as usual;
/// - what remains eaten is exactly the stale arm wake that would
/// otherwise fault the actor's next one-shot park.
///
/// Returns whether a notification was eaten.
pub(crate) fn clear_notify(&self, gen: u32) -> bool {
loop {
let w = self.load();
debug_assert!(
matches!(word_state(w), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(w) == gen,
"clear_notify from invalid word {w:#x}"
);
if word_state(w) != ST_RUNNING_NOTIFIED {
return false;
}
if self
.0
.compare_exchange(
w,
pack(gen, word_epoch(w), ST_RUNNING),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
return true;
}
}
}
/// Finalize: Running | RunningNotified → Done, epoch zeroed (wait
/// identity never crosses an occupancy). Called by the scheduler that
/// just ran the actor to completion (so those are the only legal prior
/// states), under the slot's cold lock so join's check-or-register is
/// linearized against it.
pub(crate) fn set_done(&self, gen: u32) {
let prev = self.0.swap(pack(gen, 0, ST_DONE), Ordering::AcqRel);
debug_assert!(
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED)
&& word_gen(prev) == gen,
"finalize from invalid word {prev:#x}"
);
}
/// Reclaim: Done → Vacant(gen + 1). The generation bump IS the reclaim:
/// every stale pid is dead from this store onwards. Caller holds the cold
/// lock and has verified eligibility (asserted).
pub(crate) fn reclaim(&self, gen: u32) {
debug_assert_eq!(
self.load(),
pack(gen, 0, ST_DONE),
"reclaim of a non-Done slot"
);
self.0
.store(pack(gen.wrapping_add(1), 0, ST_VACANT), Ordering::Release);
}
}
// ---------------------------------------------------------------------------
// loom model tests — RUSTFLAGS="--cfg loom" cargo test --lib --release
// ---------------------------------------------------------------------------
#[cfg(all(test, loom))]
mod loom_tests {
use super::*;
use loom::sync::atomic::{AtomicBool, AtomicUsize};
use loom::sync::Arc;
use loom::thread;
use std::sync::atomic::Ordering as O;
/// THE lost-wakeup theorem. A waiter registers a condition check then
/// parks (as every parking site does); a waker sets the condition then
/// unparks. In every interleaving the waiter must end up runnable —
/// parked-forever-with-condition-set must be unreachable.
#[test]
fn no_lost_wakeup_park_vs_unpark() {
loom::model(|| {
let word = Arc::new(StateWord::new());
word.publish_queued(0);
assert!(word.try_claim(0)); // scheduler claimed: actor Running
let epoch = word.begin_wait(0); // actor opens the wait
let ready = Arc::new(AtomicBool::new(false));
let enqueues = Arc::new(AtomicUsize::new(0));
// Waker: make the condition true, then wake the registered wait.
let w = word.clone();
let r = ready.clone();
let e = enqueues.clone();
let waker = thread::spawn(move || {
r.store(true, O::SeqCst);
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
e.fetch_add(1, O::SeqCst);
}
});
// Waiter (as the scheduler executes it): re-check the condition,
// park only if still false; a Notified park-return re-queues.
let parked = if ready.load(O::SeqCst) {
false // condition already visible: doesn't park at all
} else if word.park_return(0) {
true
} else {
enqueues.fetch_add(1, O::SeqCst); // notified → re-queued
false
};
waker.join().unwrap();
let w = word.load();
if parked {
// Parked is only a FINAL state if the waker's unpark moved it
// back to Queued (+ one enqueue). Parked-and-stays-parked
// would be the lost wakeup.
assert_eq!(word_state(w), ST_QUEUED, "lost wakeup: parked forever");
assert_eq!(enqueues.load(O::SeqCst), 1);
} else {
// Never more than one enqueue (at-most-once-enqueued).
assert!(enqueues.load(O::SeqCst) <= 1);
}
});
}
/// Two concurrent unparkers, one parked actor: exactly one wins the
/// enqueue (at-most-once), regardless of interleaving. Both stamped with
/// the live epoch — the consuming bump is what serializes them.
#[test]
fn two_unparkers_one_enqueue() {
loom::model(|| {
let word = Arc::new(StateWord::new());
word.publish_queued(0);
assert!(word.try_claim(0));
let epoch = word.begin_wait(0);
assert!(word.park_return(0)); // actor parked
let enqueues = Arc::new(AtomicUsize::new(0));
let mut hs = Vec::new();
for _ in 0..2 {
let w = word.clone();
let e = enqueues.clone();
hs.push(thread::spawn(move || {
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
e.fetch_add(1, O::SeqCst);
}
}));
}
for h in hs {
h.join().unwrap();
}
assert_eq!(enqueues.load(O::SeqCst), 1);
assert_eq!(word_state(word.load()), ST_QUEUED);
});
}
/// The stale-epoch theorem — what `select`'s loser arms lean on. An
/// actor opens a wait, two registered wakers race it (against the park
/// itself, covering the prep-to-park window); afterwards the actor is
/// runnable exactly once, and a LATE waker still stamped with the
/// consumed epoch can neither enqueue nor notify — in every
/// interleaving. (Under wildcard semantics the late waker would corrupt
/// the actor's NEXT one-shot park; this is the theorem that buys
/// `Mutex::lock_timeout`/`sleep`/`block_on_io` their unchanged code.)
#[test]
fn consumed_epoch_unpark_never_lands() {
loom::model(|| {
let word = Arc::new(StateWord::new());
word.publish_queued(0);
assert!(word.try_claim(0));
let epoch = word.begin_wait(0);
// Two arms race the wake, concurrent with the park itself.
let enqueues = Arc::new(AtomicUsize::new(0));
let mut hs = Vec::new();
for _ in 0..2 {
let w = word.clone();
let e = enqueues.clone();
hs.push(thread::spawn(move || {
if w.unpark(0, Some(epoch)) == Unpark::Enqueue {
e.fetch_add(1, O::SeqCst);
}
}));
}
let mut runnable_via_notify = false;
if !word.park_return(0) {
runnable_via_notify = true; // notified in prep-to-park
}
for h in hs {
h.join().unwrap();
}
// Exactly one path made the actor runnable.
let direct = enqueues.load(O::SeqCst);
if runnable_via_notify {
assert_eq!(direct, 0, "woken twice: notify AND enqueue");
} else {
assert_eq!(direct, 1, "parked forever, or woken twice");
}
assert_eq!(word_state(word.load()), ST_QUEUED);
// The actor runs again. A waker still holding the OLD epoch —
// a select loser arm firing later — must be a strict no-op,
// not a pending notification.
assert!(word.try_claim(0));
assert_eq!(word.unpark(0, Some(epoch)), Unpark::Noop);
assert_eq!(word_state(word.load()), ST_RUNNING, "stale epoch notified a live run");
});
}
/// The retire theorem — `select`'s no-park exit. An actor opens a wait
/// and registers, then finds an arm ready and returns WITHOUT parking;
/// a loser arm's waker fires concurrently, stamped with the live epoch.
/// The exit retires the wait (bump, then eat): in every interleaving
/// the run ends on a clean Running word — no pending notification
/// survives to fault the actor's next one-shot park — and the waker
/// never enqueues.
#[test]
fn retire_eats_late_arm_notification() {
loom::model(|| {
let word = Arc::new(StateWord::new());
word.publish_queued(0);
assert!(word.try_claim(0));
let epoch = word.begin_wait(0); // select opens + registers
let w = word.clone();
let waker = thread::spawn(move || w.unpark(0, Some(epoch)));
// No-park exit: bump (invalidates in-flight wakes), then eat
// (consumes one that already landed).
let _ = word.begin_wait(0);
word.clear_notify(0);
assert_ne!(waker.join().unwrap(), Unpark::Enqueue);
assert_eq!(
word_state(word.load()),
ST_RUNNING,
"stale arm wake survived the retire"
);
});
}
/// The ABA theorem: a stale-generation unpark racing reclaim + reuse can
/// never touch the slot's new occupant.
#[test]
fn stale_unpark_never_hits_reused_slot() {
loom::model(|| {
let word = Arc::new(StateWord::new());
// Gen-0 actor runs to completion.
word.publish_queued(0);
assert!(word.try_claim(0));
let w = word.clone();
let stale = thread::spawn(move || w.unpark(0, None));
// Scheduler: finalize, reclaim, and a new spawn reuses the slot.
word.set_done(0);
word.reclaim(0);
word.publish_queued(1);
// The stale unpark may have squeezed in only while gen 0 was
// still Running (→ Notified) — in which case set_done's swap
// absorbed it — or it observed Done/Vacant/gen-1 and no-op'd.
// Either way it must never claim an enqueue.
assert_ne!(stale.join().unwrap(), Unpark::Enqueue);
// And the new occupant is exactly where its spawn put it.
assert_eq!(word.load(), pack(1, 0, ST_QUEUED));
});
}
/// Unpark racing the claim itself: whatever the interleaving, the actor
/// is Running or RunningNotified afterwards and nobody enqueued (it was
/// never parked).
#[test]
fn unpark_vs_claim_coalesces() {
loom::model(|| {
let word = Arc::new(StateWord::new());
word.publish_queued(0);
let w = word.clone();
let unparker = thread::spawn(move || w.unpark(0, None));
assert!(word.try_claim(0)); // the entry is ours; claim must win
let r = unparker.join().unwrap();
assert_ne!(r, Unpark::Enqueue);
let st = word_state(word.load());
assert!(matches!(st, ST_RUNNING | ST_RUNNING_NOTIFIED));
});
}
}