Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Threads

CobaltC’s concurrency is small on purpose. You start a thread and you must wait for it. Data goes to a thread by moving, by lending a reference, or through a channel; data shared by several threads lives in a mutex or an atomic. And the rules of chapter 7 apply unchanged across threads, so a data race is an aliasing conflict like any other, reported rather than suffered. This chapter is about those few pieces and the guarantees around them.

spawn and join

import std;

fn sum_squares(u64 from, u64 to) : u64
{
    u64 total = 0;
    for (u64 i = from; i < to; i += 1)
    {
        total += i * i;
    }
    total
}

fn main()
{
    handle<u64> low = spawn(sum_squares, 0, 500);       // runs on its own thread
    handle<u64> high = spawn(sum_squares, 500, 1000);   // and so does this
    u64 total = join(low) + join(high);                  // wait for each, take its result
    printf("%v\n", total);
    printf("%v\n", sum_squares(0, 1000));                // the same, done here
}
332833500
332833500

spawn(f, args...) runs f(args...) on a new thread and returns a handle<R>, where R is what f returns. join(h) waits for the thread and gives you its result. A handle is a resource: if it goes out of scope without being joined, the thread is joined then and its result discarded, so a thread never outlives the scope that owns its handle. That one rule is what makes everything else in this chapter safe: a reference lent to a thread is a borrow that ends at the join, which is always before the lender’s scope ends.

The compiler runs threads in parallel on separate cores. The interpreter runs them one at a time, taking turns. A program that follows the rules gets the same result from both.

Giving a thread its data

Arguments to spawn are passed exactly as to a call: plain values are copied, resources move, and references are lent. Moving is the simplest:

import std;

fn longest(Vec<String> words) : String        // takes the vector: it moves to the thread
{
    String best = String::new();
    foreach (w in words)
    {
        if (String::len(&w) > String::len(&best))
        {
            best = w;
        }
    }
    best
}

fn main()
{
    Vec<String> words = Vec::new();
    Vec::push(&mut words, "pear");
    Vec::push(&mut words, "banana");
    Vec::push(&mut words, "fig");
    handle<String> h = spawn(longest, words);   // words is gone from here
    String result = join(h);                    // and the String comes back
    printf("%v\n", &result);
}
banana

The vector moves into the thread, which owns it, and the result moves back through join. Nothing is shared, so nothing needs a lock. A move closure does the same job when the thread’s code is better written inline: spawn(move [words]() { ... }). A closure that merely borrows locals cannot be spawned, since the thread might outlive them.

Lending a reference works too, and the borrow lasts until the join:

import std;

fn count_over(ref<Vec<i32>, shared> v, i32 limit) : usize
{
    usize n = 0;
    foreach (x in v)
    {
        if (*x > limit)
        {
            n += 1;
        }
    }
    n
}

fn main()
{
    Vec<i32> v = Vec::from_slice(&[5, 12, 7, 30, 1][0..$]);
    handle<usize> h = spawn(count_over, &v, 6);    // the thread borrows v until join
    Vec::push(&mut v, 99);            // ✗ the thread may still be reading v
    printf("%v\n", join(h));
}
error[diag.aliasing-conflict]: `h` still borrows `v`, so it cannot be written or borrowed `mut` here; end that borrow first
  --> borrow_until_join.cb:20:15
   |
20 |     Vec::push(&mut v, 99);            // ✗ the thread may still be reading v
   |               ^
   = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

Whether the thread had finished reading before the push is a matter of timing, and a program whose correctness depends on timing is rejected instead. Move the push after the join and the program is accepted. The same checker that stops a reference being kept across a push in one thread stops it across two; there is no separate theory of data races.

Sharing state: mutex

When several threads must change one value, it goes in a mutex, and the only way to reach the value is to lock it:

import std;

fn worker(ref<mutex<u64>, shared> total, u64 n)
{
    foreach (i in 1..n + 1)
    {
        guard<u64> g = lock(total);   // waits until the mutex is free
        *g += i;
    }                                 // the guard ends here, unlocking
}

fn main()
{
    mutex<u64> total = Mutex::new(0: u64);
    {
        handle<void> a = spawn(worker, &total, 100);
        handle<void> b = spawn(worker, &total, 100);
    }                                 // both handles end here: both threads are joined
    printf("%v\n", *lock(&total));    // 2 * (1 + 2 + ... + 100)
}
10100

lock(&m) returns a guard<T>, an exclusive reference into the mutex that exists as long as the guard does. *g is the value; g.field reaches a field of it. The guard is a resource, and its destruction unlocks the mutex, so the lock is held for exactly the guard’s scope and cannot be forgotten. Another thread’s lock waits. A thread that locks a mutex it already holds faults rather than deadlocking.

A mutex is shared by passing &m, a shared reference, to any number of threads. It is the one place an exclusive path, the guard, is allowed to coexist with shared references to the same object, because the lock serialises the accesses. Keep each guard in the smallest block that needs it; a guard held across a long computation is a lock held that long.

For a single integer shared by threads, a counter, a flag, a running maximum, Atomic<T> from the library does the job without a lock: Atomic::fetch_add, Atomic::load, Atomic::compare_exchange and the rest, each indivisible and each sequentially consistent. State of more than one value still belongs in a mutex.

