feat(stack,runtime): per-shape actor stacks — Stack::new(reserve, guard), Config knobs, pool rule (RFC 019 §1)
Stack takes an explicit (reserve, guard) shape, both page-rounded and
stored; usable_base derives from the stored guard. Guard default raised
4 KiB -> 64 KiB (DEFAULT_STACK_GUARD): probestack makes one page enough
for Rust frames, but an unprobed C frame can leap a page in one sub rsp
— the motivating SQLite segfault. Reserve default stays 64 KiB
(DEFAULT_STACK_RESERVE); ACTOR_STACK_SIZE retired.
Config::{stack_reserve, stack_guard} thread the runtime defaults into
RuntimeInner pre-rounded. All acquisition/recycling now goes through
acquire_stack/recycle_stack carrying the pool rule: only default-shaped
stacks are pooled (pooled ⇒ default-shaped by induction); custom shapes
mmap fresh and munmap at death. Pool lock still dropped before any mmap.
No public spawn API change (SpawnOpts is the next commit).
Tests: shape rounding + accessors, wide-guard faults at both ends
(subprocess), Config::stack_reserve permits >64 KiB recursion that
previously could only segfault.
This commit is contained in:
+92
-9
@@ -160,6 +160,8 @@ pub struct Config {
|
||||
alloc_interval: u32,
|
||||
timeslice_cycles: u64,
|
||||
stack_pool_cap: usize,
|
||||
stack_reserve: usize,
|
||||
stack_guard: usize,
|
||||
max_actors: usize,
|
||||
wake_slot: bool,
|
||||
node_id: crate::pg::NodeId,
|
||||
@@ -175,6 +177,8 @@ impl Config {
|
||||
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
|
||||
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
|
||||
stack_pool_cap: n * 4,
|
||||
stack_reserve: DEFAULT_STACK_RESERVE,
|
||||
stack_guard: DEFAULT_STACK_GUARD,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
@@ -194,6 +198,8 @@ impl Config {
|
||||
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
|
||||
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
|
||||
stack_pool_cap: max * 4,
|
||||
stack_reserve: DEFAULT_STACK_RESERVE,
|
||||
stack_guard: DEFAULT_STACK_GUARD,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
@@ -226,6 +232,30 @@ impl Config {
|
||||
self
|
||||
}
|
||||
|
||||
/// Default per-actor stack reserve (RFC 019). A *virtual* reservation —
|
||||
/// anonymous mmap is demand-paged, so RSS follows touched pages, not
|
||||
/// this number — but overflowing it hits the guard and dies. Page-rounded.
|
||||
/// Per-actor override: `SpawnOpts::stack_reserve`.
|
||||
/// Default: [`DEFAULT_STACK_RESERVE`] (64 KiB) — the million-cheap-actors
|
||||
/// story is unchanged; big stacks are opt-in.
|
||||
pub fn stack_reserve(mut self, n: usize) -> Self {
|
||||
assert!(n > 0, "stack_reserve must be non-zero");
|
||||
self.stack_reserve = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Default PROT_NONE guard below each stack (RFC 019). Address space
|
||||
/// only. Page-rounded. Rust overflow is caught by any single page
|
||||
/// (probestack touches pages in order); the wide default exists for
|
||||
/// unprobed FFI frames, which can step over a small guard in one
|
||||
/// `sub rsp`. Per-actor override: `SpawnOpts::guard_size`.
|
||||
/// Default: [`DEFAULT_STACK_GUARD`] (64 KiB).
|
||||
pub fn stack_guard(mut self, n: usize) -> Self {
|
||||
assert!(n > 0, "stack_guard must be non-zero");
|
||||
self.stack_guard = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Capacity of the actor slot table — the maximum number of
|
||||
/// **simultaneously live** actors (total spawned over a run is unbounded;
|
||||
/// slots are recycled). The table is a fixed slab allocated once at
|
||||
@@ -293,6 +323,8 @@ impl Default for Config {
|
||||
alloc_interval: crate::preempt::DEFAULT_ALLOC_INTERVAL,
|
||||
timeslice_cycles: crate::preempt::DEFAULT_TIMESLICE_CYCLES,
|
||||
stack_pool_cap: avail * 4,
|
||||
stack_reserve: DEFAULT_STACK_RESERVE,
|
||||
stack_guard: DEFAULT_STACK_GUARD,
|
||||
max_actors: DEFAULT_MAX_ACTORS,
|
||||
wake_slot: false,
|
||||
node_id: crate::pg::DEFAULT_NODE_ID,
|
||||
@@ -383,7 +415,12 @@ impl RuntimeStats {
|
||||
// Slot — packed state word + hot atomics + cold lifecycle data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) const ACTOR_STACK_SIZE: usize = 64 * 1024;
|
||||
/// Default usable stack reserve per actor (RFC 019). See [`Config::stack_reserve`].
|
||||
pub const DEFAULT_STACK_RESERVE: usize = 64 * 1024;
|
||||
|
||||
/// Default PROT_NONE guard below each actor stack (RFC 019). Raised from one
|
||||
/// page so unprobed C frames cannot leap it. See [`Config::stack_guard`].
|
||||
pub const DEFAULT_STACK_GUARD: usize = 64 * 1024;
|
||||
|
||||
pub(crate) type Closure = Box<dyn FnOnce() + Send>;
|
||||
|
||||
@@ -802,17 +839,23 @@ pub(crate) struct RuntimeInner {
|
||||
pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>,
|
||||
/// Maximum number of stacks to retain in the pool.
|
||||
pub(crate) stack_pool_cap: usize,
|
||||
/// Default stack shape (RFC 019), pre-page-rounded so it compares exactly
|
||||
/// against `Stack::shape()`. Only stacks of exactly this shape are pooled.
|
||||
pub(crate) stack_reserve: usize,
|
||||
pub(crate) stack_guard: usize,
|
||||
}
|
||||
|
||||
impl RuntimeInner {
|
||||
// Private constructor taking the parsed Config fields one-for-one; a params
|
||||
// struct would only move the same 8 values across the call boundary.
|
||||
// struct would only move the same 10 values across the call boundary.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
thread_count: usize,
|
||||
alloc_interval: u32,
|
||||
timeslice_cycles: u64,
|
||||
stack_pool_cap: usize,
|
||||
stack_reserve: usize,
|
||||
stack_guard: usize,
|
||||
max_actors: usize,
|
||||
wake_slot: bool,
|
||||
node_id: crate::pg::NodeId,
|
||||
@@ -854,6 +897,8 @@ impl RuntimeInner {
|
||||
process_groups: RawMutex::new(crate::pg::ProcessGroups::new()),
|
||||
stack_pool: RawMutex::new(Vec::new()),
|
||||
stack_pool_cap,
|
||||
stack_reserve: crate::stack::round_to_pages(stack_reserve),
|
||||
stack_guard: crate::stack::round_to_pages(stack_guard),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1052,6 +1097,8 @@ pub fn init(config: Config) -> Runtime {
|
||||
config.alloc_interval,
|
||||
config.timeslice_cycles,
|
||||
config.stack_pool_cap,
|
||||
config.stack_reserve,
|
||||
config.stack_guard,
|
||||
config.max_actors,
|
||||
config.wake_slot,
|
||||
config.node_id,
|
||||
@@ -1309,6 +1356,48 @@ pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX);
|
||||
// Spawn-side slot installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stack acquisition / recycling — RFC 019 pool rule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Get a stack of the requested shape (`None` ⇒ the runtime defaults).
|
||||
///
|
||||
/// Pool rule (RFC 019 §1): the pool is a uniform `Vec<Stack>` of
|
||||
/// default-shaped stacks and stays that way. Default-shaped requests try the
|
||||
/// pool first; custom shapes always mmap fresh (and `recycle_stack` never
|
||||
/// admits them, so a pooled stack is default-shaped by induction). The pool
|
||||
/// lock is dropped before any mmap: no syscall ever stalls another spawner.
|
||||
pub(crate) fn acquire_stack(
|
||||
inner: &RuntimeInner,
|
||||
shape: Option<(usize, usize)>,
|
||||
) -> crate::stack::Stack {
|
||||
let (reserve, guard) = shape.unwrap_or((inner.stack_reserve, inner.stack_guard));
|
||||
let default_shaped = crate::stack::round_to_pages(reserve) == inner.stack_reserve
|
||||
&& crate::stack::round_to_pages(guard) == inner.stack_guard;
|
||||
if default_shaped {
|
||||
if let Some(stack) = inner.stack_pool.lock().pop() {
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
match crate::stack::Stack::new(reserve, guard) {
|
||||
Ok(stack) => stack,
|
||||
Err(e) => panic!("stack allocation failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a dead actor's stack: pooled if default-shaped and under cap,
|
||||
/// otherwise dropped here → munmap (custom shapes and cap overflow alike).
|
||||
pub(crate) fn recycle_stack(inner: &RuntimeInner, stack: crate::stack::Stack) {
|
||||
if stack.shape() == (inner.stack_reserve, inner.stack_guard) {
|
||||
let mut pool = inner.stack_pool.lock();
|
||||
if pool.len() < inner.stack_pool_cap {
|
||||
pool.push(stack);
|
||||
}
|
||||
// else: fall through — drop → munmap.
|
||||
}
|
||||
// Custom-shaped (or cap overflow): `stack` drops here → munmap.
|
||||
}
|
||||
|
||||
/// Install a freshly spawned actor into the slot `idx` (which must have come
|
||||
/// from `allocate_slot`) and publish it as Queued. Returns the new `Pid`.
|
||||
/// Called by `scheduler::spawn_under`; lives here next to its inverse
|
||||
@@ -1437,13 +1526,7 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
||||
// (the trap sender can unpark its receiver — keep that outside too).
|
||||
let supervisor_pid = actor.supervisor;
|
||||
let Actor { stack, .. } = actor;
|
||||
{
|
||||
let mut pool = inner.stack_pool.lock();
|
||||
if pool.len() < inner.stack_pool_cap {
|
||||
pool.push(stack);
|
||||
}
|
||||
// else: drop here → munmap, same as before
|
||||
}
|
||||
recycle_stack(inner, stack);
|
||||
|
||||
// Deliver to supervisor. ROOT_PID resolves to no slot → silently absorbed.
|
||||
let sender = inner.slot_at(supervisor_pid).and_then(|sup| {
|
||||
|
||||
Reference in New Issue
Block a user