12. Channels and select

Two chapters have used channels without explaining them. That was deliberate — a channel is the obvious thing and the obvious thing is usually right — but the shelf is about to need the parts nobody guesses: what a channel is allowed to carry, what closing one means, and what happens when a task wants to wait on two things at once.

12.1 Typed channels

A channel is a typed, bounded queue between tasks. The shelf’s inbox takes documents from a writer and hands them to whoever is shelving:

struct Doc { title: str, words: int }
fn main() -> !int {
    let inbox = channel[Doc](4)
    var total = 0
    var seen = 0
    scope writers {
        writers.spawn(fn() {
            inbox.send(Doc { title: "regions", words: 900 })
            inbox.send(Doc { title: "moves", words: 640 })
            inbox.send(Doc { title: "channels", words: 1200 })
            inbox.close()
        })
        for d in inbox { total += d.words; seen += 1 }
    }
    print("{seen} documents shelved, {total} words")
    0
}
$ lupin inbox.lu
3 documents shelved, 2740 words

Four decisions are visible in channel[Doc](4) and the loop under it.

Typed. channel[Doc] carries documents and nothing else. There is no tagged union of message kinds to unpack, no interface{}, no cast at the receiving end.

Bounded. The 4 is capacity, and it is part of the program’s correctness argument rather than a tuning knob. A capacity of zero is a rendezvous: the sender waits for a receiver, hand to hand. A capacity of four lets the writer run four documents ahead. Either is a legitimate design; what neither is, is unbounded — a queue that grows without limit converts backpressure into memory exhaustion, which is a slower way of having the same problem.

for over a channel. The loop receives until the channel is drained-closed. A loop whose termination condition is somebody else’s promise is an unusual thing to write down, and close is how the promise is kept.

Close is the sender’s verb. The writer closes inbox because the writer is the only one who knows there is no fourth document. Delete that line and main’s loop waits forever for a value nobody will send — which is §10.2’s trap, arriving for the same reason.

What close means

Closing is not a fault and never becomes one. It changes what the two operations return:

struct Doc { title: str, words: int }
fn main() -> !int {
    let inbox = channel[Doc](2)
    inbox.send(Doc { title: "regions", words: 900 })
    inbox.close()
    inbox.send(Doc { title: "late", words: 1 }) else |err| {
        print("the inbox is closed: {err}")
    }
    let d = inbox.recv() else |_| { return 1 }
    print("drained {d.title}")
    let none = inbox.recv() else |err| { print("nothing left: {err}"); return 0 }
    print("{none.title}")
    0
}
$ lupin closed.lu
the inbox is closed: Closed
drained regions
nothing left: Closed

Read the three lines against the three operations. A send after close returns Closed — an ordinary error value in an ordinary row, handled with chapter 6’s else, and the document is not queued. A receive after close still drains what was already buffered, which is why regions arrives even though the close came first. And a receive on a drained-closed channel returns Closed as well, which is exactly what the for loop above was reading when it stopped.

Nothing in that paragraph is a trap, and the reason is worth stating: close is a normal part of a protocol, so it produces values rather than faults. What produces the fault is the absence of a close.

Happens-before, once

Here is the synchronization guarantee, in the one paragraph a practitioner needs. The k-th send on a channel happens before the k-th receive completes: everything the sending task wrote before that send is visible to the receiving task after that receive, with no fences, no volatile, and nothing to remember. On a rendezvous channel the edge also runs the other way — the receive happens before the send returns — which is what “hand to hand” means formally. Two more edges you have already used: a spawn happens before its child’s first action, and every action of a child happens before the join. The specification’s memory model is longer than this paragraph, and its own advice is that safe wolf code does not need the rest of it: with no unsafe and no FFI, every execution is sequentially consistent, and there is nothing to reason about beyond program order.

What a channel may carry

channel[Doc] works because a Doc is two words the machine copies. The general rule is the one chapter 10 stated as D14, and a channel is where it becomes a type check: a payload must be something that can cross a task boundary without leaving a second owner behind. Copied values qualify. Frozen data qualifies — the cold open of chapter 10 sent ints pulled out of a frozen snapshot, and could have sent the snapshot itself, because imm data crosses by reference with no copy and the sender keeps its own access. And a region qualifies, by moving:

struct Doc { title: str, words: int }
fn main() -> !int {
    let inbox = channel[region](1)
    var shelved = 0
    scope writers {
        writers.spawn(fn() {
            let batch = region()
            in batch {
                var docs = List[Doc]()
                (mut docs).push(Doc { title: "regions", words: 900 })
                (mut docs).push(Doc { title: "moves", words: 640 })
            }
            inbox.send(move batch)
        })
        let arrived = inbox.recv() else |_| { return 1 }
        shelved = 2
    }
    print("{shelved} documents crossed, nothing copied")
    0
}
$ lupin batch.lu
2 documents crossed, nothing copied

