11. Scopes as values
Chapter 10 ended on a sentence with a consequence in it: what a function needs in order to spawn is not a keyword but a scope. A scope is an ordinary value, so it can be a parameter, and that one fact is the whole of this chapter.
11.1 The scope as a capability
The shelf wants a helper that starts a reader. Not a helper that starts a reader and waits for it — a helper that starts one and returns, leaving the task running:
struct Doc { title: str, words: int }
fn tally_into(s: Scope, out: channel[int], d: Doc) {
s.spawn(fn() { out.send(d.words) })
}
fn main() -> !int {
let out = channel[int](3)
var total = 0
scope readers {
tally_into(readers, out, Doc { title: "regions", words: 900 })
tally_into(readers, out, Doc { title: "moves", words: 640 })
tally_into(readers, out, Doc { title: "channels", words: 1200 })
}
for _ in 0..3 { total += out.recv() else |_| { return 1 } }
print("{total} words, three readers, one scope")
0
}
$ lupin serve.lu
2740 words, three readers, one scope
tally_into starts a task that outlives the call. That is a real
extension of a lifetime, and the language’s whole position on it is the
first parameter: you extended readers’s lifetime, readers is written
at the call site, and the brace those three tasks die at is nine lines
below in the same function. The pattern is Nathaniel J. Smith’s
“nursery as a capability”, credited, and it survives translation into a
language with modes because a Scope parameter is a mode-free ordinary
argument — nothing in that signature is a lifetime and nothing in it is
a promise about duration. It is a name for a brace.
Compare the two ways this could have gone. A function that takes no
scope and spawns internally is externally sequential: whatever it
starts, it has joined before it returns, and a caller need not know
concurrency happened. A function that takes a Scope is the honest
spelling of the other contract — work that outlives this call, dying at a
brace you own. Both are legitimate designs, and exercise 11-8 argues
them properly. What wolf has removed is the third option, which is the
function that starts work outliving its own return without saying so.
There is no signature for it.
The audit follows from the syntax, as it did for mutation. In chapter 7,
one search for (mut gave you a file’s complete mutation surface,
because a mode is written at both ends. Here, one search for Scope in
parameter lists plus one for scope blocks gives you a codebase’s
complete set of functions that can start a task — with no false
negatives, because spawn is a method on a value and a value has to come
from somewhere. Two capabilities, two searches, the same reason.
Exercise 11-1 (fingers · lupin) — A function cannot spawn unless
somebody hands it a scope. Write launch(s, ch, n) that spawns into a
caller’s scope, and a main that calls it three times inside one scope
block. The Scope parameter is the entire mechanism — nothing else in
the signature says “concurrent.”
Exercise 11-2 (comprehension · lupin) — Take 11-1 and change one
character: make the channel a rendezvous, channel[int](0). Predict
precisely what happens and why — the answer involves which side of the
scope’s closing brace the receives sit on.
Exercise 11-3 (comprehension · lupin) — Using only the text of 11-1’s program, answer: which functions in it are able to spawn tasks, and what single search over a large codebase would find every function with that ability? (Chapter 7 asked the same question about mutation.)
11.2 The background refresher
Here is the shape that makes people reach for a detached thread. The shelf’s published snapshot goes stale, so something has to rebuild it periodically — for as long as the shelf is serving, and not one moment longer:
fn refresh_into(s: Scope, stop: channel[int], log: channel[str]) {
s.spawn(fn() {
var rounds = 0
var running = true
while running {
select {
_ from stop => { running = false },
timeout(1.ms) => { rounds += 1; log.send("republished round {rounds}") },
}
}
log.send("the refresher left with {rounds} rounds behind it")
})
}
fn main() -> !int {
let stop = channel[int](1)
let log = channel[str](8)
scope shelf {
refresh_into(shelf, stop, log)
for _ in 0..2 { print(log.recv() else |_| { return 1 }) }
stop.close()
}
log.close()
for line in log { print(line) }
0
}
$ lupin refresher.lu
republished round 1
republished round 2
the refresher left with 2 rounds behind it
Four things in that program are worth naming, and none of them is a library.
The shutdown signal is a channel close. stop.close() is the only
message the refresher ever receives, and it receives it by having its
select arm become ready — a closed channel is permanently ready, which
is chapter 12’s rule and the reason this loop needs no flag shared
between two tasks. Closing is a broadcast to every receiver at once, and
it cannot be sent twice by accident.
The refresher’s lifetime is shelf’s. Not “until someone remembers
to stop it” — the brace after stop.close() will not complete while the
refresher lives, so if the close were removed the program would hang at
that brace rather than exiting with the task still running. Try it: the
failure mode of forgetting to shut a background task down is a deadlock
you find in the first test run, not a process that never exits in
production.
The log is drained after the join. log.close() is on the far side
of the brace, and it is correct there for §10.3’s reason: the join has
already proved that every send which will ever happen has happened, so
closing cannot cut anyone off.
select with a timeout arm is the periodic loop. There is no sleep
in this program. A sleeping task is a task that cannot be told anything;
a task blocked in a select with a timeout arm is doing the same waiting
and remains reachable, which is why the refresher notices the close
immediately rather than after its next interval.
The cost of this pattern, stated plainly: refresh_into takes a
parameter it would not need in a language with detached spawn, and every
call site carries it. That is the bill. What it buys is on the same
page — the answer to “can this program exit with work still running” is
readable from the braces, the refresher has an owner, and the shutdown
path is a line of code rather than a convention.
One shape this chapter deliberately does not build is the daemon: work whose lifetime is the process, supervised, restarted when it dies. That wants a failure domain rather than a scope, and chapter 15 gives it one.
Coming from Go: the honest comparison is with
context.Context, which is Go’s answer to the same problem and a good one. Actxthreads through call sites, carries cancellation, and is conventionally the first parameter — which is, structurally, the same admission aScopeparameter makes. Two differences are worth stating. Actxpropagates a cancellation request while the goroutine’s lifetime remains its own, soctx.Done()tells a goroutine it should stop and nothing makes it; a scope’s brace waits. And actxis a library convention the compiler does not know about, so forgetting to pass one compiles, while forgetting to pass aScopedoes not. Go’s design works and is used at enormous scale; the difference is where the enforcement lives.
Exercise 11-4 (extension · lupin) — Build a worker pool: three
workers share one jobs channel and one results channel; main feeds
six jobs and closes. Each worker is the same four lines. Why does the
pool need no “shut down workers” message?
Exercise 11-5 (comprehension + schedule play · lupin) — Shrink the pool to two workers and four jobs, and tag each result with the worker that produced it. Before running: is the assignment of jobs to workers part of the program, or part of the schedule? Run under seed 1 and seed 2024 and defend your answer with the outputs.
11.3 The structured dump
A task tree that the language guarantees is a task tree a tool can print. Build one in the interpreter and ask what exists:
wolf> let counted = channel[int](2)
wolf> fn tally(out: channel[int], n: int) { out.send(n) }
defined fn `tally`
wolf> scope shelf { shelf.spawn(fn() { tally(counted, 900) }); shelf.spawn(fn() { tally(counted, 640) }) }
wolf> :mem
regions:
#0 `program` arena state=open objects=0
tasks and procs:
proc#0 `program` root=task0 state=live
task#0 `main` Running
task#1 `task@37` Done
task#2 `task@80` Done
wolf> :quit
That is not a stack sample. Read it as a tree and every edge in it was
declared by the program: proc#0 is the process’s own failure domain
with task#0 at its root, and tasks 1 and 2 exist because a scope
block spawned them. Their state is recorded because the runtime is the
thing that changed it. The dump’s contents are the implementation’s
business and may grow; the dump’s existence is contract, which is a
distinction worth keeping in mind when you rely on one.
The lesson here is borrowed and worth crediting. Java’s structured
concurrency work (JEP 444 and its successors) made the same observation
about thread dumps: a dump of a thread pool is a list of stacks with no
relationships in it, because the relationships were never written down,
while a dump of structured tasks is a tree that tells you which work
belongs to which request. Wolf gets the tree for the same reason —
spawn needs a scope — and the payoff is the same. When something hangs,
“who was waiting for whom” is a question with a printed answer.
The names in the third and fourth lines are the machine’s, taken from the
spawn site. Names are part of the contract for this reason: a dump whose
entries are task@37 and task@80 tells you the shape, and a dump whose
entries say reader and refresher tells you the program.
Exercise 11-6 (spelunking · lupin REPL) — Turn on the trace and run a scope with two children, then read the scheduler’s own account of it. From the trace alone, reconstruct the task tree — which tasks exist, who owns them, and in what order they completed.
Exercise 11-7 (comprehension · lupin REPL) — In 11-6’s trace, find
every SchedDecision line and read its “picked 0 of N ready” suffix. At
which event did the scheduler actually have a choice, and what does that
tell you about how many different traces that one-line program could
produce?
Exercise 11-8 (design) — A library offers
fetch_all(urls: List[str]) -> List[Response] and wants to fetch
concurrently. Two candidate signatures:
fn fetch_all(urls: List[str]) -> List[Response]
fn fetch_all(s: Scope, urls: List[str]) -> List[Response]
The first hides an internal scope; the second borrows the caller’s. Argue for each: who controls cancellation and lifetime in each design, and which caller is each one honest to?