sync

tape
import sync from "sync";
import threads from "threads";

pub struct Health { down: [16]bool; checks: i64; }

@tape { sync.gen_guarded(Health); }
let health: *Guarded_Health = guarded_Health_new(Health { .. });

@pool(4)
thread probe {
    fn check(i: i64) -> i64 {
        let h: *Health = health.lock();     // the only way to the shared value
        defer health.unlock();
        h.checks = h.checks + 1;
        h.down[i] = false;
        return h.checks;
    }
}

Threads share no mutable state: a thread’s functions cannot reach a module var (see No shared state). sync is the one place state that several threads must change together may live: a counter, a cache index, a health table. The value sits in a heap block with its lock, the block’s address is held in a module let (its initializer runs once before main, so any thread may read it), and the value is reached only through this module’s functions, which are safe to call from any thread. Reading the program still tells where the sharing is: at the sync types, nowhere else.

sync is a capability module (it uses the runtime’s locks) and @profile(t1).

Guarded values

gen_guarded(T) makes, for your type T, a block holding a T and a mutex:

FunctionDoes
guarded_T_new(value: T) -> *Guarded_TA new block holding a copy of value, its lock free; kept for the program (there is no free)
g.lock() -> *TTakes the lock and gives you the value, to read or change
g.unlock()Releases the lock

The pointer lock returns is valid until unlock; keeping it past that is the same mistake as using a closed file. The usual shape is lock, defer unlock, as above. A guarded value can be anything, a number too:

tape
@tape { sync.gen_guarded(i64); }
let largest: *Guarded_i64 = guarded_i64_new(0);

fn saw(v: i64) {
    let n: *i64 = largest.lock();
    if (v > *n) { *n = v; }
    largest.unlock();
}

A plain counter that tasks only add to needs no lock: that is an Atomic.

Call the generator once per type, at module level; the type you generate for must be pub. The names land in your module, so you use them without the sync. prefix. A T with strings or containers in it is never freed: the block lives as long as the program.

Mutex

FunctionDoes
mutex() -> MutexA new lock, free
m.lock()Waits until the lock is free and takes it
m.unlock()Releases it
tape
let m: sync.Mutex = sync.mutex();

fn update() {
    m.lock();
    defer m.unlock();
    // ...
}

A mutex is not reentrant: a thread locking a mutex it already holds waits forever. Use a Mutex with your own data when a guarded value doesn’t fit (two things changed together, a queue with a condition variable); otherwise gen_guarded keeps the lock and the value in one place.

Cond

A condition variable lets a task wait, without spinning, until another changes something under a mutex:

FunctionDoes
cond() -> CondA new condition variable
c.wait(&m, timeout)Releases m (which you hold), sleeps until wake_all or the timeout, takes m again; true when woken, false when the timeout passed. timeout is a time.Duration; leave it out to wait without limit
c.wake_all()Wakes every waiter

A wait can end early for no reason, so check the condition in a loop:

tape
import collections from "collections";
import time from "time";

struct Queue { items: collections.Vec_i64; head: i64; }
let q: *Queue = queue_new();           // a heap block of your own
let qm: sync.Mutex = sync.mutex();
let qc: sync.Cond = sync.cond();

thread producer {
    fn push(v: i64) {
        qm.lock();
        q.items.push(v);
        qm.unlock();
        qc.wake_all();
    }
}

thread consumer {
    fn take() -> ?i64 {
        qm.lock();
        defer qm.unlock();
        while (q.items.len() == q.head) {
            if (!qc.wait(&qm, time.seconds(1))) { return null; }   // nothing came
        }
        let v: i64 = q.items.get(q.head);
        q.head = q.head + 1;
        return v;
    }
}

A negative timeout stops the program (sync.Cond.wait: negative timeout).

RwLock

Many readers or one writer, for state read often and replaced rarely (a configuration, a routing table):

