Three changes, each independently measured, landed together. Fuse pre-resume mutex acquisitions ----------------------------------- The schedule loop previously took the shared lock three separate times per actor resume: once to pop the run queue, once to read the stack pointer, and once to pop the first-resume closure. These are now a single acquisition that returns everything needed to resume the actor. As a side effect, pending_closures was changed from a Vec<(Pid, Closure)> with O(n) linear scan to a Vec<Option<Closure>> indexed by slot index, making first-closure lookup O(1). Move stack allocation outside the shared lock ---------------------------------------------- Stack::new() (mmap + mprotect) was previously called inside with_shared, stalling every other scheduler thread for the duration of two syscalls on every spawn. It now runs before the lock is acquired. Stack pool ---------- Rather than munmap-ing a stack when an actor finishes and mmap-ing a fresh one on the next spawn, stacks are now recycled through a per-Runtime pool (Mutex<Vec<Stack>>). finalize_actor extracts the stack from the Actor before clearing the slot and pushes it to the pool outside the shared lock. spawn_under pops from the pool before falling back to Stack::new(). The pool is unbounded for now (shrink policy TBD) but capped at stack_pool_cap stacks on return, defaulting to thread_count * 4. The cap is configurable via Config::stack_pool_cap(n). Results (24-thread, against stored baseline) -------------------------------------------- chained_spawn smarm 1-thread: 9763 → 261 µs (-97%) chained_spawn smarm 24-thread: 23562 → 838 µs (-96%) ping_pong_oneshot smarm 1-thread: 18409 → 742 µs (-96%) ping_pong_oneshot smarm 24-thread: 44596 → 1425 µs (-97%) catch_unwind_panics smarm 24-thread: 267812 → 124094 µs (-54%) fan_out_compute smarm 24-thread: 2839 → 2226 µs (-22%) Tokio regressions in the checker output are baseline measurement drift; no tokio code was changed.
smarm
SMARM — Smarm, Marks Actor Runtime Machinery. A proof-of-concept green-thread actor runtime for Rust.
Implements the core ideas in Achitecture.md: green-thread actors on a
shared heap, scheduled cooperatively, communicating only by Send messages.
Erlang's isolation model without Erlang's copying GC, Rust's zero-copy
ownership transfers without async's function colouring.
The scheduler is multi-threaded — one OS thread per available CPU, all drawing
from a shared run queue. The single-threaded run() entry point is kept as a
convenience wrapper around runtime::init(Config::exact(1)).run(f).
What's here
| Module | What it does |
|---|---|
stack |
mmap'd growable stack with guard page; SIGSEGV on overflow |
context |
#[naked] x86-64 context-switch shims, callee-saved regs only |
preempt |
Allocator-driven preemption; check!() macro for no-alloc loops |
pid |
(index, generation) PIDs; stale handles are detectable, not silent |
actor |
Trampoline + catch_unwind boundary at the actor entry point |
scheduler |
Run queue, slot table, spawn/join, parking, idle path |
channel |
Unbounded MPSC channel; recv parks the actor |
mutex |
Mutex<T> with mandatory timeout; FIFO waiters; parks the green thread |
timer |
Min-heap of (deadline, reason); Sleep and WaitTimeout reasons |
io |
block_on_io for blocking work; wait_readable/wait_writable + read/write via epoll |
supervisor |
Signal::Exit / Signal::Panic delivered to a parent actor's mailbox |
Quick taste
use smarm::{run, spawn, channel};
run(|| {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
for _ in 0..3 {
let v = rx.recv().unwrap();
println!("got {v}");
}
});
for v in 1..=3i64 {
tx.send(v).unwrap();
}
h.join().unwrap();
});
Layout
src/
stack.rs context.rs preempt.rs pid.rs actor.rs
scheduler.rs channel.rs mutex.rs timer.rs io.rs supervisor.rs
lib.rs
tests/
per-module integration tests
benches/
primes.rs fan-out/fan-in compute, vs tokio current_thread
Building and running
Standard Cargo. Requires Rust 1.95 or newer (the #[naked] attribute went stable
in 1.88; we use a few unrelated post-1.88 features). x86-64 Linux only —
ARM64 and macOS are on the deferred list because of the assembly shim and the
epoll dependency.
cargo test # all tests
cargo test --test mutex # one module
cargo bench # primes benchmark vs tokio
What's not here
See the Defer section of Architecture.md.
restart-intensity caps, join! for handle groups, stack growth via remap,
hierarchical timer wheel, fd-wait timeouts, Signal::Timeout. Each is
mechanism we know how to add; none belongs in this iteration.
Docs
| Document | What it covers |
|---|---|
Architecture.md |
Design intent, runtime model, and deferred work |
smarm - Deep Dive.html |
Generated walkthrough of the system; good starting point |
BENCHMARKS_AND_TUNING.md |
Where smarm wins and loses vs tokio, preemption knob recommendations |
benchmarks.md |
Raw benchmark results, methodology, and tuning experiment log |
Contributing
This is a personal proof-of-concept. There's no PR workflow — if you fork it and do something interesting, just send me an email. I'd genuinely like to hear about it.
The name is a recursive acronym. The M is for Marks, as in the BEAM — Bogdan/Björn's Erlang Abstract Machine, the virtual machine that runs Erlang and Elixir. smarm is not the BEAM. It just admires it from a safe distance.