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.
625 lines
24 KiB
Rust
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));
|
|
});
|
|
}
|
|
}
|