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.