FunctionDoes
rwlock() -> RwLockA new read-write lock, free
rw.read_lock(), rw.read_unlock()Take and release the lock for reading; any number of readers hold it together
rw.write_lock(), rw.write_unlock()Take and release it for writing; a writer holds it alone

A writer that is waiting goes first: readers arriving after it queue behind it, so a steady stream of readers cannot starve a writer. The lock is not upgradable: a reader that needs to write releases its read lock and takes the write lock.

tape
let config: *Config = config_new();
let rw: sync.RwLock = sync.rwlock();

thread server {
    fn serve(path: string) -> i64 {
        rw.read_lock();
        defer rw.read_unlock();
        return route(config, path);
    }
}

fn reload(c: Config) {
    rw.write_lock();
    *config = c;
    rw.write_unlock();
}

Atomic

A 64-bit counter many tasks change without a lock: requests served, bytes sent, a reference count.

FunctionDoes
atomic(v: i64) -> AtomicA new counter holding v; kept for the program (there is no free)
a.load() -> i64The counter’s value
a.store(v: i64)Sets it to v
a.add(delta: i64) -> i64Adds delta (a negative one subtracts; wraps like any i64 add) and gives the value before
a.cas(old: i64, new: i64) -> boolWhen the counter holds old, sets it to new and gives true; otherwise leaves it and gives false
tape
let served: sync.Atomic = sync.atomic(0);

@pool(8)
thread server {
    fn serve(path: string) -> i64 {
        served.add(1);
        // ...
        return 0;
    }
}

fn report() -> i64 { return served.load(); }

Each operation is indivisible, and every thread sees all of them in one order: what a thread wrote before a store or an add is visible to a thread whose load sees it. There is no weaker ordering to choose. A cas that fails changes nothing; a loop that retries it loads the value again:

tape
let peak: sync.Atomic = sync.atomic(0);

fn raise_to(v: i64) {
    var cur: i64 = peak.load();
    while (v > cur && !peak.cas(cur, v)) { cur = peak.load(); }
}

An Atomic is a handle to a word on the heap, so a module let holds it and any thread may call its functions. The counter is i64 only.

Blob

Immutable bytes several tasks share without copying them: a cached response body one task stores and others send.

FunctionDoes
blob(data: []const u8) -> BlobA new blob holding a copy of data
b.bytes() -> []const u8The bytes, valid while b lives
b.len() -> i64How many bytes

A Blob counts its references. Copying one (into a variable, a struct, a task’s arguments, a Vec) adds a reference to the same bytes, nothing is copied; when a copy goes out of scope it releases its reference, and the last one out frees the bytes. So the bytes live exactly as long as something holds them, on whichever thread that is:

tape
@pool(8)
thread senders {
    fn send(body: sync.Blob, conn: i64) -> i64 {
        return write_all(conn, body.bytes());   // this task's own reference
    }
}

fn sent(n: i64) {}

fn serve_cached(conns: []const i64) {
    let body: sync.Blob = sync.blob(render_page());
    for (c in conns) { senders.send(body, c) { on_done: sent }; }
}   // body's reference goes here; the bytes go when the last send ends

The bytes are never written after blob returns, so any number of threads may read them at once without a lock. A Blob { .. } holds no bytes (its bytes() is empty), and so does an empty blob("").

Lifetime and copies

A Mutex, Cond, RwLock or Atomic is made once and lives for the program: there is no free. Copying one copies a handle, so two copies are the same lock or the same counter, which is what a module let relies on. One in a local variable leaks its object when the variable goes out of scope, so make them module state (a let), or keep them inside a heap block the program keeps. A Blob is the other way round: each copy is a reference, and the bytes are freed when the last one goes.

In the VM

tape run runs a thread’s tasks one at a time, each to completion. A lock is then never contended, and a wait without a timeout on a condition that no other task can make true is a hang the program wrote (natively another task would wake it). The results are the same in both modes; what differs is only how many tasks are ever inside a lock at once. The tests of a program that uses sync run in both, as tape test does.

Last modified: