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.
11.1 The scope as a capability
The shelf wants a helper that starts a reader and returns, leaving the task running, not one that starts a reader and waits for it:
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 argument: 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 specification puts it exactly there —
a scope handle is an ordinary value, and “lifetime extension is visible
at every call site” ([conc.task.scope]). The pattern is Nathaniel J.
Smith’s “nursery as a capability”, and it survives translation into a
language with modes because a scope handle is a mode-free ordinary
argument: nothing about it is a lifetime and nothing about it is a
promise about duration. It is a name for a brace.
The parameter’s type is Scope, the prelude’s name for a scope handle
([conc.proc.handle]), and it is the whole of what the signature says
about concurrency: this function is handed a brace, and whatever it
starts dies there. Nothing in tally_into’s body could spawn without
it, because spawn is a method on that value.
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 handle is the explicit 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: a function that starts work outliving its own return without saying so.
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. The capability to spawn is
written at both ends too: as a type in the signature and as a value at
the call. One search for : Scope gives you every function that can
start work in a caller’s brace, one for .spawn( every task start, and
one for scope every brace they die at, with no false negatives,
because spawn is a method on a value and the value has to come from a
scope block or from a caller who has one.
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 handed-over scope is the entire mechanism: nothing else in
the call 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 exactly as long as the shelf is serving:
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 are in that program, 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. 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, not after its next interval.
The cost of this pattern: refresh_into takes a
parameter it would not need in a language with detached spawn, and every
call site carries it. 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, not 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 comparison to make is with
context.Context, 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 a handed-over scope makes. There are two differences. 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 hand over the scope does not — the body has nothing to callspawnon. 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
Read it as a tree, not a stack sample, 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.
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? Say also what the second signature fails to tell a reader, and where they have to look instead.