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, and runtime.NumGoroutine in 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, an errgroup from golang.org/x/sync, or a log.Println and a hope. All three work; all three are the program carrying its own error plumbing. errgroup is 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 to Wait on. 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.