inbox.send(move batch) is chapter 8’s promise arriving. The whole region changes hands — two documents, a list, the strings inside them, however large the graph is — and the cost is the cost of moving one value, because a region is one owner and the send transfers the owner. No traversal, no serialization, no copy. The synchronization comes with it: every write made into batch before the send happens before every access the receiver makes, so the receiving task sees a complete object graph rather than a half-built one.

The word move in that line is doing chapter 7’s job, and it charges chapter 7’s price:

fn main() -> !int {
    let inbox = channel[region](1)
    let batch = region()
    let n = in batch { 2 }
    inbox.send(move batch)
    let again = in batch { 3 }
    print("{again}")
    0
}
$ lupin stale.lu
stale.lu: trap(use-after-move): `batch` was moved out and is uninitialized here [mem.tier0.move.2] at 153..158; `batch` moved here at 122..132
$ echo $?
3

That is [mem.tier0.move.2] — the same clause, the same trap kind, and the same diagnostic vocabulary chapter 7 taught on a struct field. A sending task that keeps using what it sent has made an ownership mistake, not a concurrency mistake, and the language declines to invent a new category for it. This is the whole reason “the racy program does not typecheck” is a claim about the memory model: the rules that make a send safe are the rules you already learned, pointed at a task boundary.

Chapter 16 is where region transfer becomes a design technique rather than a mechanism — moving a cyclic graph between failure domains, and choosing transfer or sharing by shape.

Exercise 12-1 (fingers · lupin) — A producer sends four squares and closes; main drains with a for loop. Type it, run it, then delete the ch.close() line and predict what the second run does before you try it.

Exercise 12-2 (extension (break-it-on-purpose) · lupin) — Using one task and one channel of capacity 1, write the shortest program you can whose second statement never finishes. Predict the trap kind and the roster before running.

12.2 select with timeouts

A task that can wait on only one thing at a time is a task that cannot have a deadline. select waits on several:

fn main() -> !int {
    let queries = channel[str](1)
    select {
        q from queries => { print("serving {q}") },
        timeout(5.ms) => { print("idle: no query inside the deadline") },
    }
    queries.send("regions")
    select {
        q from queries => { print("serving {q}") },
        timeout(5.ms) => { print("idle: no query inside the deadline") },
    }
    0
}
$ lupin deadline.lu
idle: no query inside the deadline
serving regions

Two identical selects, two different outcomes, and one sentence explains both: select runs the body of exactly one ready arm, and if no arm is ready it blocks until one becomes ready. The first select finds an empty channel, so the only arm that can become ready is the timer. The second finds a value waiting, takes it, and the timer never enters into it.

A timeout arm is therefore not a delay. It is the arm that wins when no other arm can, and reading it that way stops the two mistakes people make with it — thinking it bounds the body’s runtime, or thinking it fires even when work was available.

When both arms are ready

Give select two ready arms and it has a genuine choice to make:

fn main() -> !int {
    let fast = channel[str](1)
    let slow = channel[str](1)
    fast.send("regions")
    slow.send("moves")
    var served = ""
    select {
        q from fast => { served = q },
        q from slow => { served = q },
    }
    print("served {served}")
    0
}

Both served regions and served moves are conforming outcomes of that program. The choice among simultaneously-ready arms is pseudo-random and drawn from the scheduler’s seed, which is the load-bearing part of the clause: it is not the wall clock, not the arm’s position in the source, and not whichever channel happened to be touched first. It is a recorded decision, and a recorded decision can be replayed.

$ lupin fair.lu
served regions
$ lupin run fair.lu --seed=1
served moves
$ lupin run fair.lu --seed=2024
served regions

Those two seeded lines reproduce forever. That is the sentence to hold on to, because it inverts what “nondeterministic” usually costs you. Nondeterminism in wolf means the specification admits more than one outcome; any single run is as repeatable as arithmetic, and a failing run is a number you can put in a bug report.

The number is not the only handle. Ask the interpreter to enumerate the program’s schedules instead of picking one:

$ lupin conform-run fair.lu --explore=8
fair.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 2 distinct — SCHEDULE-DEPENDENT
    exit(0) ×1 stdout=served regions\n leaks=0 forest=ok — replay: --seed=0
      decision stream: ev:0
    exit(0) ×1 stdout=served moves\n leaks=0 forest=ok — replay: --seed=4611686018427387905
      decision stream: ev:1
  deadlocks: 0 · races: 0 · max depth: 1 decision(s)