Passing values: Channel

A channel is a queue between threads. Values move in at one end and out at the other, so a String or a Vec travels between threads with one owner at every moment:

import std;

fn worker(ref<Channel<u64>, shared> jobs, ref<Channel<u64>, shared> results)
{
    while (Some(n) = Channel::recv(jobs))       // None once the channel is closed and empty
    {
        _ = Channel::send(results, n * n);
    }
}

fn main()
{
    Channel<u64> jobs = Channel::new(4);         // holds at most 4 waiting jobs
    Channel<u64> results = Channel::new(100);
    {
        handle<void> a = spawn(worker, &jobs, &results);
        handle<void> b = spawn(worker, &jobs, &results);
        foreach (i in 1..11: u64)
        {
            _ = Channel::send(&jobs, i);         // waits while the channel is full
        }
        Channel::close(&jobs);                   // the workers stop when it runs dry
    }                                            // and are joined here
    Channel::close(&results);
    u64 total = 0;
    while (Some(sq) = Channel::recv(&results))
    {
        total += sq;
    }
    printf("%v\n", total);                       // 1 + 4 + ... + 100, in whatever order they came
}
385

send waits while the channel is full and recv while it is empty; a waiting thread uses no processor time. close says nothing more is coming: what was sent is still received, and then recv gives None, which ends a worker’s loop. This shape, a pool of workers fed by one channel and closed when the work is done, is the common one. send returns a Result, since a value sent after close comes back as an error rather than vanishing; the _ = says the result is not needed here.

When a thread fails

A fault in a thread you started ends that thread, not the program. The thread unwinds, its destructors run, a mutex it held is poisoned so that the next lock of it faults, and its failure becomes a value at the boundary:

import std;

fn handler(usize i) : u64
{
    Vec<u64> v = Vec::filled(3, 7: u64);
    v[i]                              // a bug when i >= 3
}

fn main()
{
    usize served = 0;
    usize failed = 0;
    foreach (i in 0..5)
    {
        match (try_join(spawn(handler, i)))     // the thread's fault, as a value
        {
            Ok(_)  : served += 1,
            Err(f) :
            {
                printf("thread %v failed: %v\n", i, &ThreadFailure::diagnostic(&f));
                failed += 1;
            },
        }
    }
    printf("%v served, %v failed\n", served, failed);
}
thread 3 failed: diag.index-out-of-bounds
thread 4 failed: diag.index-out-of-bounds
3 served, 2 failed

try_join(h) returns Result<R, ThreadFailure>, with the failure’s diagnostic name and its full report available as text. A plain join of a failed thread raises the failure again in the joiner, and so does a handle that ends unjoined, so a program that never looks still fails as a whole, one scope at a time, rather than carrying on in a broken state. This is what lets a server give each connection a thread and survive a bug in one of them.

One exception keeps the guarantee honest. A thread that was lent an exclusive reference may be halfway through changing data that belongs to a thread still running, so its fault ends the program at once, as a fault always did. Lend a thread exclusive access only when its failure should be the program’s; otherwise move the data in.

cancel(&h) asks a thread to stop: its current or next wait ends with a contained failure, which ThreadFailure::is_cancelled recognises. And a wait that can never end, every thread waiting on another, is reported as diag.deadlock rather than hanging.

Finding the timing bugs

A program whose result depends on which thread ran first is easy to write and hard to notice, because the timing you test is rarely the one that goes wrong. coby --explore N prog.cb runs a program N times under different orders of its threads and groups the runs by outcome; more than one outcome means the result depends on timing. Here is a program with a lost update:

import std;

fn bump(ref<mutex<i64>, shared> m)
{
    i64 seen = 0;
    {
        guard<i64> g = lock(m);
        seen = *g;
    }                                 // the lock is released here...
    guard<i64> g = lock(m);
    *g = seen + 1;                    // ...so the other thread may have run in between
}

fn main()
{
    mutex<i64> m = Mutex::new(0: i64);
    {
        handle<void> a = spawn(bump, &m);
        handle<void> b = spawn(bump, &m);
    }
    printf("total %v\n", *lock(&m));
}
explored 40 schedules of explore.cb: 2 distinct outcomes

15 runs, exit 0 -- replay: coby --schedule-seed 1 explore.cb
  | total 1

25 runs, exit 0 -- replay: coby --schedule-seed 2 explore.cb
  | total 2

Each outcome comes with a seed, and coby --schedule-seed S prog.cb replays that run exactly, as often as needed to see why. cobc --explore does the same with the compiled program. The fix here is to hold the lock across the read and the write, which is the rule of thumb for every mutex: a read-then-write under one lock is one operation, and under two it is none.

What is not here

There are no memory orderings weaker than sequential consistency, no condition variables, no thread-local storage and no read-write lock. There are, in the library, Barrier, Semaphore and Once for the common waiting patterns, Atomic::wait and notify for sleeping until a value changes, Arc<T> for a value that several threads own together, and slice_parts for splitting a vector into parts that threads fill at the same time with no lock, since disjoint slices never conflict. The language guide’s chapter on concurrency covers each, with its rules.