@std/sync

API Reference

class AtomicInt

A 64-bit integer cell shared through the registry, with atomic access.

The cell starts at 0 the first time its id is used. Every operation is sequentially consistent, so a value stored with set is visible to the next get from any thread, and fetchAdd and cas read-modify-write the cell without a lock in between.

Reach for fetchAdd to build counters and for cas when a thread must only write if nobody else moved the value first. Ordinary non-atomic fields of an object are not covered by this: only the cell itself is atomic.

Handles share by id, so a thread that only knows the number reaches the same cell with AtomicInt.byId(id).

.rnx
import { AtomicInt } from "@std/sync";

let a = AtomicInt.byId(11);
a.set(41);
print(a.get() + 1);

init(id: Int)

fields

  • id: Int
fn byId(id: Int): AtomicInt

Open the shared cell registered under id, creating it at 0 if this is the first use of that id.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

returns — handle to the cell.

fn cas(expected: Int, newVal: Int): Bool

Compare-and-swap: store newVal only when the cell holds expected.

The comparison and the store are one indivisible step, so the call either swaps or reports that another thread got there first. When it returns false the cell is left alone, so read it again to see what the current value is.

expected — value that must be present for the swap to happen.

newVal — value to store on match.

returns — true when the swap happened.

import { AtomicInt } from "@std/sync";

let slot = AtomicInt.byId(8);
slot.set(1);
print(slot.cas(1, 2), slot.get());
print(slot.cas(1, 3), slot.get());
Run in Playground
fn fetchAdd(delta: Int): Int

Add delta to the cell and report the value that was there first.

The read and the write are one indivisible step, so several threads can add to the same cell without losing updates. This is the building block for counters, unique id handouts, and work queues.

delta — amount to add. Negative values subtract.

returns — value before the add.

import { AtomicInt } from "@std/sync";

let a = AtomicInt.byId(1);
a.set(10);
print(a.fetchAdd(5), a.get());
Run in Playground
fn get(): Int

Read the cell.

returns — current value, as stored by the most recent `set` or atomic update that this thread can observe.

import { AtomicInt } from "@std/sync";

let a = AtomicInt.byId(11);
a.set(41);
print(a.get() + 1);
Run in Playground
fn set(val: Int)

Store a new value, replacing whatever was there.

val — value to store. Written in full, so a reader never sees a half-updated value.

class Barrier

Rendezvous gate: all count parties block in wait until the count-th party arrives, then all are released and the gate resets for the next generation. wait returns true for exactly one leader per generation. Construct with byId(id, count) where count must be greater than 0.

Use it when threads have to reach the same point before any of them can continue, such as the end of a parallel phase. The gate resets itself, so one handle serves every generation without being rebuilt.

Because it is a rendezvous, every party must actually arrive. A thread that exits early leaves the others parked forever, and a single thread calling wait on a barrier of two or more never returns. A count of 1 returns true on every call and never blocks, which is a convenient way to exercise the gate.

.rnx
import { Barrier } from "@std/sync";

let solo = Barrier.byId(61, 1);
print("first generation:", solo.wait());
print("gate resets:", solo.wait());
let pair = Barrier.byId(62, 2);
print("two parties required:", pair.count);

init(id: Int, count: Int)

fields

  • count: Int
  • id: Int
fn byId(id: Int, count: Int): Barrier

Open the shared barrier registered under id.

The first party to arrive sets the trip threshold, so every thread must build its handle with the same count. The gate is reusable: each trip resets it for the next generation.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

count — parties required to trip the gate, greater than 0. Every handle for this id should pass the same number.

returns — handle to the barrier.

throws — when count is 0 or negative, with a message naming the bad value. The throw runs in a function that is not declared `throws`, so an enclosing `try` does not intercept it: the program stops with `Uncaught exception` and exit status 1. Validate the count before calling when it comes from outside the program.

import { Barrier } from "@std/sync";

let solo = Barrier.byId(61, 1);
print("first generation:", solo.wait());
print("gate resets:", solo.wait());
Run in Playground
fn wait(): Bool

Block until count parties arrive.

All count parties park; the count-th arrival releases the whole generation at once, resets the gate, and returns true for exactly one leader (false for the rest). The leader is whichever thread arrived last, so use the return value to elect one thread for per-generation work such as merging results.

returns — true for the single party whose arrival tripped the gate, false for the others. Every party sees the same generation release.

import { Barrier } from "@std/sync";

let solo = Barrier.byId(61, 1);
print("leader:", solo.wait());
Run in Playground
class Channel

An unbounded first-in-first-out queue shared through the registry.

Any thread holding a handle for an id can enqueue and dequeue on the same queue. Values arrive in the order they were sent, and the queue grows to fit whatever is written to it, so a fast producer can outrun a slow consumer and use memory until it is drained.

send takes an Any, and recv hands the value back as an Any, so narrow the result with an is check before you use it. Heap payloads (strings, arrays, objects) are retained for the receiver and released when it drops them, so a value sent across threads stays valid.

Queued values are tracked per originating heap, so a value sent by one thread is delivered to whichever receiver can take ownership of it. A queue drained from a different heap than the one that filled it releases the leftovers instead of handing them out.

close releases whatever is queued and leaves the id registered, so a later send on the same handle still works. It does not wake a thread already parked in recv: a blocked consumer stays blocked until a value arrives, so treat close as a way to free buffered values rather than as a shutdown signal.

.rnx
import { Channel } from "@std/sync";

let jobs = Channel.byId(9);
jobs.send(7);
jobs.send(8);
print("queued:", jobs.len());
print("took:", jobs.tryRecv());
print("next:", jobs.recv());
print("empty:", jobs.tryRecv());

init(id: Int)

fields

  • id: Int
fn byId(id: Int): Channel

Open the shared queue registered under id, creating it empty on first use.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

returns — handle to the queue.

fn close(): Void

Drop the queue, releasing anything still queued.

Queued heap payloads are freed here. The id stays registered and a later send on the same handle works again, so this releases buffered values without unregistering the queue. A thread parked in recv is not woken.

fn len(): Int

Queued message count.

returns — number of waiting messages. Advisory only: another thread may enqueue or dequeue right after the read, so do not build a decision on it that `tryRecv` has to undo.

fn recv(): Any

Dequeue the oldest value, blocking until one arrives.

Parks the calling thread while the queue is empty. close does not release it, so a consumer waiting here needs a send from elsewhere, or a Condvar with a timeout to bound the wait.

returns — the value as Any; cast before use.

import { Channel, Thread } from "@std/sync";

fn produce(): Int {
let inbox = Channel.byId(3);
inbox.send("payload");
return 1;
}

let inbox = Channel.byId(3);
let worker = Thread.spawn(produce);
print("received:", inbox.recv());
print("worker done:", worker.join().unwrap());
Run in Playground
fn send(val: Any): Void

Enqueue a value, retaining heap payloads for the receiver.

The call never blocks, whatever the queue length. Values come out in the order they went in.

val — Int, Float, String, Array, or object. A heap payload is retained on the way in, so the receiver owns it after `recv`.

import { Channel } from "@std/sync";

let jobs = Channel.byId(9);
jobs.send(7);
jobs.send(8);
print("queued:", jobs.len());
print("took:", jobs.tryRecv());
Run in Playground
fn tryRecv(): Int

Dequeue the oldest value only if one is already queued.

The polling counterpart to recv: it reports absence in the return value instead of parking, so a loop over it never blocks.

returns — the value as Any when one was ready, or -1 when the queue was empty. Test against -1, not against a boolean, and narrow a real value with an `is` check before using it.

import { Channel } from "@std/sync";

let jobs = Channel.byId(9);
jobs.send(7);
print(jobs.tryRecv(), jobs.tryRecv());
Run in Playground
class Condvar

Condition variable for wait/notify coordination, shared through the registry.

A condvar carries no state of its own. It only parks and wakes threads, so the thing being waited on has to live somewhere else, normally behind the Mutex handed to wait. The usual shape is a loop: take the mutex, test the condition, and call wait while the test still says "not ready".

wait and waitTimeout must be called with that mutex held. The call releases the mutex while the thread sleeps and takes it again before returning, so no other thread can slip in between the test and the sleep. Calling either without holding the mutex aborts the program.

.rnx
import { Condvar, Mutex } from "@std/sync";

let m = Mutex.byId(51);
let c = Condvar.byId(51);
m.lock();
print("woken:", c.waitTimeout(m, 30));
m.unlock();
c.notifyAll();
print("notify with no waiters is a no-op");

