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:
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
-
Write a program with two workers that each sum half of the numbers from 1 to 1000000 and send their sums back to
mainas astruct Part { sum: i64 }. -
Send a
struct Batch { weights: f32[] }holding five weights to a worker that sends back their average. -
What does a worker that increments a global, and then sends it back to
mainin a message, tell you about the global's value inmain? Write the program and check.