docs(mutex): user-facing rewrite of the actor-blocking mutex

Every public item was previously undocumented. Lead with why Mutex<T>
exists (a channel/gen_server is overkill for plain shared state) and
how it differs from std::sync::Mutex (parks the actor not the OS
thread, every lock is timeout-bounded by default). Add a compiling
doctest. Document new/lock/lock_timeout/try_lock/set_default_timeout/
MutexGuard/LockTimeout/DEFAULT_TIMEOUT. Drop em-dashes; keep wake
protocol mechanics as contributor-facing comments on private internals.
This commit is contained in:
2026-07-24 08:44:56 +02:00
parent 36a0a9832d
commit dd845f22fe
+120 -9
View File
@@ -1,12 +1,89 @@
//! Actor-aware mutex with mandatory timeout.
//! Shared mutable state across actors, when a channel is overkill.
//!
//! `Mutex<T>` parks the calling *green* thread on contention rather than
//! blocking the OS thread. Every lock attempt is bounded by a timeout.
//! smarm actors normally coordinate by sending messages, and for a piece of
//! owned state the right tool is usually a `gen_server`: one actor holds the
//! data and everyone else talks to it. Sometimes that is more machinery than
//! you need, and plain shared, lockable state is simpler: [`Mutex<T>`] is
//! that escape hatch. It behaves like `std::sync::Mutex<T>`, guarding a value
//! of type `T` behind a guard that gives you `&mut T` while held, but it is
//! built for smarm's actors rather than OS threads.
//!
//! Internals use `Arc<std::sync::Mutex<...>>` so the type is genuinely
//! `Send + Sync` and can be shared across scheduler threads.
//! The key difference from `std::sync::Mutex` is what happens on contention.
//! [`Mutex::lock`] parks the calling actor (a cooperatively scheduled green
//! thread) rather than blocking the underlying OS thread, so other actors on
//! the same OS thread keep running while it waits. And every lock attempt is
//! bounded by a timeout: an actor that hangs on to the lock forever (stuck in
//! a bug, or just slow) would otherwise wedge every other actor waiting on
//! it, so smarm makes the wait bounded by default instead of leaving it up
//! to you to remember.
//!
//! Fairness: FIFO. Poisoning: none. Reentrance: deadlock (caller bug).
//! ## A first lock
//!
//! ```
//! use smarm::{run, spawn, Mutex};
//!
//! run(|| {
//! let counter = Mutex::new(0u32);
//!
//! // Mutex::clone() is cheap and hands out another handle to the SAME
//! // underlying value, much like Arc::clone: every clone shares one lock
//! // and one value, so mutations through one are visible through all.
//! let a = counter.clone();
//! let b = counter.clone();
//!
//! let h1 = spawn(move || {
//! let mut guard = a.lock().unwrap();
//! *guard += 1;
//! });
//! let h2 = spawn(move || {
//! let mut guard = b.lock().unwrap();
//! *guard += 1;
//! });
//! h1.join().unwrap();
//! h2.join().unwrap();
//!
//! assert_eq!(*counter.lock().unwrap(), 2);
//! });
//! ```
//!
//! ## Choosing a timeout
//!
//! [`Mutex::lock`] waits up to [`DEFAULT_TIMEOUT`] (30 seconds) before giving
//! up with [`LockTimeout`]. To use a different bound for one call, use
//! [`Mutex::lock_timeout`] instead; to change the default for every future
//! `lock()` call on this mutex (including through its clones), use
//! [`Mutex::set_default_timeout`]. If you never want to wait at all, use
//! [`Mutex::try_lock`], which returns immediately whether or not the lock was
//! free.
//!
//! ## Fairness and panics
//!
//! Waiters are granted the lock in the order they started waiting (FIFO), so
//! no actor can be starved by later arrivals repeatedly cutting in line.
//!
//! This mutex never poisons. `std::sync::Mutex` marks itself poisoned if a
//! thread panics while holding the lock, because a partly mutated value might
//! be left behind for the next lock holder to see. smarm's actors already
//! rely on `Drop` running during unwinding to release the lock, so if a
//! holder panics, [`MutexGuard::drop`] still runs and the next waiter is
//! granted the lock normally. It is the same tradeoff `std::sync::Mutex`
//! offers you if you choose to ignore poisoning: you may see a value left
//! mid-update by the panicking actor, so a panic inside a critical section is
//! still a bug worth fixing, just not one that also wedges every future lock
//! attempt.
//!
//! Locking a mutex you already hold (on the same actor) does not queue
//! behind yourself: it deadlocks, the same way relocking a non-reentrant
//! `std::sync::Mutex` does. Don't call `lock` while already holding a guard
//! from the same `Mutex`.
//!
//! ## Outside the runtime
//!
//! `Mutex<T>` also works when called from plain code that is not running as
//! a smarm actor (for example, in a test's setup code before calling
//! [`run`](crate::run)). There, an actor's cooperative park has no meaning,
//! so a lock attempt instead blocks the calling OS thread directly until the
//! mutex is free; there is no timeout on this path.
use crate::pid::Pid;
use crate::scheduler;
@@ -15,8 +92,14 @@ use std::collections::VecDeque;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
/// How long [`Mutex::lock`] waits for the lock before giving up, unless
/// overridden per-mutex with [`Mutex::set_default_timeout`] or per-call with
/// [`Mutex::lock_timeout`].
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// Returned by [`Mutex::lock`] / [`Mutex::lock_timeout`] when the timeout
/// elapses before the lock became available. The lock attempt is abandoned;
/// nothing was acquired, and the mutex's value is unaffected.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct LockTimeout;
@@ -70,7 +153,7 @@ impl TimerTarget for MutexCore {
};
// Remove from waiters only if still there with matching epoch.
// If the lock was already granted (holder == Some(pid)), the
// timer fired after the grant treat as no-op; the actor
// timer fired after the grant: treat as no-op; the actor
// will see `is_holder == true` and return Ok.
if st.holder == Some(pid) {
return;
@@ -100,6 +183,8 @@ pub struct Mutex<T> {
}
impl<T> Mutex<T> {
/// Wrap `value` in a new mutex, initially unlocked, with the default
/// lock timeout ([`DEFAULT_TIMEOUT`]).
pub fn new(value: T) -> Self {
Self {
core: Arc::new(MutexCore::new(DEFAULT_TIMEOUT)),
@@ -107,6 +192,11 @@ impl<T> Mutex<T> {
}
}
/// Change how long future [`lock`](Self::lock) calls on this mutex wait
/// before giving up. Applies to every clone of this `Mutex` (they share
/// one underlying lock), and to `lock` calls already in progress that
/// have not yet started waiting. Does not affect [`lock_timeout`](Self::lock_timeout)
/// calls, which always use the timeout passed in.
pub fn set_default_timeout(&self, timeout: Duration) {
match self.core.state.lock() {
Ok(mut st) => st.default_timeout = timeout,
@@ -114,6 +204,12 @@ impl<T> Mutex<T> {
}
}
/// Acquire the lock, waiting up to this mutex's default timeout
/// ([`DEFAULT_TIMEOUT`], or whatever [`set_default_timeout`](Self::set_default_timeout)
/// last set) if it is currently held elsewhere. Returns a [`MutexGuard`]
/// that releases the lock when dropped, or [`LockTimeout`] if the
/// deadline passes first. To use a one-off timeout instead of the
/// mutex's default, call [`lock_timeout`](Self::lock_timeout) directly.
pub fn lock(&self) -> Result<MutexGuard<'_, T>, LockTimeout> {
let timeout = match self.core.state.lock() {
Ok(st) => st.default_timeout,
@@ -122,6 +218,10 @@ impl<T> Mutex<T> {
self.lock_timeout(timeout)
}
/// Acquire the lock, waiting up to `timeout` (ignoring this mutex's
/// default) if it is currently held elsewhere. Returns a [`MutexGuard`]
/// that releases the lock when dropped, or [`LockTimeout`] if `timeout`
/// elapses first with the lock still unavailable.
pub fn lock_timeout(&self, timeout: Duration) -> Result<MutexGuard<'_, T>, LockTimeout> {
// Outside the runtime (e.g. in tests, after run() returns) there is no
// current actor PID. Fall back to a blocking std::sync::Mutex acquire.
@@ -157,7 +257,7 @@ impl<T> Mutex<T> {
Ok(g) => g,
Err(e) => panic!("smarm: mutex state lock poisoned (core corrupt): {e}"),
};
// begin_wait is lock-free legal under the state lock; this
// begin_wait is lock-free (legal under the state lock); this
// makes the epoch atomic with the registration's visibility to
// grants and timeouts.
let epoch = scheduler::begin_wait();
@@ -170,7 +270,7 @@ impl<T> Mutex<T> {
scheduler::insert_wait_timer(deadline, me, target, epoch);
scheduler::park_current();
// Resumed precisely: only our grant or our timer can wake this
// Resumed, precisely: only our grant or our timer can wake this
// wait (both epoch-stamped; a stop wake unwinds out of
// park_current). The one-shot interpretation below is therefore
// exhaustive. Are we the holder?
@@ -193,6 +293,9 @@ impl<T> Mutex<T> {
}
}
/// Acquire the lock only if it is immediately available: never parks and
/// never waits. Returns `Some` with a [`MutexGuard`] if the lock was
/// free, `None` if it is currently held elsewhere.
pub fn try_lock(&self) -> Option<MutexGuard<'_, T>> {
let me = crate::actor::current_pid()?;
let mut st = match self.core.state.lock() {
@@ -234,6 +337,10 @@ impl<T> Mutex<T> {
}
impl<T> Clone for Mutex<T> {
/// Cheap: hands back another handle to the same underlying lock and
/// value, the way `Arc::clone` does. All clones of a `Mutex` share one
/// lock and one protected value; locking through any clone excludes
/// every other clone.
fn clone(&self) -> Self {
Self { core: self.core.clone(), value: self.value.clone() }
}
@@ -247,6 +354,10 @@ unsafe impl<T: Send> Sync for Mutex<T> {}
// Guard
// ---------------------------------------------------------------------------
/// Grants access to the value inside a [`Mutex`] while the lock is held.
/// Dereferences to `&T` and `&mut T`. Dropping the guard releases the lock
/// and, if another actor is waiting, wakes the next one in arrival order.
/// Returned by [`Mutex::lock`], [`Mutex::lock_timeout`], and [`Mutex::try_lock`].
pub struct MutexGuard<'a, T> {
mutex: &'a Mutex<T>,
value: Option<T>,