Synchronization
Concurrency primitives for sharing state across spawned coroutines: Mutex<T> for mutual exclusion over a typed value, Atomic for a lock-free i64, and WaitGroup for joining on a set of workers. All three are heap-allocated wrappers owned by the caller — pair every new() with a defer .free().
Import
import stdlib::sync::*;
Overview
| Type | Purpose | Construct | Free |
|---|---|---|---|
Mutex<T> |
Guarded mutable value of any type T |
Mutex::new(initial) |
m.free() |
Atomic |
Lock-free shared i64 (counters, flags, seq numbers) |
Atomic::new(initial) |
a.free() |
WaitGroup |
Block until N coroutines finish | WaitGroup::new() |
wg.free() |
These are built on pthread_mutex / pthread_cond (the same layer the runtime's channels use) and C11 _Atomic. Each constructor returns a raw pointer (*Mutex<T>, *Atomic, *WaitGroup); the wrapper is freed only when you call .free(), so the idiom is:
let m: *Mutex<i32> = Mutex::new(0);
defer m.free();
Choosing a primitive
| If you need… | Reach for | Why |
|---|---|---|
A shared counter / flag / sequence number (i64) |
Atomic |
Lock-free; no critical section to forget to release. |
| To guard a struct, vector, or multi-step invariant | Mutex<T> |
One lock covers a whole "load, mutate, store" transaction. |
| To wait for a fixed set of spawned workers to finish | WaitGroup |
add ahead, done per worker, wait in the parent. |
| One-shot "only the first racer wins" init | Atomic::cas |
Atomic test-and-set without a lock. |
Mutex<T>
A mutex protecting a value of type T. Use .with() for the common load-mutate-store pattern, or .lock() / .get() / .set() / .unlock() for explicit control.
pub struct Mutex<T> {
inner: T,
handle: *void,
}
Both fields are private; reach the value through get / set / with.
Method catalog
| Method | Signature | Description |
|---|---|---|
new |
Mutex::new(initial: T) -> *Mutex<T> |
Build a mutex wrapping initial; starts unlocked. |
lock |
fn lock(self: *Mutex<T>) |
Acquire the lock, blocking until available. |
unlock |
fn unlock(self: *Mutex<T>) |
Release the lock. Calling without holding it is UB. |
get |
fn get(self: *Mutex<T>) -> T |
Read (a copy of) the inner value. Caller must hold the lock. |
set |
fn set(self: *Mutex<T>, v: T) |
Write the inner value. Caller must hold the lock. |
with |
fn with<U>(self: *Mutex<T>, f: fn(*T) -> U) -> U |
Run f(&inner) while holding the lock; return its result. |
free |
fn free(self: *Mutex<T>) |
Destroy the pthread mutex and free the wrapper. |
None of these methods return !T/?T — there are no fallible operations. The only failure modes are usage errors (unbalanced lock/unlock, recursive locking), which are undefined behaviour rather than returned errors.
new
pub fn new(initial: T) -> *Mutex<T>
Allocates the wrapper on the heap, stores initial, and initialises an unlocked pthread mutex. The returned *Mutex<T> is a raw pointer you own; release it with free().
lock / unlock / get / set
Acquire with lock(), mutate through get() / set(), then release with unlock(). Every lock() must be matched by exactly one unlock().
import stdlib::sync::*;
fn main() -> i32 {
let m: *Mutex<i32> = Mutex::new(0);
defer m.free();
m.lock();
m.set(m.get() + 1);
m.unlock();
m.lock();
let v: i32 = m.get();
m.unlock();
println!("counter =", v);
return 0;
}
with
with<U>(f) takes the lock, calls f with a pointer to the inner value, and releases the lock before returning f's result. This is the safe way to do "load, mutate, store" without juggling lock / unlock by hand. The callback receives *T, so it can read and mutate the value in place — and unlike get(), the mutation lands on the guarded value.
import stdlib::sync::*;
fn bump(p: *i32) -> i32 {
*p = *p + 1;
return *p;
}
fn main() -> i32 {
let m: *Mutex<i32> = Mutex::new(10);
defer m.free();
let after: i32 = m.with(bump); // locks, bumps, unlocks
println!("after =", after); // 11
return 0;
}
with works over any payload type, including structs:
import stdlib::sync::*;
struct Stats { hits: i32, misses: i32 }
fn add_hit(p: *Stats) -> i32 {
p.hits = p.hits + 1;
return p.hits;
}
fn main() -> i32 {
let m: *Mutex<Stats> = Mutex::new(Stats{ hits: 0, misses: 0 });
defer m.free();
let h: i32 = m.with(add_hit);
println!("hits =", h);
return 0;
}
Worked example: shared counter across coroutines
The headline use of a Mutex is guarding a value that many coroutines touch at once. Here four workers each call with(inc) 100 times; the WaitGroup makes main wait for all of them before reading the final total.
import stdlib::sync::*;
fn inc(p: *i32) -> i32 {
*p = *p + 1;
return *p;
}
fn worker(m: *Mutex<i32>, wg: *WaitGroup) {
defer wg.done();
for let i: i32 = 0; i < 100; i++ {
m.with(inc);
}
}
fn main() -> i32 {
let m: *Mutex<i32> = Mutex::new(0);
defer m.free();
let wg: *WaitGroup = WaitGroup::new();
defer wg.free();
wg.add(4);
for let w: i32 = 0; w < 4; w++ {
spawn worker(m, wg);
}
wg.wait();
m.lock();
let total: i32 = m.get();
m.unlock();
println!("total =", total); // 400
return 0;
}
total = 400
Without the mutex, the *p = *p + 1 read-modify-write would race and the total would land somewhere below 400. (For a plain i64 counter like this, an `Atomic` is lighter weight — see below; the Mutex shines when the guarded value is a struct or the update spans several fields.)
Worked example: guarding a struct
with() and explicit lock/get/set are interchangeable; with() is preferred because it cannot leak a held lock. The explicit form is handy when you need a value read out of the critical section:
import stdlib::sync::*;
struct Account { balance: i32 }
fn deposit(p: *Account) -> i32 {
p.balance = p.balance + 50;
return p.balance;
}
fn main() -> i32 {
let m: *Mutex<Account> = Mutex::new(Account{ balance: 100 });
defer m.free();
let bal: i32 = m.with(deposit);
println!("balance =", bal); // 150
// Explicit lock/get/set is equivalent:
m.lock();
let cur: Account = m.get();
m.set(Account{ balance: cur.balance - 30 });
m.unlock();
m.lock();
let after: Account = m.get();
m.unlock();
println!("after withdraw =", after.balance); // 120
return 0;
}
free
Releases the underlying pthread mutex and frees the wrapper. The pointer is dangling afterwards, so always pair with defer.
let m: *Mutex<i32> = Mutex::new(0);
defer m.free();
Atomic
A lock-free i64. Use it for shared counters, flags, or sequence numbers — any case where a Mutex<i32> would dominate the contention picture.
pub struct Atomic {
handle: *void,
}
All operations are sequentially consistent (the strongest, simplest memory order — reads and writes appear in a single global order).
Method catalog
| Method | Signature | Description |
|---|---|---|
new |
Atomic::new(initial: i64) -> *Atomic |
Build an atomic initialised to initial. |
load |
fn load(self: *Atomic) -> i64 |
Load the current value. |
store |
fn store(self: *Atomic, v: i64) |
Store v. |
add |
fn add(self: *Atomic, delta: i64) -> i64 |
Add delta, return the previous value. |
cas |
fn cas(self: *Atomic, expected: i64, desired: i64) -> bool |
Compare-and-swap; true if swapped. |
free |
fn free(self: *Atomic) |
Free the wrapper. |
To subtract or decrement, pass a negative delta: a.add(0 - 1) (Glide has no unary-minus literal in this position; build the negative with 0 - n).
load / store / add
add returns the value before the addition, which makes it a natural unique-id / sequence seed (add(1) hands each caller a distinct prior count).
import stdlib::sync::*;
fn main() -> i32 {
let seq: *Atomic = Atomic::new(0);
defer seq.free();
let first: i64 = seq.add(1); // 0 (was 0, now 1)
let second: i64 = seq.add(1); // 1 (was 1, now 2)
let third: i64 = seq.add(1); // 2 (was 2, now 3)
println!("tickets:", first, second, third);
println!("next free id =", seq.load()); // 3
return 0;
}
tickets: 0 1 2
next free id = 3
Worked example: atomic increment across coroutines
This is where Atomic pays off: eight coroutines each do 1000 increments with no lock, and the total is exact because add is atomic.
import stdlib::sync::*;
fn bumper(a: *Atomic, wg: *WaitGroup) {
defer wg.done();
for let i: i32 = 0; i < 1000; i++ {
a.add(1);
}
}
fn main() -> i32 {
let a: *Atomic = Atomic::new(0);
defer a.free();
let wg: *WaitGroup = WaitGroup::new();
defer wg.free();
wg.add(8);
for let w: i32 = 0; w < 8; w++ {
spawn bumper(a, wg);
}
wg.wait();
let total: i64 = a.load();
println!("total =", total); // 8000
return 0;
}
total = 8000
cas
Compare-and-swap: if the current value equals expected, replace it with desired and return true; otherwise leave it unchanged and return false. Use it for one-shot guards where only the first racer should win.
import stdlib::sync::*;
fn try_init(flag: *Atomic, id: i32) {
if flag.cas(0, 1) {
println!("worker won init:", id);
} else {
println!("worker skipped:", id);
}
}
fn main() -> i32 {
let flag: *Atomic = Atomic::new(0);
defer flag.free();
try_init(flag, 1); // wins
try_init(flag, 2); // skips
try_init(flag, 3); // skips
println!("flag =", flag.load()); // 1
return 0;
}
worker won init: 1
worker skipped: 2
worker skipped: 3
flag = 1
WaitGroup
A counter you can wait to reach zero. Mirrors Go's sync.WaitGroup: add(n) before spawning, done() from each worker, wait() from the parent.
pub struct WaitGroup {
handle: *void,
}
Internally it is a counter guarded by a mutex + condition variable: add bumps the counter (broadcasting when it reaches zero), done decrements, and wait blocks on the condvar until the counter is zero.
Method catalog
| Method | Signature | Description |
|---|---|---|
new |
WaitGroup::new() -> *WaitGroup |
Build an empty wait group with counter zero. |
add |
fn add(self: *WaitGroup, n: i32) |
Increase the counter by n (pass a positive number). |
done |
fn done(self: *WaitGroup) |
Decrement by one; when it hits zero, blocked wait() calls return. |
wait |
fn wait(self: *WaitGroup) |
Block until the counter reaches zero. |
free |
fn free(self: *WaitGroup) |
Free the wrapper. |
add / done / wait
The canonical pattern: bump the counter, spawn that many workers (each defer wg.done()), then block in wait().
import stdlib::sync::*;
fn worker(wg: *WaitGroup) {
defer wg.done();
// ...do work...
}
fn main() -> i32 {
let wg: *WaitGroup = WaitGroup::new();
defer wg.free();
wg.add(3);
for let i: i32 = 0; i < 3; i++ {
spawn worker(wg);
}
wg.wait(); // returns once all 3 workers have called done()
println!("all workers finished");
return 0;
}
Edge cases: empty group and rebalancing
wait() on a fresh (zero-counter) group is a no-op. Each add(n) must be balanced by n done() calls before wait() unblocks:
import stdlib::sync::*;
fn main() -> i32 {
let wg: *WaitGroup = WaitGroup::new();
defer wg.free();
wg.wait(); // counter already 0 -> returns at once
println!("nothing to wait for");
wg.add(2);
wg.done();
wg.done();
wg.wait(); // balanced -> returns
println!("done");
return 0;
}
Lifetime and ownership
Every primitive is a heap allocation that you own. The constructor allocates, and only free() releases — there is no automatic drop for these raw pointers. The robust idiom is defer .free() right after construction:
let m: *Mutex<i32> = Mutex::new(0);
defer m.free();
let a: *Atomic = Atomic::new(0);
defer a.free();
let wg: *WaitGroup = WaitGroup::new();
defer wg.free();
After free() the pointer is dangling — do not touch it again.
See also
Channels and spawn/coroutines (the unit of concurrency these primitives synchronize, and the message-passing alternative to shared state) are covered in the language book rather than this stdlib reference. Pair a WaitGroup with a channel to collect worker results after wait().