$ echo $?
1

Read it as a report about your program rather than a test result. The budget of 8 was a ceiling: this program contains exactly one decision with two choices, so the frontier closed after two schedules and max depth: 1 decision(s) says so. Each outcome carries the seed that reproduces it. And the verdict — SCHEDULE-DEPENDENT — is a finding rather than a failure, which is why the exit code is 1 even though nothing went wrong: a program whose output depends on the schedule is something a test suite should be told about. §12.4 shows a program that earns the other verdict.

Chapter 17 turns this instrument on a bug and shows what hunting with it looks like. What matters here is smaller and it is a design rule: the seed exists so that your choice of what to observe is the thing under test. A program that prints arrival order will be schedule-dependent no matter how carefully it is written; a program that prints a sum will not.

The million idle connections

The question every completion-based I/O design has to answer is what an idle connection costs, and select is where wolf answers it. An arm waiting on a channel, a timer, or an I/O completion is not a task — it is an entry in the runtime’s wait set for the duration of the select. A task is what runs when an arm becomes ready. So the shape wolf’s concurrency is designed for is a task per active request rather than a task per connection, and a million idle connections hold a million registrations rather than a million stacks.

That is a claim about structure, and it is worth being precise about what it does and does not say. It says the design does not force a stack per idle connection. It does not say anything about throughput, latency, or how the registration is implemented — those are measurements, and Part 4 is where the book makes claims it can measure.

Exercise 12-3 (comprehension · lupin) — Two identical selects; between them, one send. Predict both printed lines:

fn main() -> !int {
    let a = channel[int](1)
    select {
        v from a => { print("got {v}") },
        timeout(5.ms) => { print("timed out") },
    }
    a.send(9)
    select {
        v from a => { print("got {v}") },
        timeout(5.ms) => { print("timed out") },
    }
    0
}

Exercise 12-4 (comprehension + schedule play · lupin) — Both channels are ready before the select runs. Write down every output this program is allowed to print, then run it under seed 1 and seed 2024:

fn main() -> !int {
    let a = channel[int](1)
    let b = channel[int](1)
    a.send(1)
    b.send(2)
    var got = 0
    select {
        v from a => { got = v },
        v from b => { got = v },
    }
    print("{got}")
    0
}

Exercise 12-5 (spelunking · lupin) — Run the explorer over 12-4 and read its report line by line. Why “2 schedule(s)” and not eight? What is a decision stream, and why does the tool exit 1 when nothing failed?

12.3 When channels are the wrong queue

A channel is the right answer often enough that it becomes a reflex, and the reflex is worth interrupting once. Here is a work list that a channel would serve, and should not:

fn main() -> !int {
    var work = List[int]()
    (mut work).push(900)
    (mut work).push(640)
    var total = 0
    while work.len > 0 {
        total += work[work.len - 1]
        (mut work).pop()
    }
    print("{total} words, one owner, no synchronization")
    0
}
$ lupin worklist.lu
1540 words, one owner, no synchronization

One task pushes and pops. A channel[int] would run this program too, and it would charge for two properties the program does not use.

The first is synchronization: a channel establishes a happens-before edge between two tasks at every send, and there is no second task here to establish it with. The second is blocking: a channel’s receive waits for a sender, and the only task that could send is the one waiting — which is exercise 12-2’s one-task deadlock, wearing work clothes.

There is a third cost and it is the one that outlives the program. Types are claims. channel[int] announces to every future reader that this list crosses a task boundary, and a reader who believes it will reason about interleavings that cannot happen. List[int] states the truth: one owner, no concurrency, and chapter 7’s rules are the whole story. Make the cheapest claim that is true.

Go’s community has measured the throughput side of this for years — a channel’s send and receive carry a lock and a scheduler interaction, and for a producer-consumer pattern with a cheap body that cost can dominate the work. The structural argument above is the one that decides most cases before a benchmark is needed: if the data does not cross a task boundary, a channel is buying you nothing and telling your readers something false.

The answer flips the moment a second task appears. When the work list is fed by a producer or drained by a pool, the channel’s two costs become exactly the two features required, and the refactor is chapter 11’s worker pool.

Exercise 12-6 (extension · lupin) — Build a router: one task reads an inbox and forwards each value to an evens or odds sink. main feeds 1 through 8 and then sums both sinks. Mind the closes: who closes what, in what order?

Exercise 12-7 (design) — A single task maintains a work list it alone pushes to and pops from. Argue why a channel is the wrong type for that list even though it would work, and name the two properties a channel charges for that this task does not use. When does the answer flip?

