Module sync/mutex
Mutual exclusion lock primitives.
Phase D (THREAD_SAFETY): Mutex parameterized over the protected data.
Access is granted via with_lock(closure) — the closure receives a
inout(v): T that is second-class (cannot escape the closure scope).
The private __MutexUnlocker handles unlock on both normal return
and unwind via its Dispose impl.
Stability
unstable — the closure-scoped shape (with_lock / try_with_lock, no
guard type) is settled and concurrently tested
(tests/sync/mutex.test.yo, 12/12), but ONE row of
plans/STD_API_STABILIZATION.md §4 "Concurrency" is still open and it
is on this surface: _raw_lock / _raw_unlock / _raw_handle_ptr are
reachable from outside the module and are meant to be removed. They
cannot go yet because Cond.wait takes the raw handle they hand out —
Mutex(T) has no guard to pass instead — so freezing this module waits
on a condvar API that couples to a Mutex(T) without exposing its
primitive. There is also no poisoning: a thread that unwinds out of
body releases the lock and leaves the data as it was, where Rust
would mark the Mutex poisoned. That is a deliberate choice (Yo has no
poisoned state anywhere), not an omission, and it is not what is
holding the freeze.
Types
A mutual-exclusion lock that OWNS the data it protects — Rust's
Mutex<T>, minus the guard.
Access is granted only inside a closure: with_lock hands body an
inout(v) : T that is second-class and cannot escape the closure, so
there is no way to hold a reference to the protected value after the
lock is released. That is the deliberate divergence from Rust, which
returns a MutexGuard and relies on its lifetime; Yo has no lifetimes
to lean on, so the scope is the closure body.
T must be Send (it crosses threads) and Acyclic (the handle is
atomically reference-counted, and atomic RC is only sound for data that
cannot form a cycle). The Acyclic impl below is what lets a
Mutex(T) nest inside another sync container or an std/imm structure.
A Mutex(T) value is a SHARED HANDLE, which is why every method takes
self : Self rather than inout(self): copying the handle (into a
thread closure, say) bumps the atomic reference count and both copies
name the same lock. The OS primitive is destroyed by the Dispose impl
when the last handle goes away, so a mutex cannot outlive its own
destruction — but it also is not destroyed while any thread still holds
a handle.
There is no poisoning: if body unwinds, the lock is released and the
data keeps whatever value it had at that moment.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: (Send, Acyclic)), Mutex(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Mutex(T), ...)
new : (fn(value : T) -> Self)Wrap value in a new, unlocked mutex — Rust's Mutex::new.
value moves INTO the lock and is thereafter reachable only through
with_lock / try_with_lock. Creating the OS primitive is the only
allocation-time cost; there is no lazy initialization on first lock.
Returns: Self
with_lock : (
fn(
generic(R : Type),
self : Self,
body : Impl(Fn(inout(v) : T) -> R)
) -> R
)Take the lock, run body on the protected value, release the lock,
and return body's result — Rust's lock().unwrap() plus the guard's
scope, in one call.
This blocks until the lock is available, parking the thread rather
than spinning. body receives an inout(v) : T, so it may mutate the
protected value in place; the mutation is visible to the next holder.
Release is structural, not by falling off the end: a private
__MutexUnlocker is created inside the critical section and its
Dispose impl unlocks, so an early return out of body or an
unwind through this frame still releases the lock. Nothing marks the
mutex poisoned in that case (see the type doc).
Re-entering the same mutex from the same thread is not portable.
The POSIX primitive is a default pthread_mutex_t, so a second
with_lock on a mutex this thread already holds deadlocks; on Windows
the primitive is a CRITICAL_SECTION, which is recursive and lets the
call through. Do not rely on either — nest different mutexes, not the
same one.
Returns: R
try_with_lock : (
fn(
generic(R : Type),
self : Self,
body : Impl(Fn(inout(v) : T) -> R)
) -> Option(R)
)Run body under the lock IF it can be taken without waiting, and
return .Some(result); .None when another thread holds it.
The closure-scoped counterpart of Rust's try_lock. Rust hands back a
Result<MutexGuard, TryLockError>; Mutex(T) deliberately has no guard
type — access is only ever granted inside a closure — so the "did I get
it" answer is the Option instead. body not running IS the failure
case, which is why the result is optional rather than the lock state
being reported separately: there is no way to observe "not acquired" and
still have a value.
Release is by the same __MutexUnlocker with_lock uses, so an early
return or an unwind out of body still unlocks.
Do not spin on this. A while(!took, ...) loop around try_with_lock
is a busy-wait that starves the holder on a single core; with_lock
parks instead. try_with_lock is for the case where there is other
useful work to do when the lock is busy.
One platform note: a Windows CRITICAL_SECTION is RECURSIVE, so a thread
that ALREADY holds this mutex gets .Some here, where a POSIX mutex
gives .None. Do not use the answer to detect self-reentrancy.
Returns: Option(R)
is_unlocked : (fn(self : Self) -> bool)True when the mutex is NOT held right now.
Advisory only, and racy by construction: it takes the lock to find
out and releases it again, so the answer describes a moment that has
already passed. Never branch on this to decide whether a later
with_lock will block — that is a TOCTOU bug. It exists for assertions
and diagnostics, which is why it is spelled as a question about the past
rather than as can_lock.
Returns: bool
_raw_lock : (fn(self : Self) -> unit)Returns: unit
_raw_unlock : (fn(self : Self) -> unit)Returns: unit
_raw_handle_ptr : (fn(inout(self) : Self) -> *__YO_THREAD_SYNC_TYPE)Returns: *__YO_THREAD_SYNC_TYPE
impl(generic(T : Type), where(T <: (Send, Acyclic)), Mutex(T), Dispose(...))
dispose : (fn(self : Self) -> unit)Release the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Returns: unit