Module sync/rwlock

sync/rwlock
Stability: unstable — `try_with_read` / `try_with_write` landed 2026-09-10 and the file has 22 passing cases, but two properties of this surface are still open questions rather than decisions. First, READ-ONLY-NESS IS NOT ENFORCED. `with_read` binds the value by value because Yo has no read-only binding mode; for a `ref(...)` `T` that copy shares the same object, so a body that mutates through it mutates shared data while holding only a read lock — and assigning to the parameter degenerates in the evaluator (issues/assign-to-by-value-closure-param-under-generic-result-types-unit.md). Freezing `with_read` means either a language-level read-only binding or a different signature. Second, the lock is READER-PREFERRING and can starve a writer: `_raw_read_lock` waits only while a writer HOLDS the lock, not while one is queued, so an unbroken stream of readers never lets a waiting writer in. Rust's `RwLock` leaves the fairness to the OS primitive and says so; this one implements the policy itself, so the policy is part of the API and has to be chosen deliberately before it can be frozen. — stable modules only change additively; this one may still change.

Reader-writer lock allowing multiple concurrent readers or one exclusive writer.

Phase D (THREAD_SAFETY): RwLock(T) is parameterized over the protected data and access is granted only through a closure, exactly like Mutex(T).with_lock:

  • with_read(body) binds the protected value BY VALUE, so for a value T the body gets a copy and its writes never reach the shared cell. Yo has no read-only binding mode, so for a ref(...) T — where the copy shares the same object — read-only-ness is a convention, not an enforced rule. Do NOT assign to the parameter: that currently degenerates in the evaluator (see issues/assign-to-by-value-closure-param-under-generic-result-types-unit.md).
  • with_write(body) binds inout(v) : T, so writes land in the protected cell.

The private __RwLockReadUnlocker / __RwLockWriteUnlocker objects release the lock from their Dispose impl, so the release happens on a normal return, on an early return, and on an unwind alike.

Example

{ RwLock } :: import "std/sync/rwlock";

lock := RwLock(i32).new(i32(0));

// Many readers may hold the lock at the same time.
value := lock.with_read((v) => v);

// One writer at a time, excluding every reader.
bumped := lock.with_write((v) => {
  v = (v + i32(1));
  v
});

A body may end in a VALUE or in a STATEMENT — the latter binds the callback's generic R to unit, which used to emit void* tmp = <void call> and fail to C-compile (the same hole was in Mutex.with_lock). Fixed 2026-08-26: issues/fixed/generic-r-callback-with-unit-closure-emits-void-star-temp.md.

Stability

unstable — try_with_read / try_with_write landed 2026-09-10 and the file has 22 passing cases, but two properties of this surface are still open questions rather than decisions.

First, READ-ONLY-NESS IS NOT ENFORCED. with_read binds the value by value because Yo has no read-only binding mode; for a ref(...) T that copy shares the same object, so a body that mutates through it mutates shared data while holding only a read lock — and assigning to the parameter degenerates in the evaluator (issues/assign-to-by-value-closure-param-under-generic-result-types-unit.md). Freezing with_read means either a language-level read-only binding or a different signature.

Second, the lock is READER-PREFERRING and can starve a writer: _raw_read_lock waits only while a writer HOLDS the lock, not while one is queued, so an unbroken stream of readers never lets a waiting writer in. Rust's RwLock leaves the fairness to the OS primitive and says so; this one implements the policy itself, so the policy is part of the API and has to be chosen deliberately before it can be frozen.

Types

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

Reader-writer lock built on Mutex and Cond, owning the protected T. Uses atomic reference counting for safe cross-thread sharing.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), where(T <: (Send, Acyclic)), RwLock(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), RwLock(T), ...)
new : (fn(value : T) -> Self)

Create a new reader-writer lock protecting value.

Returns: Self

with_read : ( fn( generic(R : Type), self : Self, body : Impl(Fn(v : T) -> R) ) -> R )

Run body under a SHARED acquisition. Several readers may be inside with_read at once; no writer can be. body receives the protected value BY VALUE — for a value T its writes stay in the body's own copy.

Returns: R

with_write : ( fn( generic(R : Type), self : Self, body : Impl(Fn(inout(v) : T) -> R) ) -> R )

Run body under an EXCLUSIVE acquisition. No other reader or writer can be inside the lock. body receives inout(v) : T, so its writes land in the protected cell.

Returns: R

try_with_read : ( fn( generic(R : Type), self : Self, body : Impl(Fn(v : T) -> R) ) -> Option(R) )

Run body under a SHARED acquisition IF one can be taken without waiting, and return .Some(result); .None when a writer holds the lock. Rust's try_read, in this type's closure-scoped shape.

Note this did NOT need the runtime's __yo_mutex_trylock, which the plan assumed it would. try_read has to test _writer, and that test happens under the SHORT internal mutex, which is held only for the length of a few field reads and never across a wait. Blocking on it is bounded and unrelated to how long a reader or writer holds the RwLock itself — so the non-blocking part is skipping the condvar wait, not skipping the mutex.

Returns: Option(R)

try_with_write : ( fn( generic(R : Type), self : Self, body : Impl(Fn(inout(v) : T) -> R) ) -> Option(R) )

Run body under an EXCLUSIVE acquisition IF one can be taken without waiting, and return .Some(result); .None when any reader or a writer holds the lock. Rust's try_write.

A single concurrent reader is enough to make this .None — an exclusive acquisition excludes readers too, so this fails far more often than try_with_read under a read-heavy load. Do not spin on it: a writer polling against a stream of readers can be starved indefinitely, which is exactly what the blocking with_write avoids by parking in the queue.

Returns: Option(R)

_raw_read_lock : (fn(self : Self) -> unit)

Returns: unit

_raw_try_read_lock : (fn(self : Self) -> bool)

Returns: bool

_raw_try_write_lock : (fn(self : Self) -> bool)

Returns: bool

_raw_read_unlock : (fn(self : Self) -> unit)

Returns: unit

_raw_write_lock : (fn(self : Self) -> unit)

Returns: unit

_raw_write_unlock : (fn(self : Self) -> unit)

Returns: unit