Module async/mutex
Async (event-loop) mutex — a critical section that may SPAN AWAITS
(plans/archive/STD_API_AUDIT.md §7 P0 item 6 / D7). std/sync/mutex's blocking
lock inside a future parks the entire event loop; this one suspends the
waiting task instead. It is a task's lock, as tokio::sync::Mutex is: a task
that awaits INSIDE a critical section yields the loop mid-section, and plain
variables are only task-safe between suspension points.
A Mutex is a move-only value, shared as Arc(Mutex(T)) (Rust's
Arc<tokio::sync::Mutex<T>>; plans/VALUES_BY_DEFAULT.md §3.13 A5). Copying
one is an error; each task takes its own handle on the Arc, and lock's
future borrows the mutex through it (lock(self : &Self, io)). A shared
borrow through an Arc may live across a suspension, because no handle
writes an Arc's payload (§3.13 A2): the lock state changes only inside the
mutex. An Rc's payload is read-only through the handle too (decision 46),
so Rc(Mutex(T)) is the single-thread sharing, Rust's Rc<Mutex<T>>.
It is thread-safe (Send and Sync when T is Send), which is what
Arc requires: a short blocking guard (std/sync's RawMutex, never held
across an await) protects the lock state, so tasks on different threads'
loops may contend for one mutex. A waker fired from another thread resumes
its task on that task's own loop (std/async/waker).
Contended acquisition is WAKER-BASED and FIFO, across loops as within
one. A task that finds the lock held parks itself and puts its waker at the
back of a queue; unlock HANDS the lock to the waiter at the front: the
lock stays held, marked as that waiter's, and the waiter owns it when it
resumes, with no re-check a later arrival or a try_lock could win. So
the order the tasks arrived in is the order they acquire, whichever
thread's loop each runs on. There is no timer under it, so a hand-off
costs a loop turn rather than a millisecond. A waiter whose task is
cancelled leaves the queue; one cancelled after the lock was handed to it
passes the lock on to the next waiter, when its task's last reference
goes away (unlock skips a same-loop waiter that refused its wake at
once). Until 2026-09-11 this re-checked on a 1 ms tick and was explicitly
unfair (plans/archive/WAKER_BASED_SCHEDULING.md stage 3); until
2026-10-17 unlock freed the lock and woke the front waiter to re-check,
which was FIFO only among one loop's tasks
(issues/fixed/2026-10-16-the-async-mutex-is-not-fifo-across-event-loops.md).
{ Mutex } :: import("std/async/mutex");
m := arc(Mutex(i64).new(i64(0)));
io.await(m.*.lock(io), io);
// ... awaits allowed here — the section stays exclusive ...
m.*.set(m.*.get() + i64(1));
m.*.unlock();
Stability
unstable — new, lock, with_lock, try_lock, unlock, is_locked,
get and set are intended to keep their names and meanings. What changed
under them in 2026-09-11 is the MECHANISM (a waiter queue instead of a 1 ms
tick) and one guarantee that is now stronger than it was: acquisition is
FIFO, where the timer version documented itself as unfair. On 2026-10-14
the mutex became a move-only value shared as Arc(Mutex(T)); it was a
copyable handle over Rc state before. On 2026-10-17 unlock began
handing the lock over instead of freeing it, which made the FIFO order hold
across loops.
Types
A mutual-exclusion lock for async tasks, protecting a T.
A move-only value (it owns its value cell and a handle on its lock state):
tasks share one as Arc(Mutex(T)), each holding its own handle, and
lock's and with_lock's futures borrow the mutex through it
(io.await(m.*.lock(io), io); plans/VALUES_BY_DEFAULT.md §3.13 A2 and A5).
Its methods take self : &Self and change the state inside the cells under
the core's guard, Rust's interior mutability.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: Send), Mutex(T), Send())
impl(generic(T : Type), where(T <: Send), Mutex(T), Sync())
impl(generic(T : Type), where(T <: Acyclic), Mutex(T), Acyclic())
impl(generic(T : Type), Mutex(T), ...)
new : (fn(value : T) -> Self)A new unlocked mutex protecting value.
Returns: Self
_value_ptr : (fn(self : &Self) -> *T)The value cell. Read or write it under the core's guard, or while holding the async lock.
Returns: *T
_acquire_or_park : (fn(self : &Self) -> Option(_Parked))Take the lock if it is free (.None), else queue a fresh park's waker
(moved) at the back of the waiters and return the park with its ticket,
in one guarded step: a waiter cannot miss an unlock that runs between
its check and its queueing.
Returns: Option(_Parked)
lock : (fn(self : &Self, io : Io) -> Impl(Future(unit)))Acquire the lock, suspending while another task holds it.
The check and the acquisition happen under the guard with no suspension
between them, so tasks cannot interleave inside the hand-off. When the
lock IS held, the task publishes its waker and then suspends — in that
order, which is the order that cannot lose a wake (std/async/waker).
It is woken only by the unlock that hands it the lock, so it takes the
lock without re-checking: nothing that arrived later can have taken it.
The future borrows the mutex (§3.13 A2): await it where it is made,
io.await(m.*.lock(io), io).
Returns: Impl(Future(unit))
with_lock : (fn(generic(R : Type), self : &Self, body : &Impl(Fn(v : &mut T) -> R), io : Io) -> Impl(Future(R)))Run body under the lock as one critical section: acquire, run to
completion, release. body is a synchronous closure and cannot await;
for a section that spans awaits call lock/unlock directly. Only the
async lock is held while body runs, never the guard, so a waiter on
another thread parks instead of blocking its loop, and body may call
is_locked/try_lock on this mutex (get/set too, though it has the
value already). Was DEFERRED on C54 — a generic
-> Impl(Future(R)) method called with two different Rs miscompiled
because the async body's body(..) result was stamped as the bare
forall R and rendered through the global last-writer registry; the
stamp now resolves through the enclosing specialization's env
(issues/fixed/future-wrapper-return-shared-across-specializations.md, C54).
Returns: Impl(Future(R))
try_lock : (fn(self : &Self) -> bool)Acquire the lock without suspending: true on success. A lock being
handed to a waiter is held, so try_lock never jumps the queue.
Returns: bool
unlock : (fn(self : &Self) -> unit)Release the lock and hand it to the longest-waiting acquirer, if any: the lock stays held, and that waiter owns it when its task resumes, on whichever thread's loop it runs (see the module doc on ordering). With no waiter the lock is free. Panics if the mutex is not locked.
Returns: unit
is_locked : (fn(self : &Self) -> bool)True while some task holds the lock, or it is being handed to one.
Returns: bool
waiter_count : (fn(self : &Self) -> usize)How many tasks are suspended waiting for this lock, not counting one it is being handed to. Diagnostics only.
Returns: usize
get : (fn(self : &Self) -> T)Read the protected value. Meaningful only while YOU hold the lock: an
unlocked read is a logic bug. The guard orders it against other
get/set calls, not against a with_lock body, which runs under the
async lock alone.
Returns: T
set : (fn(self : &Self, value : T) -> unit)Replace the protected value (same locking contract as get).
Returns: unit