12.4 when (a, b)

Some state is genuinely shared. Two readers both count queries served and words returned, and the two numbers have to agree with each other:

fn main() -> !int {
    let served = Mutex(0)
    let words = Mutex(0)
    scope readers {
        readers.spawn(fn() { when (served, words) { served += 1; words += 900 } })
        readers.spawn(fn() { when (words, served) { words += 640; served += 1 } })
    }
    when (served, words) { print("{served} queries, {words} words") }
    0
}
$ lupin counters.lu
2 queries, 1540 words

Look at the two when lines before reading further, because in most languages you have found a bug. One task takes served then words; the other takes words then served. That is the AB-BA deadlock, the first thing any review looks for, and the reason codebases carry documents titled “lock ordering.”

It is not a bug here, and the spelling is why. when (a, b) acquires the whole set before the body runs, one object at a time, in a canonical order — a single total order over every synchronizing object in the process, which the runtime assigns and the program never writes. when (served, words) and when (words, served) therefore perform identical acquisitions. The order you wrote is documentation; the order the machine takes is the canonical one.

Which makes the deadlock argument short. A task blocked partway through acquiring a set holds only objects that come earlier in the canonical order than the one it is waiting for. Every task acquires in that same order. So there is no cycle to form: the classic lock-order deadlock is not detected, it is absent, and it is absent for the same reason a sorted list has no inversions.

The explorer will say so:

$ lupin conform-run counters.lu --explore=16
counters.lu: explored 2 schedule(s) in 2 execution(s) (DPOR; 0 slept, 0 pruned), frontier closed
  outcomes: 1 distinct — observably deterministic (every schedule agrees)
    exit(0) ×2 stdout=2 queries, 1540 words\n leaks=0 forest=ok — replay: --seed=0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)

observably deterministic (every schedule agrees) is the verdict §12.2’s program could not earn, and deadlocks: 0 is the claim of this section checked against every interleaving the reduction admits rather than argued on the page. Three decisions, two schedules, one outcome.

Why it is a construct and not a library

The guarantee above is a property of the only way to take a lock, and that is not something a library can arrange. A library gives you lock and unlock, and the moment a program can hold one lock and then ask for another, canonical order stops being an invariant and becomes a coding standard. Wolf declines to have the incremental form at all:

fn main() -> !int {
    let served = Mutex(0)
    let words = Mutex(0)
    when (served) {
        when (served, words) { words += 900 }
    }
    0
}
error[E0201]: `when` requires at least two operands — it acquires its whole set at once, so name every sync object the body touches in one `when` list
 --> ./s11.lu:7:19
  |
7 |     when (served) {
  |                   ^
  |
$ lupin nested.lu
nested.lu: E0201: `when` acquires a set, so it needs at least two operands; for one, call the method on the sync type [gram.expr.conc] at 75..88
$ echo $?
2

Both implementations refuse the program, with the same code and the same rule in two voices — the compiler at the parse rung, the interpreter before it runs a line. There is no one-operand when to nest, so “hold this while I take that” has no spelling to write it in. The bug is not caught. It is unsayable, which is a stronger guarantee than any detector, and it is bought with a grammar restriction rather than a runtime check.

Deadlock through a channel remains perfectly constructible — exercise 12-2 is four lines of it — and that asymmetry is deliberate. Waiting for data is a program’s own business, and a program that waits for data that never comes is wrong in a way no grammar can see. Acquiring locks piecemeal was never anything but a bug factory, so the grammar took it away.

Exercise 12-8 (comprehension · lupin) — Two tasks acquire the same two mutexes in opposite spellings. Predict the total, and predict what the explorer says about this program — then check both.

Exercise 12-9 (extension (break-it-on-purpose) · lupin) — Now construct the classic deadlock when was designed to kill: task one takes a then b, task two takes b then a, nested. Write it and report what actually happens — at what phase does this program die?

Where Part 3 has got to

Three chapters in, the vocabulary is complete enough to write a server. A scope owns tasks and joins them at a brace; a Scope parameter is how a function borrows one; a channel is a typed bounded queue whose close is a broadcast and whose payload rule is the memory model’s; a select waits on several things and takes a seed; and when acquires a set of locks in an order no program can get wrong.

Worth noticing what none of those did. Every concurrent thing in these three chapters was concurrent because you wrote spawn, and every one of them was doing a different job — one reader totalling words while another looked for the busiest document. That is concurrency: several jobs in flight, structured so that none of them outlives its brace. It is not the same subject as taking one job and dividing it, which is a question about a collection rather than about a scope, and it has its own vocabulary.