feat(runtime): phase 3 — pluggable run queue (rq-mutex / rq-mpmc / rq-striped)
src/run_queue.rs: the run queue extracted behind a compile-time-selected type alias; mutually-exclusive cargo features with compile_error! guards (zero or >1 selected). No runtime dispatch. All variants compile in every build so their unit tests always run; the feature only picks the alias. - rq-mutex (default): Mutex<VecDeque>, the control/baseline. - rq-mpmc: hand-rolled Vyukov bounded MPMC ring, per-cell sequence numbers, padded enqueue/dequeue counters. Strict FIFO, lock-free. - rq-striped: M Vyukov rings (M = thread_count rounded up to pow2), fetch-add ticket distribution, probe-from-home on push. Relaxed FIFO with stripe-bounded skew; Σcapacity ≈ 2×max_actors so the probe terminates. Capacity soundness: occupancy ≤ max_actors by the at-most-once-enqueued invariant + slab cap, rings sized ≥ that bound, so push is infallible; a full ring panics as a double-enqueue invariant violation rather than spin. Two contracts the extraction made explicit (documented + debug-asserted): - Queue ops require preemption disabled: a producer suspended between claiming a cell and publishing its sequence stalls every consumer behind it — livelock, since the suspended actor's own resume entry is behind the hole. Structurally guaranteed since the phase-2 with_runtime NoPreempt fix. - pop()==None is a snapshot, not a fence. Termination is counter-first: every queue entry's target stays Queued (hence live) until that entry is popped, so live==0 alone implies nothing actionable is or can be queued; argument rewritten at the schedule_loop site. SharedState and with_shared are deleted — nothing global is mutex-guarded on the run path under the ring variants. Validated: 22 suites green per variant (release for all three; debug with live asserts for all three), ring unit tests (FIFO, lap wraparound, 4p/4c exactly-once, skewed drain) in every build, both compile_error! guards verified to fire.
This commit is contained in:
+43
-62
@@ -9,7 +9,7 @@
|
||||
//! RuntimeInner {
|
||||
//! slots: Box<[Slot]> ← FIXED slab, max_actors entries, lock-free lookup
|
||||
//! free: RawMutex<Vec<u32>> ← vacant slot indices
|
||||
//! shared: Mutex<SharedState> ← the run queue (and nothing else)
|
||||
//! run_queue: RunQueue ← compile-time selected (src/run_queue.rs)
|
||||
//! timers: Mutex<Timers>
|
||||
//! io: Mutex<Option<IoThread>>
|
||||
//! live_actors: AtomicU32 ← spawned-but-not-finalized count (termination)
|
||||
@@ -62,15 +62,17 @@
|
||||
//!
|
||||
//! # Locks and ordering
|
||||
//!
|
||||
//! - `shared` now guards **only the run queue**. It is the innermost lock:
|
||||
//! nothing else is ever acquired while holding it.
|
||||
//! - The run queue is its own module (`run_queue.rs`), selected at compile
|
||||
//! time (`rq-mutex` / `rq-mpmc` / `rq-striped`). Queue ops require
|
||||
//! preemption disabled (debug-asserted there); when the mutex variant is in
|
||||
//! play it is the innermost lock — nothing else is acquired under it.
|
||||
//! - Per-slot `cold` locks ([`RawMutex`], non-poisoning, guard enters
|
||||
//! `NoPreempt`) guard the lifecycle collections. **Leaf rule: never hold
|
||||
//! two cold locks at once** — `finalize_actor`'s link cascade and `link()`
|
||||
//! lock peers one at a time (correctness arguments at the call sites).
|
||||
//! Holding a cold lock while pushing to the run queue is permitted.
|
||||
//! - Lock order overall: `io` → (slot `cold` | `free` | `stack_pool`,
|
||||
//! mutually leaf) → `shared`/run-queue. `timers` is independent (never
|
||||
//! mutually leaf) → run queue (innermost). `timers` is independent (never
|
||||
//! nested with any of the above on either side).
|
||||
//!
|
||||
//! # Termination (counter-based)
|
||||
@@ -106,7 +108,6 @@ use crate::supervisor::Signal;
|
||||
use crate::timer::Timers;
|
||||
use crate::context::{get_actor_sp, set_actor_sp, switch_to_actor};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{
|
||||
AtomicBool, AtomicPtr, AtomicU32, AtomicU64, AtomicUsize, Ordering,
|
||||
};
|
||||
@@ -487,28 +488,15 @@ impl Slot {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state (behind Mutex<>) — now: the run queue, nothing else
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) struct SharedState {
|
||||
pub(crate) run_queue: VecDeque<Pid>,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
fn new() -> Self {
|
||||
Self { run_queue: VecDeque::new() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RuntimeInner — the shared core behind an Arc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) struct RuntimeInner {
|
||||
/// The run queue. Innermost lock: nothing else is acquired under it.
|
||||
/// Phase 3 swaps this for the compile-time-selected `RunQueue`.
|
||||
pub(crate) shared: Mutex<SharedState>,
|
||||
/// The run queue, compile-time selected (see `run_queue.rs` for the
|
||||
/// contract: ops require preemption disabled, push is infallible,
|
||||
/// pop-None is a snapshot).
|
||||
pub(crate) run_queue: crate::run_queue::RunQueue,
|
||||
/// The fixed actor slot table. Allocated once; slots never move.
|
||||
pub(crate) slots: Box<[Slot]>,
|
||||
/// Vacant slot indices. RawMutex leaf; never held with a cold lock.
|
||||
@@ -552,7 +540,7 @@ impl RuntimeInner {
|
||||
// Low indices on top of the stack so early spawns get low pids.
|
||||
let free: Vec<u32> = (0..max_actors as u32).rev().collect();
|
||||
Arc::new(Self {
|
||||
shared: Mutex::new(SharedState::new()),
|
||||
run_queue: crate::run_queue::RunQueue::new(thread_count, max_actors),
|
||||
slots,
|
||||
free: RawMutex::new(free),
|
||||
live_actors: AtomicU32::new(0),
|
||||
@@ -570,17 +558,6 @@ impl RuntimeInner {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_shared<R>(&self, f: impl FnOnce(&mut SharedState) -> R) -> R {
|
||||
// Preemption must be off while we hold the queue mutex: a
|
||||
// preemption-driven context switch under it would carry the lock off
|
||||
// to a suspended actor and stall every scheduler thread. (The stop
|
||||
// sentinel shares the gate, so nothing can unwind in here either.)
|
||||
let prev = crate::preempt::PREEMPTION_ENABLED.with(|c| c.replace(false));
|
||||
let result = f(&mut self.shared.lock().unwrap());
|
||||
crate::preempt::PREEMPTION_ENABLED.with(|c| c.set(prev));
|
||||
result
|
||||
}
|
||||
|
||||
/// Slot lookup by index only — bounds-checked, NOT generation-checked.
|
||||
/// `ROOT_PID` (index `u32::MAX`) is out of bounds by construction and
|
||||
/// resolves to `None`. Callers verify the generation atomically: either
|
||||
@@ -594,7 +571,7 @@ impl RuntimeInner {
|
||||
/// into `Queued` (spawn's publish, the unpark protocol, or the
|
||||
/// scheduler's yield/notified-park return paths).
|
||||
pub(crate) fn enqueue(&self, pid: Pid) {
|
||||
self.with_shared(|s| s.run_queue.push_back(pid));
|
||||
self.run_queue.push(pid);
|
||||
crate::te!(crate::trace::Event::Enqueue(pid));
|
||||
}
|
||||
|
||||
@@ -694,14 +671,14 @@ impl Runtime {
|
||||
crate::trace::open();
|
||||
|
||||
// Re-initialise shared state for this run.
|
||||
{
|
||||
let s = self.inner.shared.lock().unwrap();
|
||||
assert!(s.run_queue.is_empty(), "run() called while previous run still active");
|
||||
debug_assert_eq!(
|
||||
self.inner.live_actors.load(Ordering::Acquire), 0,
|
||||
"run() called while previous run still active"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
self.inner.run_queue.len(), 0,
|
||||
"run() called while previous run still active"
|
||||
);
|
||||
debug_assert_eq!(
|
||||
self.inner.live_actors.load(Ordering::Acquire), 0,
|
||||
"run() called while previous run still active"
|
||||
);
|
||||
*self.inner.io.lock().unwrap() = Some(IoThread::start().expect("failed to start IO thread"));
|
||||
|
||||
// Spawn the initial actor through the public spawn path (which
|
||||
@@ -1113,27 +1090,31 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|
||||
None => (0, None),
|
||||
};
|
||||
|
||||
let pop = inner.with_shared(|s| {
|
||||
let len = s.run_queue.len() as u64;
|
||||
stats.run_queue_len.store(len, Ordering::Relaxed);
|
||||
match s.run_queue.pop_front() {
|
||||
Some(pid) => Pop::Got(pid),
|
||||
None => {
|
||||
// live_actors is read under the queue lock. If it is 0,
|
||||
// every finalize fully completed — including its wakeup
|
||||
// enqueues, which happen-before the decrement — so with
|
||||
// the queue also empty, nothing can produce work again
|
||||
// (any enqueue targets a live actor; a spawner is itself
|
||||
// live). io_out was read before the lock per phase 1.
|
||||
let live = inner.live_actors.load(Ordering::Acquire);
|
||||
if live == 0 && io_out == 0 {
|
||||
Pop::AllDone
|
||||
} else {
|
||||
Pop::Idle { io_outstanding: io_out, wake_fd: io_fd }
|
||||
}
|
||||
stats.run_queue_len.store(inner.run_queue.len(), Ordering::Relaxed);
|
||||
let pop = match inner.run_queue.pop() {
|
||||
Some(pid) => Pop::Got(pid),
|
||||
None => {
|
||||
// Termination does not lean on pop-None being a fence (with
|
||||
// the ring queues it is only a snapshot). The argument is
|
||||
// counter-first: every queue entry's target stays `Queued` —
|
||||
// hence un-finalized, hence counted live — until that very
|
||||
// entry is popped. So `live == 0` (Acquire, pairing with
|
||||
// finalize's Release decrement, which strictly follows all
|
||||
// wakeup enqueues) by itself implies no entry is in, or can
|
||||
// ever again enter, the queue: enqueues only target live
|
||||
// actors, and a spawner is itself live. The pop-None above
|
||||
// is then just the cheap fast-path filter; io_out was read
|
||||
// before it per the phase-1 ordering. `live == 0` is also
|
||||
// final — no spawn can resurrect the count — so every
|
||||
// scheduler thread independently reaches this same verdict.
|
||||
let live = inner.live_actors.load(Ordering::Acquire);
|
||||
if live == 0 && io_out == 0 {
|
||||
Pop::AllDone
|
||||
} else {
|
||||
Pop::Idle { io_outstanding: io_out, wake_fd: io_fd }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let pid = match pop {
|
||||
Pop::Got(pid) => pid,
|
||||
|
||||
Reference in New Issue
Block a user