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/await split 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 nursery block, 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 type T between 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 to f as a task in the enclosing nursery. (Using spawn outside a nursery is a compile error — there’d be nothing to scope it to.)
  • channel(N) — make a buffered channel of capacity N. 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 already closed is a runtime error — closing means “no more values are coming,” so close only once every send is done. recv(ch) — take the next value, as an Option<T>: Some(v) while values flow, None once the channel is closed and drained. That None is how a consumer loop knows to stop; on an open-but-empty channel it blocks until something arrives.
  • try_recv(ch)recv that never blocks: Some(v) if a value is queued right now, None if 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, recv returns None instead 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 mn while 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, because main drives the nursery’s join rather than sitting in a recv loop during it (the pattern above — workers talk inside the nursery, main reads 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.