10. Spawning is a scope
A Bruckner pause: every voice stops at once, and the silence is part of the score.
A scope exit joins all its children. Nothing outlives the bar line.
Chapter 8 closed with two sentences it said were the whole of wolf’s concurrency safety story: a closed region is a self-contained subtree of memory with exactly one owner, and data with no writers is data any number of readers can read at once. Here is the second of them, cashed. The shelf publishes a snapshot, two readers ask two different questions of it at the same time, and neither takes a lock:
struct Doc { title: str, words: int }
fn published() -> List[Doc] {
freeze region {
var docs = List[Doc]()
(mut docs).push(Doc { title: "regions", words: 900 })
(mut docs).push(Doc { title: "moves", words: 640 })
(mut docs).push(Doc { title: "channels", words: 1200 })
docs
}
}
fn main() -> !int {
let store = published()
let totals = channel[int](1)
let busiest = channel[str](1)
scope readers {
readers.spawn(fn() {
var sum = 0
for d in store { sum += d.words }
totals.send(sum)
})
readers.spawn(fn() {
var best = 0
var name = ""
for d in store {
if d.words > best { best = d.words; name = d.title }
}
busiest.send(name)
})
}
let n = totals.recv() else |_| { return 1 }
let t = busiest.recv() else |_| { return 1 }
print("{n} words in {store.len} documents, busiest {t}")
0
}
$ lupin served.lu
2740 words in 3 documents, busiest channels
Count what is not in that program. No thread handles, no join call, no
mutex around the store, no Send or Sync bound on Doc, no library
imported, and no way for either reader to still be running when print
executes. The last one is the subject of this chapter and it is
structural: the closing brace of scope readers does not complete until
both children have.
Two questions are worth separating before anything else, because
Part 3 answers them with different tools. What does this program mean
when more than one thing is happening? is a question about the
language, and the implementation that answers it is the reference
interpreter: lupin owns the scheduler, and — as §12.2 shows — owns a
seed you can hand it so that a run repeats exactly. What does this
program cost? is the compiler’s question, and Part 4 asks it. Almost
every prompt in this part is therefore lupin; where the compiler has
a verdict of its own to give, it gives it under its own prompt, as it
does before this section is over.
10.1 The task tree
A task is a unit of concurrent work. A scope is the block that owns one or more of them, and the two facts worth memorizing about the pair are both visible in five lines:
fn main() -> !int {
let answer = channel[int](1)
scope readers {
readers.spawn(fn() {
print("the reader is counting")
answer.send(900)
})
print("the scope is still open")
}
print("the scope has joined")
let n = answer.recv() else |_| { return 1 }
print("{n} words")
0
}
$ lupin join.lu
the scope is still open
the reader is counting
the scope has joined
900 words
The first fact: inside the braces, order is the scheduler’s business.
main reached its print before the child reached its own, and a
different schedule may print those two lines the other way round —
nothing in the program says which, and §12.2 is where that stops being
vague and becomes a seed.
The second fact: after the brace, order is not the scheduler’s business
at all. the scope has joined cannot print before the reader is counting, under any schedule, on any machine, because the brace is a
join — it waits for every task the scope spawned. That is one arrow
in and one arrow out, which is Nathaniel J. Smith’s black-box rule from
the nursery design that this construct descends from: a block of code
you can read as a single statement, whatever it started inside itself.
Read the shape of the arrows in the program above and the whole model
follows. readers is a value, spawn is a method on it, and there is
no other way to start a task — so a function that has no scope cannot
start one:
fn main() -> !int {
readers.spawn(fn() { print("nobody's child") })
0
}
error[E0301]: nothing named `readers` is in scope
--> ./s2.lu:5:5
|
5 | readers.spawn(fn() { print("nobody's child") })
| ^^^^^^^ not found
|
That is an ordinary name-resolution error, and its ordinariness is the
point. Wolf has no detached spawn — no go, no Thread::new, no
submit — so “who owns this task” is never a question you can decline
to answer. You answer it by naming a scope, and if you have no scope,
the program does not resolve.
What crosses back
The reader in that program computed 900 and the parent read 900, and the
route between them was answer — a channel, which chapter 12 takes
apart properly. It is worth knowing now why the route exists, because
the alternative is the first mistake everyone makes with a new
concurrency construct: reach out of the closure and write to a local.
Wolf’s rule is decision D14, and it is short. A task body may read
values it copies, and it may read imm data — which is why both readers
in the cold open touch store freely, and why neither needed
permission: published returned a frozen region’s contents, and frozen
data has no writers to race with. Anything else a task wants to share
travels one of three ways: it is moved, it is frozen, or it is wrapped
in a synchronizing type. Two tasks and one plain mutable local is not on
the list.
So the shape to build with is the one the cold open uses. A task computes; the answer leaves through a channel; the parent reads it after the join, when the arithmetic on the previous page says there is nothing left to wait for. Chapter 12 gives that sentence its vocabulary — capacity, close, and what a channel is allowed to carry — and §12.4 gives the synchronizing type its keyword.
Exercise 10-1 (fingers · lupin) — Type and run your first scope:
two children each send a number into a channel, and main adds what it
receives after the scope closes. Then swap the two spawn lines and run
again. What changed?
Exercise 10-2 (comprehension · lupin) — Predict the order of the two lines, then say what enforces it — the scheduler, or something stronger:
fn main() -> !int {
scope s {
s.spawn(fn() { print("child speaks") })
}
print("main speaks")
0
}
Exercise 10-3 (comprehension · lupin) — A child’s last expression is a value. Predict this program’s exit code, and account for the 42:
fn main() -> !int {
scope s {
s.spawn(fn() { 42 })
}
7
}
10.2 The leaked goroutine, retired
Here is the oldest bug in structured-less concurrency, ported to the shelf. A reader is spawned to serve a query. The query never arrives.
fn main() -> !int {
let queries = channel[str](0)
scope readers {
readers.spawn(fn() {
let q = queries.recv() else |_| { return 0 }
print("serving {q}")
})
}
print("every query answered")
0
}
$ lupin leak.lu
leak.lu: trap(deadlock): every live task is blocked at a runtime-owned blocking point and no timer is pending; blocked-task roster: `main` (task 0), `task@82` (task 1) [conc.deadlock.trap] at 58..209
$ echo $?
3
Read the trap clause by clause, because every phrase in it is load-bearing.
“every live task is blocked at a runtime-owned blocking point.” The
blocking points are a closed set — channel send and receive, select,
acquiring a synchronizing type, waiting on I/O or a timer, and an
explicit checkpoint() — and being closed is what makes the condition
decidable. The runtime knows where every task is parked because it
parked them.
“and no timer is pending.” A timeout arm somewhere would
eventually fire and wake somebody, so its absence is part of the proof
rather than decoration. §12.2 is where timers get their arm.
“blocked-task roster.” Two entries: the child, blocked in recv,
and main, blocked at the join. main is on the list because waiting
for your children is waiting, and a deadlock is made of exactly that.
This roster is the list you would otherwise reconstruct by hand from a
hung process’s stacks.
Now the comparison the section is named for.
Coming from Go: this is the goroutine leak, and Go’s own documentation is the honest source for it. A goroutine’s exit synchronizes with nothing: the memory model says so,
go f()returns immediately, and a goroutine blocked forever on a channel nobody sends to is a live object with a stack, invisible to the function that started it and to the function that started that. The Go community measures the cost rather than denying it — the proposal to put goroutine-leak detection into the standard testing package (golang/go issue 74609) exists because the leak is common enough to want a detector, andruntime.NumGoroutinein a test teardown is the folk version. Wolf’s version of the same program is above. The leak is not detected by a tool; it has no spelling. A task belongs to a scope, the scope’s brace waits, and “the function returned while its goroutine lingers on” is not a state this language has. What you get instead is the program above: a defined outcome, with a roster, at exit 3.
The honest half of that trade is on the page too. Wolf did not make the mistake impossible — the program still hangs on a query that never comes, and it is still a bug. What changed is the class of the bug. A silent leak grows until a process dies of memory a week later; a deadlock is a fault, at a line, with the list of who was waiting, and the same list every time you run it.
Exercise 10-4 (extension (break-it-on-purpose) · lupin) — Port Go’s classic leak: spawn a receiver on a channel that nobody will ever send to. In Go the goroutine outlives the function, silently, forever. Write the wolf version and predict what happens instead — and at which line.
Exercise 10-5 (spelunking · lupin) — Read exercise 10-4’s trap
line clause by clause. What does “no timer is pending” rule out, what
is the “blocked-task roster” for, and why does the trap name main
itself as blocked?
10.3 The dropped error, surfaced
A task can fail. Two readers ask the shelf for a word count and one of the titles is not on the shelf:
fn words_of(title: str) -> int ! {Unknown} {
if title == "regions" { return 900 }
Unknown
}
fn totals() -> !int {
let answer = channel[int](2)
scope readers {
readers.spawn(fn() { answer.send(words_of("regions")?) })
readers.spawn(fn() { answer.send(words_of("sonata")?) })
}
0
}
fn main() -> !int {
let r = totals() else |err| { print("the join raised {err}"); 7 }
print("{r}")
0
}
$ lupin raise.lu
the join raised Unknown
7
Nothing in totals handles an error, and yet the error is handled. Walk
the route: the failing child’s ? raises Unknown inside the task, the
task ends with that error as its outcome, the scope’s closing brace
collects it, and the brace re-raises it into totals’s own row.
From there it is chapter 6’s machinery with no adaptation at all —
main writes else |err| and reads the row.
The crossing point is the brace. That is the sentence to keep, because it is what makes concurrent failure ordinary: the place where a child’s error becomes the parent’s problem is a syntactic location you can put your finger on, and it is the same location that does the joining. A scope that joins is a scope that can report.
Three details follow from the same clause and are worth stating plainly. A child’s success value is discarded at the join — a scope waits for completion, not for results, which is why the reader in §10.1 sent its 900 through a channel instead of returning it. A child’s failure cancels its siblings, which is §10.4. And if more than one child fails, one error surfaces and the rest attach to it as context; which one surfaces is a scheduling decision, recorded like every other.
Coming from Go: the contrast here is not about style. In Go,
go func() { ... }()has nowhere to put an error: the function has no caller to return to and no signature to return through, so the conventional answers are a second channel for errors, anerrgroupfromgolang.org/x/sync, or alog.Printlnand a hope. All three work; all three are the program carrying its own error plumbing.errgroupis the closest relative of the code above, and comparing them fairly is instructive — it gives you the first error and cancels the rest, which is the same policy — with the difference that it is a type you must remember to reach for, and a group you must remember toWaiton. Wolf’s version is the brace you already wrote.
Exercise 10-6 (comprehension · lupin) — Three children compute
through ?; one of them fails. Predict both printed lines, and name the
exact point in the source where the error crosses from child to parent.
Exercise 10-7 (extension · lupin) — Change 10-6 so no child fails
(use 1, 2, and 4), then finish the job: close the channel, drain it, and
return the sum. Why is it correct to close only after the scope’s
closing brace — what has the join already proved by then?
10.4 Cancellation
One reader is blocked on a query that will never come. Its sibling fails immediately. The interesting question is not whether the blocked reader dies — the scope cannot join until it does — but whether it gets to clean up on the way out:
fn refuse() -> !int { Overloaded }
fn serve() -> !int {
let queries = channel[str](0)
scope readers {
readers.spawn(fn() {
defer print("the reader released its cursor")
let q = queries.recv()?
print("serving {q}")
0
})
readers.spawn(fn() { refuse() })
}
0
}
fn main() -> !int {
let r = serve() else |err| { print("the scope raised {err}"); 7 }
print("{r}")
0
}
$ lupin cancel.lu
the reader released its cursor
the scope raised Overloaded
7
It does. Cancellation is cooperative and polite: it is delivered at a
blocking point, it arrives at the blocked task as an ordinary error
value, and the task returns through its own frames — which runs its own
defers, in chapter 4’s order, for chapter 4’s reasons. There is no
unwinding mechanism anywhere in this, because there is no unwinding
anywhere in wolf: a cancelled recv hands back an error, the ? on it
returns, and returning is what runs a defer.
Cooperative has a consequence, and it is the one to hold on to: a task
is cancelled at a blocking point, not between two arithmetic
instructions. A child grinding through a long computation with no
channel operation in it finishes that computation. This is a deliberate
trade — the alternative is asynchronous interruption, which is how
other runtimes acquired the rule that you may not allocate in a signal
handler — and the cost is stated rather than hidden: cancellation
latency is bounded by the distance to your next blocking point, and
checkpoint() exists for the loop that has none.
The blocking points are the closed set §10.2 listed, and one boundary in that list is worth naming here because chapter 9 built it. A task inside a C call is not interrupted, inspected, or migrated — C frames are never unwound, and the runtime does nothing to a task in foreign code until the call returns. Cancellation reaches it at the first blocking point after that. The membrane holds in both directions: chapter 9 kept C from reaching into wolf’s memory model, and this clause keeps wolf’s scheduler from reaching into C’s stack.
There is a second, harsher rule about cleanup in this language, and it belongs to a different unit of failure. Cancellation runs a task’s defers; killing a proc deliberately does not, freeing its regions wholesale instead. Chapter 14 is where procs and that rule live, and the difference between the two is a decided thing rather than an accident.
Exercise 10-8 (comprehension · lupin) — One sibling blocks
forever; the other fails immediately. Predict all the output, and answer
the pointed part first: does the blocked sibling’s defer run?
What a task costs
A task is not a coroutine and not a promise. It is work the runtime schedules onto OS threads, and three consequences of that are contract rather than implementation detail.
A blocked task holds its thread. There is no stack-switching sleight of
hand at a recv, which is what lets a task’s stack be an ordinary
stack, its defers be ordinary returns, and a debugger’s view of it be
the truth. What the runtime may do about a pool of threads with blocked
members — grow it, shrink it, compensate — is unobservable, and
therefore not something a program may depend on or be surprised by.
Spawning confers no ordering beyond the two edges §10.1 showed: the spawn happens before the child’s first action, and every action of the child happens before the join. Any interleaving consistent with those two edges is a conforming execution, which is the licence §12.2 turns into a testing tool rather than a hazard.
And there is no function coloring. fn is fn: nothing in a signature
anywhere in this chapter said “concurrent”, no caller had to be
rewritten to call a callee that spawns, and the reader in the cold open
was an ordinary closure over ordinary values. What a function needs in
order to spawn is not a keyword. It is a scope — which is the next
chapter, because a scope is a value, and values can be passed.
Exercise 10-9 (extension · lupin) — Build a two-stage pipeline: a
producer sends 1 through 5 into raw; a transformer squares each into
squared; main counts what arrives. Each stage closes the channel it
sends on, when its input runs dry. Run it under two seeds. Then answer:
which task must close squared, and what goes wrong if main tries to?
Exercise 10-10 (design) — Go has go f(); wolf deliberately has
no detached spawn — a task needs a scope, and the scope must close. Take
the other side seriously: name a real program shape that detached spawn
serves well, sketch how wolf expresses it, and state what the wolf
version pays and what it collects.