Chapter 26 13 min read

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&lt;T&gt;

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;
}
output
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;
}
output
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;
}
output
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;
}
output
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().