Module async/mutex

async/mutex
Stability: unstable — may still change; see below.

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

Mutex type-function
fn(T : Type) -> Type

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

NameTypeNotes
TTypecomptime

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