Chapter 14 — Concurrency
Doing several things at once is where sharp edges tend to appear. Ingle takes a deliberately structured approach that keeps them blunt.
The headline ideas:
- No function colouring. There’s no
async/awaitsplit dividing your code into two incompatible worlds. Any function can be run concurrently. (You’ll remember unit functions from Chapter 4 — those make natural workers.) - Tasks are scoped. Concurrency happens inside a
nurseryblock, and tasks cannot outlive it — the block does not finish until every task spawned inside it has finished. When control leaves the nursery, everything it started is done. No leaked background threads, no “did that ever complete?” - They talk over typed channels. A
Channel<T>carries values of typeTbetween tasks.
Here’s a complete producer/consumer:
fn producer(ch: Channel<int>) {
send(ch, 10)
send(ch, 20)
send(ch, 30)
close(ch) // no more values coming
}
fn consumer(ch: Channel<int>, out: Channel<int>) {
var sum = 0
loop {
match recv(ch) {
case Some(v) { sum = sum + v } // a value arrived
case None { break } // channel closed and drained
}
}
send(out, sum)
}
fn main() -> int {
let ch: Channel<int> = channel(2) // buffered, capacity 2
let out: Channel<int> = channel(1)
nursery {
spawn producer(ch)
spawn consumer(ch, out)
} // both tasks are finished here, guaranteed
match recv(out) {
case Some(v) { return v } // => 60
case None { return 0 }
}
}
=> 60
The pieces:
nursery { ... }— a task group. The block joins all its tasks before it exits.spawn f(args)— launch a call tofas a task in the enclosing nursery. (Usingspawnoutside a nursery is a compile error — there’d be nothing to scope it to.)channel(N)— make a buffered channel of capacityN. Its element type comes from the binding’s annotation (let ch: Channel<int> = channel(2)).send(ch, v)— put a value in (blocks if the buffer is full). Sending on a channel you’ve alreadyclosed is a runtime error — closing means “no more values are coming,” so close only once everysendis done.recv(ch)— take the next value, as anOption<T>:Some(v)while values flow,Noneonce the channel is closed and drained. ThatNoneis how a consumer loop knows to stop; on an open-but-empty channel it blocks until something arrives.try_recv(ch)—recvthat never blocks:Some(v)if a value is queued right now,Noneif the channel is empty (or closed and drained). It’s the primitive an event loop reaches for when it has other work to do and can’t afford to sit waiting on the channel.close(ch)— mark the channel closed. Queued values still drain; after that,recvreturnsNoneinstead of blocking forever.
Notice how naturally recv returning Option<T> falls out of Chapter 9 — “maybe there’s a
value” is exactly an Option, so the same match you already know handles the channel
draining.
Many workers, the same code
Because channels are shareable handles, you can spawn several workers all pulling from one
jobs channel, and the work fans out across whichever worker is free — a worker pool, in a few
lines. (The shipped example examples/05_concurrency.ig does exactly this: one dispatcher, four
workers counting error lines, a results channel summed at the end. It runs today.)
One source, three speeds
The same program, with no source changes, runs on any of three schedulers — only the wall-clock time
changes. The default runs your tasks as green threads cooperatively on a single OS thread: a
send or recv that can’t proceed yields, and the scheduler resumes it later. Built with
EMBER_PARALLEL=1 (and what a native binary uses), a thread-per-task runtime gives each task a
real OS thread on its own core. And make mn selects an M:N green-thread scheduler — a small
pool of OS threads multiplexing many lightweight fibers that park on a channel rather than block a
thread, so thousands of tasks cost a handful of threads. One set of words,
nursery/spawn/Channel<T>; one answer; three ways to spend your cores.
This works because of ownership. Recall: structs and arrays are uniquely owned (never aliased across tasks), and strings and enums are immutable. So there’s essentially no shared mutable state for threads to race over — the data-race class of bug is gone by construction, the same way use-after-free was. All three runtimes also detect a genuine deadlock (every task blocked on a channel that can never deliver) and report it as a clean runtime error instead of hanging.
Where it stands, honestly. The M:N scheduler is built but gated behind
make mnwhile it clears a wider soak (and grows right-sized fiber stacks for the hundred-thousand-fiber tier), so the cooperative single-thread runtime is the default for now. Structured cancellation-on-failure already rides with it — a failing task tears its group down at the next yield seam. One limit remains across all three runtimes: channel communication is child-to-child, becausemaindrives the nursery’s join rather than sitting in arecvloop during it (the pattern above — workers talk inside the nursery,mainreads results after — is the idiom). Still on the list (Chapter 23):select/timeouts, and flipping the default to M:N.
Fireside trivia. The word “nursery” for a scoped task group comes from the “structured concurrency” movement of the late 2010s — the argument that concurrency should nest like blocks do, with a clear beginning and end, rather than spawning free-floating threads that wander off. The name is a metaphor: tasks are children, the nursery looks after them, and nobody leaves until everyone’s accounted for. It’s a much nicer image than “thread pool,” and a much nicer debugging experience too.