feat(runtime): phase 2 — fixed slab, per-slot packed-atomic state machine, raw cold locks
The slot table split (ROADMAP_v0.5 phase 2): slot lookup is lock-free, the
run path (yield/park/unpark/pop/resume) takes zero locks beyond the queue
mutex itself, and SharedState shrinks to { run_queue }.
Core pieces:
- src/raw_mutex.rs: hand-rolled 3-state futex mutex (Drepper mutex3),
non-poisoning; the guard enters NoPreempt, which both bounds hold times and
structurally closes the unwind-under-lock hole (the stop sentinel shares
the gate). Used for per-slot cold data, the free list, and the stack pool.
- Fixed slab Box<[Slot]> (default max_actors = 16_384, ~4 MiB, align(128)
against false sharing). Slots never move -> stable addresses, lock-free
index. Exhaustion panics loudly, naming Config::max_actors(n) as the fix.
Unbounded/segmented slab stays deferred (see ROADMAP).
- Per-slot AtomicU64 packing (generation << 32 | state), states
Vacant/Queued/Running/RunningNotified/Parked/Done. Every transition CASes
the packed word, so the generation check is atomic with the transition: no
ABA, no spurious unparks on recycled slots. RunningNotified replaces the
pending_unpark bool (the lost-wakeup window is a state, not a flag) and
uniformly fixes a LATENT LOST WAKEUP in the old Blocking-IO completion
path, which set the result for a still-Running actor without flagging it.
- Invariant: a pid is in the run queue at most once (pushes pair 1:1 with
transitions into Queued; only the scheduler does Queued->Running). This is
what makes phase 3's bounded rings sound.
- sp -> relaxed AtomicUsize; stop flag + first-resume closure as AtomicPtrs
(closure double-boxed for a thin pointer, swap-to-take): the resume path is
fully atomic.
- finalize_actor: Done published under the dying slot's cold lock (join's
check-or-register is linearized by it); link cascade locks peers ONE AT A
TIME (cold locks are leaves) with the acyclicity argument written at the
site; link() registers on the target first, then self (stale self-entries
are benign, every walk re-verifies the peer's word).
- Termination by counters: live_actors incremented in spawn pre-enqueue,
decremented at the very END of finalize after all wakeup enqueues; exit on
io_out == 0 (read before the queue lock, phase-1 ordering) && queue empty
&& live == 0. Soundness note at the site: any enqueue targets a live actor.
- spawn boxes the closure and acquires the stack BEFORE any runtime lock: no
allocation ever happens under a global lock anymore.
- with_runtime/try_with_runtime now enter NoPreempt for their full span.
This fixes a bug the rework exposed: install_actor allocated with
preemption enabled while the RUNTIME RefCell borrow was live; a timeslice
preemption there migrates the actor across OS threads and the borrow guard
increments one thread's RefCell count and decrements another's — underflow
to 'permanently mutably borrowed', cascading panics (caught by stress
suite: deterministic non-unwinding-panic abort in lost_wakeup_many_pairs).
The old code was safe only by accident; now it's structural.
- Behavior note: request_stop on a RUNNING target now marks it
RunningNotified, so its next park returns immediately to an observation
point — faster stop observation; parked/queued/done semantics unchanged.
Validated: 22 suites green in release (1/2/8-thread oversubscribed on the
1-core sandbox) and in debug with all debug_asserts live; stress suite x5.
This commit is contained in:
+66
-44
@@ -49,10 +49,9 @@
|
||||
//! [`Down`]: crate::monitor::Down
|
||||
//! [`request_stop`]: crate::scheduler::request_stop
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::channel::{channel, Receiver};
|
||||
use crate::monitor::DownReason;
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::State;
|
||||
use crate::scheduler::{request_stop, self_pid, with_runtime};
|
||||
|
||||
/// A linked peer's death, delivered to a trapping actor's inbox.
|
||||
@@ -78,11 +77,13 @@ pub fn trap_exit() -> Receiver<ExitSignal> {
|
||||
let (tx, rx) = channel::<ExitSignal>();
|
||||
let me = self_pid();
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(actor) = s.slot_mut(me).and_then(|slot| slot.actor.as_mut()) {
|
||||
if let Some(slot) = inner.slot_at(me) {
|
||||
let mut cold = slot.cold.lock();
|
||||
// Own slot: generation is necessarily current (we're running).
|
||||
if let Some(actor) = cold.actor.as_mut() {
|
||||
actor.trap = Some(tx);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
@@ -100,45 +101,61 @@ pub fn link(target: Pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Under the lock: if the target is live, record the link both ways and
|
||||
// return `None`. If it is gone, return the caller's trap sender (if any)
|
||||
// so we can deliver the NoProc signal after releasing the lock.
|
||||
let dead_action: Option<Option<Sender<ExitSignal>>> = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let target_live = matches!(
|
||||
s.slot(target),
|
||||
Some(slot) if slot.actor.is_some() && !matches!(slot.state, State::Done)
|
||||
);
|
||||
if target_live {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
if !slot.links.contains(&target) {
|
||||
slot.links.push(target);
|
||||
}
|
||||
// Cold locks are leaves: never hold two at once. The link is recorded one
|
||||
// side at a time, TARGET FIRST — that ordering is what makes the race
|
||||
// window sound:
|
||||
//
|
||||
// - Once `me` is in `target.links`, the target's death always reaches us
|
||||
// (its finalize cascade walks that list). So after step 1 succeeds, the
|
||||
// link semantics are already live.
|
||||
// - If the target dies between step 1 and step 2, its cascade removes
|
||||
// `target` from OUR links (a no-op, we haven't added it yet) and
|
||||
// delivers the exit signal — correct, the link was established. Our
|
||||
// subsequent step-2 insert leaves a stale `target` entry in `me.links`;
|
||||
// stale entries are benign by construction (every cascade walk
|
||||
// re-verifies the peer's word; `unlink` removes them like any other).
|
||||
//
|
||||
// The reverse order would be unsound: target dying in the window would
|
||||
// walk its links WITHOUT us — a silently dead link that we believe is live.
|
||||
let registered_on_target = with_runtime(|inner| match inner.slot_at(target) {
|
||||
Some(slot) => {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.is_live_for(target) && cold.actor.is_some() {
|
||||
if !cold.links.contains(&me) {
|
||||
cold.links.push(me);
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
if !slot.links.contains(&me) {
|
||||
slot.links.push(me);
|
||||
}
|
||||
}
|
||||
None
|
||||
true
|
||||
} else {
|
||||
// Grab our own trap sender so the NoProc delivery (below)
|
||||
// doesn't need a second lock acquisition.
|
||||
Some(
|
||||
s.slot(me)
|
||||
.and_then(|slot| slot.actor.as_ref())
|
||||
.and_then(|a| a.trap.clone()),
|
||||
)
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
|
||||
match dead_action {
|
||||
None => {} // linked successfully
|
||||
Some(Some(tx)) => {
|
||||
if registered_on_target {
|
||||
with_runtime(|inner| {
|
||||
let slot = inner.slot_at(me).expect("link: own slot vanished");
|
||||
let mut cold = slot.cold.lock();
|
||||
if !cold.links.contains(&target) {
|
||||
cold.links.push(target);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Target already gone: deliver NoProc to ourselves — as a message if
|
||||
// trapping, otherwise as a cooperative stop.
|
||||
let my_trap = with_runtime(|inner| {
|
||||
inner.slot_at(me).and_then(|slot| {
|
||||
let cold = slot.cold.lock();
|
||||
cold.actor.as_ref().and_then(|a| a.trap.clone())
|
||||
})
|
||||
});
|
||||
match my_trap {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
|
||||
}
|
||||
Some(None) => request_stop(me),
|
||||
None => request_stop(me),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,13 +169,18 @@ pub fn unlink(target: Pid) {
|
||||
return;
|
||||
}
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
slot.links.retain(|p| *p != target);
|
||||
// One cold lock at a time (leaf rule). Order is immaterial here:
|
||||
// a half-removed link is just a stale entry on one side, and stale
|
||||
// entries are benign (re-verified on every cascade walk).
|
||||
if let Some(slot) = inner.slot_at(me) {
|
||||
let mut cold = slot.cold.lock();
|
||||
cold.links.retain(|p| *p != target);
|
||||
}
|
||||
if let Some(slot) = inner.slot_at(target) {
|
||||
let mut cold = slot.cold.lock();
|
||||
if slot.generation() == target.generation() {
|
||||
cold.links.retain(|p| *p != me);
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
slot.links.retain(|p| *p != me);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user