init(id: Int)

fields

  • id: Int
fn byId(id: Int): Condvar

Open the shared condvar registered under id, with no waiters on first use.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

returns — handle to the condvar.

fn notifyAll(): Void

Wake every parked waiter, if any.

Does nothing when no thread is waiting. Use it when several waiters share a condition and a change releases all of them at once.

import { Condvar, Mutex } from "@std/sync";

let m = Mutex.byId(51);
let c = Condvar.byId(51);
m.lock();
c.notifyAll();
m.unlock();
Run in Playground
fn notifyOne(): Void

Wake one parked waiter, if any.

Does nothing when no thread is waiting, so a notify sent too early is lost. The woken thread holds its mutex again by the time wait returns.

fn wait(m: Mutex): Void

Sleep until notified, releasing the mutex while parked.

The mutex is released before the thread sleeps and re-taken before this returns, so the caller still holds it on the way out. A notify that arrives before the sleep is missed, which is why the condition has to be re-tested in a loop after waking.

m — mutex held by the caller. The call aborts the program when it is not held.

import { Condvar, Mutex } from "@std/sync";

let m = Mutex.byId(51);
let c = Condvar.byId(51);
m.lock();
c.notifyAll();
m.unlock();
Run in Playground
fn waitTimeout(m: Mutex, millis: Int): Bool

Sleep until notified or the timeout elapses, whichever comes first.

Releases and re-takes the mutex the same way wait does, so a notifying thread can make progress while this thread sleeps. Useful for a poll loop that must not block forever, and for giving up on a thread that never arrives.

m — mutex held by the caller. The call aborts the program when it is not held.

millis — maximum park time. A negative value is treated as 0.

returns — true when woken by a notify, false on timeout.

import { Condvar, Mutex } from "@std/sync";

let m = Mutex.byId(51);
let c = Condvar.byId(51);
m.lock();
print("woken:", c.waitTimeout(m, 30));
m.unlock();
Run in Playground
class Mutex

Mutual exclusion lock shared through the registry.

One thread holds the lock at a time. The others park in lock until it is released, which is what makes a read-modify-write on shared state safe.

The lock is not reentrant and carries no owner. A thread that already holds it and calls lock again parks forever, and unlock on a lock nobody holds aborts the program. Every lock needs exactly one matching unlock on each path out of the critical section, including error paths.

For waiting on a condition rather than for the lock itself, pair this with Condvar, which releases the mutex while a thread sleeps.

.rnx
import { Mutex } from "@std/sync";

let m = Mutex.byId(31);
m.lock();
m.unlock();
print(m.tryLock());
m.unlock();

init(id: Int)

fields

  • id: Int
fn byId(id: Int): Mutex

Open the shared mutex registered under id, unlocked on first use.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

returns — handle to the mutex.

fn lock(): Void

Acquire the lock, blocking until it is free.

Parks the calling thread while another thread holds the lock. Deadlocks if the same thread already holds it, or if two threads take two locks in opposite orders.

import { Mutex } from "@std/sync";

let m = Mutex.byId(31);
m.lock();
m.unlock();
print(m.tryLock());
m.unlock();
Run in Playground
fn tryLock(): Bool

Acquire the lock only when it is free, never blocking.

The polling counterpart to lock, for code that would rather give up a lock than wait for it. It reports the outcome in the return value, so a failed attempt leaves the lock untouched.

returns — true when the lock was taken by this call, false when it was already held. Release the lock only when this returns true.

import { Mutex } from "@std/sync";

let a = Mutex.byId(31);
let b = Mutex.byId(31);
a.lock();
print(b.tryLock());
a.unlock();
Run in Playground
fn unlock(): Void

Release a held mutex and wake one thread waiting in lock.

Aborts the program when the lock is not held, so pair every lock with exactly one unlock.

class RwLock

Reader-writer lock shared through the registry.

Many readers may hold the lock at once, or one writer may hold it exclusively, never both. Use it when a shared structure is read far more often than it changes; a plain Mutex is simpler and just as cheap when the critical section is short.

writeLock waits for the last reader as well as for any writer, so keep read sections short or a writer can sit behind a stream of them. There is no way to upgrade a held read lock to a write lock: the thread would wait for its own read access to end.

