Chapter 20

Threads That Share Nothing

Goose has threads, and it has no data races. It gets there the blunt way: threads share no mutable memory at all. There are no locks, no atomics and no memory orderings in the language, because there is nothing for them to protect. Threads communicate by sending values through typed queues.

#A worker is a separate program20.1

A function declared thread_fn is the entry point of a worker. The compiler compiles it, and everything it calls, as a separate program, with its own data stacks and its own copies of the globals it uses:

main its own globals its own data stacks its own code path worker its own globals its own data stacks its own code path queue<Job> queue<Tally> only flat values cross, as a single copy
Each worker is compiled as a separate program with its own globals and data stacks. Values travel between them through queues, one per type.

You start a worker with thread_spawn, which returns an id, and wait for it to finish with thread_wait(id). Here is what "its own copies of the globals" means in practice:

var hatched = 0;

thread_fn hatch() {
    hatched += 10;
    print("worker sees ", hatched);
}

fn main() {
    hatched = 1;
    let id = thread_spawn(hatch);
    thread_wait(id);
    print("main sees ", hatched);
}
worker sees 11
main sees 1

The worker's copy of hatched was taken from main's at the moment of the spawn. After that the two are unrelated, and the worker's change never reaches main.

Threads need a real C compiler. The in-process backend that goose file.goose uses cannot run them, so build threaded programs with goose build:

thread_fn idle() { }

fn main() { thread_wait(thread_spawn(idle)); }
JIT mode does not support threads yet (TinyCC cannot place thread-local storage in an in-memory run); compile with -o and a C compiler instead

#Typed queues20.2

Values move between threads through queues, and there is one queue per type. qput(v) puts a value on the queue for its type, and qget<T>() takes the next T, waiting until there is one. Because the queue is chosen by the type, sending a value needs no channel object:

struct Job { pen: i32, geese: i32 }
struct Tally { pen: i32, eggs: i64 }

fn count_eggs(geese: i32) -> i64 {
    var total: i64 = 0;
    for g in geese { total += (g * 7 + 3) % 5; }
    return total;
}

thread_fn counter() {
    loop {
        let job = qget<Job>();
        guard job.pen >= 0 else { break; }        // a negative pen means stop
        qput(Tally { pen: job.pen, eggs: count_eggs(job.geese) });
    }
}

fn main() {
    let workers = 4;
    for p in 8 { qput(Job { pen: p as i32, geese: (100 + p * 10) as i32 }); }
    for w in workers { qput(Job { pen: -1, geese: 0 }); }
    var ids: i64[>..] = [];
    for w in workers { ids.push(thread_spawn(counter)); }
    var by_pen: i64[8] = [0; 8];
    for p in 8 {
        let t = qget<Tally>();
        by_pen[t.pen] = t.eggs;
    }
    for id in ids { thread_wait(id); }
    print(by_pen);
}
[200, 220, 240, 260, 280, 300, 320, 340]

The tallies arrive in whatever order the workers finish, so main files each one under its pen number. Every worker reads from the same queue of Jobs, which is how the work is shared out.

The other queue operations are qget<T>(ms), which waits at most ms milliseconds and returns a bool saying whether a value came, qpoll<T>(), which never waits, and qlimit<T>(n), which caps a queue's length so that qput returns false when it is full. hardware_threads() returns a sensible number of workers for the machine.

#What can cross: flat values20.3

A value that goes through a queue must be flat: it must contain no references and no slices, at any depth. A reference from one thread's memory would mean nothing in another thread's:

struct Note { view: u8[:] }

fn main() {
    var buf: u8[>..] = [];
    buf.append("honk");
    qput(Note { view: buf[..] });
}
error: queue elements must be flat (§11.2), not Note

Flat does not mean fixed-size. A struct with a u8[] string, or a T[] of results, is flat, because its variable-size parts are stored inline. So a message carries its data with it, and sending it is a single copy of contiguous bytes:

struct Report { pen: i32, names: u8[][] }

thread_fn reporter() {
    qput(Report { pen: 7, names: ["Ada", "Bea", "Cleo"] });
}

fn main() {
    let id = thread_spawn(reporter);
    let r = qget<Report>();
    thread_wait(id);
    print("pen ", r.pen, ": ", r.names);
}
pen 7: ["Ada", "Bea", "Cleo"]

#Workers that fail20.4

When a worker started with thread_spawn fails an assertion or a bounds check, the whole program stops, as it would on the main thread. A worker started with thread_spawn_contained is different: its failure ends only that worker. thread_wait returns false for it, and the form that takes a builder appends the failure message:

thread_fn fragile(n: i64) {
    assert(n > 2);
    print("worker ", n, " finished");
}

fn main() {
    var why: u8[>..] = [];
    for n in [1, 5] {
        if !thread_wait(thread_spawn_contained(fragile, n), why) {
            print("worker ", n, " failed: ", why);
            why.clear();
        }
    }
    print("main carries on");
}
worker 1 failed: assert failed (main.goose:2)
worker 5 finished
main carries on

The example also shows arguments passed to a worker. Like queue messages, they must be flat.

Everything a worker owned is released when it ends, whether it returned or failed. Workers still running when main returns are stopped.

#Design note: no shared memory20.5

Shared memory is what makes concurrent programming hard. Every piece of data two threads can see needs a rule about who may touch it and when, and the compiler or the programmer must enforce the rule. Rust enforces it with Send, Sync and borrowing, and C leaves it to you.

Goose removes the shared data. That is a real limitation: some algorithms, such as a parallel loop over a big array that every thread writes a part of, want shared memory, and in Goose they must instead send the parts out and gather the results back. The memory model softens the cost, because sending a flat value is one memcpy of a contiguous block, often no more work than a cache miss would have been. And what you get in return is a concurrency model with nothing left to get wrong, apart from the logic of the messages themselves.

#Exercises20.6

  1. Write a program with two workers that each sum half of the numbers from 1 to 1000000 and send their sums back to main as a struct Part { sum: i64 }.

  2. Send a struct Batch { weights: f32[] } holding five weights to a worker that sends back their average.

  3. What does a worker that increments a global, and then sends it back to main in a message, tell you about the global's value in main? Write the program and check.