The lock carries no owner and is not reentrant. readUnlock with no reader and writeUnlock with no writer both abort the program.

.rnx
import { RwLock } from "@std/sync";

let r = RwLock.byId(41);
r.writeLock();
print("read while writer:", r.tryReadLock());
print("second writer:", r.tryWriteLock());
r.writeUnlock();
print("read when free:", r.tryReadLock());
r.readUnlock();

init(id: Int)

fields

  • id: Int
fn byId(id: Int): RwLock

Open the shared rwlock registered under id, fully released on first use.

id — registry slot shared with other threads. Pick an id no other code in the program uses; a number reused by a different primitive names a different object.

returns — handle to the rwlock.

fn readLock(): Void

Acquire shared read access, blocking only while a writer holds the lock.

Any number of readers hold the lock at the same time, so readers never wait for each other. Must be released with one readUnlock.

fn readUnlock(): Void

Release shared read access.

Wakes a waiting writer once the last reader is out. Aborts the program when no read access is held.

fn tryReadLock(): Bool

Take read access only when no writer is active, never blocking.

Succeeds while readers hold the lock, since readers do not exclude each other. The polling counterpart to readLock.

returns — true when read access was taken by this call. Release it with `readUnlock` only when this returns true.

import { RwLock } from "@std/sync";

let r = RwLock.byId(41);
r.writeLock();
print(r.tryReadLock());
r.writeUnlock();
Run in Playground
fn tryWriteLock(): Bool

Take write access only when the lock is completely free, never blocking.

Fails while any reader or writer holds the lock. The polling counterpart to writeLock.

returns — true when write access was taken by this call. Release it with `writeUnlock` only when this returns true.

import { RwLock } from "@std/sync";

let r = RwLock.byId(41);
print(r.tryWriteLock());
r.writeUnlock();
Run in Playground
fn writeLock(): Void

Acquire exclusive write access, blocking until no reader and no writer hold the lock.

Parks the calling thread for as long as any reader is active. Must be released with one writeUnlock.

fn writeUnlock(): Void

Release exclusive write access and wake everyone waiting on the lock.

Aborts the program when write access is not held.

class Thread

Owned handle to a spawned thread. Thread.spawn returns this nominal type so handles can live in collections such as Array<Thread>. Handles created before this type existed (bare Thread.spawn without importing @std/sync) still work as raw ids with the same join().

spawn is a compiler intrinsic rather than a method here: it takes a zero-argument function that returns an Int, Bool, Float, String, or null, and the checker rejects anything else at the call site. The spawned body runs on its own native thread, so what it touches is shared only when it goes through a byId handle.

A spawned function that throws is not a build error, the way a named throws function passed to spawn is. The throw is captured and surfaces as an Err from join.

join waits up to 30 seconds for the thread to finish and consumes the handle, so joining the same handle twice reports the second attempt as a dead thread. A thread that has not finished by then yields Err(thread N join timed out after 30s).

.rnx
import { Thread } from "@std/sync";

fn worker(): Int {
    return 40 + 2;
}
let workers: Array<Thread> = [];
workers.push(Thread.spawn(worker));
print(workers[0].join().unwrap());

init(id: Int)

fields

  • id: Int
fn byId(id: Int): Thread

Open a handle to an existing native thread slot.

Thread.spawn is an intrinsic and the compiler claims every Thread.<name> call, so this method is not reachable from Rasmalai source: the compiler answers unknown Thread.byId before the class body is consulted. A handle is a plain wrapper around a slot id, and the id is readable from a spawned handle's id field.

id — slot id returned by a previous spawn.

returns — handle to the thread.

fn join(): Result<Any, String>

Wait for the thread to finish, up to 30 seconds.

Output the thread produced is merged into this thread's output when it is joined. The handle is consumed, so a second join on the same handle reports a dead thread.

returns — Ok(value) with the thread return, Err(message) on failure. The message is `uncaught <value>` for a body that threw, `Thread panicked` for a panic, the 30 second timeout note for a thread that ran too long, `join of dead thread N` for a reused handle, and a spawn failure note when the thread never started.

import { Thread } from "@std/sync";

fn worker(): Int {
return 40 + 2;
}
let workers: Array<Thread> = [];
workers.push(Thread.spawn(worker));
print(workers[0].join().unwrap());
Run in Playground