13. Dividing one job

Chapter 12 closed by naming a subject it had not covered. Everything concurrent in the three chapters behind us was concurrent because two different jobs were in flight — one reader totalling words while another looked for the busiest document. Cutting a single job into pieces is a different question, and it begins in the same place every time: the pieces have to add up somewhere.

The shelf’s word count is one job. Its loop runs on one core, and the obvious way to spread it is to give the loop to a reader:

struct Doc { title: str, words: int }

fn main() -> !int {
    let store = freeze region {
        var docs = List[Doc]()
        (mut docs).push(Doc { title: "regions", words: 900 })
        (mut docs).push(Doc { title: "moves", words: 640 })
        docs
    }
    var total = 0
    scope readers {
        readers.spawn(fn() { for d in store { total += d.words } })
    }
    print("{total} words")
    0
}

That program does not run, in either implementation, and the rest of this chapter is why.

13.2 The race that does not compile

Here is the compiler on it:

error[E1101]: this task writes to `total`, which it captures from the enclosing function
  --> ./s1.lu:15:47
   |
15 |         readers.spawn(fn() { for d in store { total += d.words } })
   |         ----------------------------------------------------------- the task's closure captures it at this spawn
   |                                               ^^^^^ tasks cannot mutate captured state
   |
   = note: task captures are copies, `imm` shares, or region moves (D14) — never mutable windows
     onto the parent's locals; two tasks writing one binding is the data race the memory
     model forbids. Three ways out: send results over a `channel` and let one owner mutate;
     guard truly shared state with a `Mutex` acquired in a `when` block; or, for loop-shaped
     work, use `par` with a reduction.

warning[W1101]: this write to `total` stays inside the task
  --> ./s1.lu:15:47
   |
15 |         readers.spawn(fn() { for d in store { total += d.words } })
   |                       -------------------------------------------- the closure captured it at spawn
   |                                               ^^^^^ lands on the task's own copy
   |
   = note: task captures copy (or move); the enclosing binding never sees this assignment. Send the
     result over a channel, or return it through the scope's join.

And here is the interpreter, which does not run it either:

$ lupin divide.lu
divide.lu: E1101: this task writes to `total`, which it captures from the enclosing function: unsynchronized mutable capture across tasks (D14 — copy, share `imm`, or `move`; a `sync` type mediates shared writes) [conc.task.spawn] at 347..352
$ echo $?
2

Two tools, one code, one span — 347..352 is the same five bytes the compiler underlines. This is the part of the book where the differential stops being an argument and becomes a boring fact: the specification says total may not be written from a task, and both readings of the specification say so before the program starts, in the same words about the same bytes. Chapter 3 set the pattern as two tools, one rule, and two enforcement moments — the compiler proving it before the program starts, the interpreter catching it in the act. Here the two moments collapse into one, which is what a rule looks like when both readings have finished arguing about it.

Now read where the error points, because it is not where you would put it. There is one task in that program. One task cannot race with anything — there is nothing to interleave with, and the sum it computes would be arithmetically correct. The compiler rejects it anyway, at the capture, and the phrase it uses is tasks cannot mutate captured state.

That is the rule chapter 10 stated positively and this chapter cashes. D14 gives a task body three ways to touch anything it did not create: it copies, it reads imm data, or it takes a region by move. A mutable window onto the parent’s local is not on the list, and the check does not wait to see how many tasks want one. Wolf is not detecting your race. It is declining to compile the shape a race is made of, and the shape has one instance in it.

The consequence is worth stating plainly, because it is the pitch. Add the second reader and you have written the classic lost-update bug — two tasks, one accumulator, an answer that depends on timing. You will never see it. The program that would have contained it does not build, and it did not build back when it was still a one-task program that worked. Exercise 13-3 is the two-task version, and part of what it asks is how much the two tools say about it.

W1101 is the same fact from the other side, and it is what makes the rejection teachable rather than merely correct. The closure captured total by value, so if the write were allowed to stand it would land on the task’s private copy and the parent’s total would print 0 — not a race at all, a silently wrong answer. The error says the shape is forbidden; the warning says what the shape would have done. Read them together and you know both that you may not write it and why you did not want to.

The two answers this program has

The note ends with three ways out. Here are the two that answer the program above, and you have already met both.

The first is a channel, which is the route §10.1 said existed and left to chapter 12 to build. Each reader computes its piece and sends it; one owner — main, after the join — does the adding:

struct Doc { title: str, words: int }

fn main() -> !int {
    let store = freeze region {
        var docs = List[Doc]()
        (mut docs).push(Doc { title: "regions", words: 900 })
        (mut docs).push(Doc { title: "moves", words: 640 })
        docs
    }
    let parts = channel[int](2)
    scope readers {
        readers.spawn(fn() { parts.send(store[0].words) })
        readers.spawn(fn() { parts.send(store[1].words) })
    }
    var total = 0
    for _ in 0..2 { total += parts.recv() else |_| { return 1 } }
    print("{total} words")
    0
}
$ lupin fanout.lu
1540 words

The accumulator did not disappear; it moved. total is still a plain var and it is still added to twice, but both additions now happen in one task, after the brace that guarantees the other two are finished. Nothing is guarded because nothing is shared: the readers own their partial sums, the channel carries copies, and main owns the total. This is the shape to reach for by default, and the reason is that it has no critical section to get wrong.

The explorer agrees, over every interleaving it can reach:

$ lupin conform-run fanout.lu --explore=16
fanout.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=1540 words\n leaks=0 forest=ok — replay: --seed=0
  deadlocks: 0 · races: 0 · max depth: 3 decision(s)

outcomes: 1 distinct is §17.2’s verdict arriving early: two schedules exist, both were run, and the program has one answer. Addition commutes, so the order the two pieces arrive in cannot be observed — which is a property of the operation you combined them with, not of the language, and is the first thing to check when a divided job starts giving two answers.

The second way out is for state that is genuinely shared, where no amount of rearranging makes one owner enough. Two numbers that must agree with each other are the honest case, and §12.4 built the instrument:

fn main() -> !int {
    let words = Mutex(0)
    let docs = Mutex(0)
    scope readers {
        readers.spawn(fn() { when (words, docs) { words += 900; docs += 1 } })
        readers.spawn(fn() { when (docs, words) { words += 640; docs += 1 } })
    }
    when (words, docs) { print("{words} words in {docs} documents") }
    0
}
$ lupin guarded.lu
1540 words in 2 documents

Two things about that program are the point. The two when lines name their operands in opposite orders and it does not matter, for the reason §12.4 gave: the set is acquired whole, in a canonical order the program never writes. And when needs a set — two operands, minimum, which is why this example guards two counters rather than one. That is not a limitation to work around. A single number under a lock is a job for the program above: one owner and a channel, with no lock at all.

The same law, one boundary over

The rule the capture check enforces is not really about closures. It is about what may exist in two places at once, and a channel is the other place a value can go:

struct Doc { title: str, words: int }

fn main() -> !int {
    let inbox = channel[List[Doc]](1)
    0
}
error[E1102]: `List[Doc]` cannot be sent through a channel
 --> ./s4.lu:7:25
  |
7 |     let inbox = channel[List[Doc]](1)
  |                         ^^^^^^^^^ not a sendable payload type
  |
  = note: channel payloads must be `Copy` data, `imm` data, a region value (the send is its affine
    move), or a `sync` type ([conc.chan.type]) — sending anything else would give two tasks
    one mutable value. D14's verbs are the ways out: `move` the data into a region and send
    the region, `freeze` it into shareable `imm` data, or guard it with a `Mutex`.
$ lupin inbox.lu
inbox.lu: E1102: `List[Doc]` cannot be sent through a channel: a payload must be `Copy`, `imm`, a moved region, or a `sync` type — a bare region-interior `List` is none of those. Send the region instead [conc.chan.type] at 83..92
$ echo $?
2

Same two tools, same agreement, same clause vocabulary — and notice that no send appears in the program. The channel’s type is the claim, so the declaration is where the claim is checked, which means a sendability mistake is found at the line where you designed the queue rather than at the line where you finally used it.

Read the four admitted classes back slowly. Copy data, imm data, a region that moves, and a sync type: that is D14’s list again, the one §10.1 gave for what a task body may touch and §12.1 taught three of by showing them work. Two error codes, two boundaries, one rule — and the same note under both, offering the same three ways out. A language that had written this twice would have left you a way to smuggle a mutable value across one boundary by declaring it at the other.

Exercise 13-2 (fingers · lupin) — Nine numbers, squared and summed, on one core: build the list, square each into a second list, add them up, print the total. Run it and keep the program. The number it prints is the number every divided version of this job has to agree with, and a divided job that does not reproduce its sequential answer is not faster — it is wrong.

Exercise 13-3 (comprehension · wolf + lupin) — Two tasks increment a captured var:

fn main() -> !int {
    var hits = 0
    scope s {
        s.spawn(fn() { hits += 1 })
        s.spawn(fn() { hits += 1 })
    }
    hits
}

Before running it, predict the three fixes the note offers and which two apply here. Then run it under both tools and account for the difference in what they print — the codes and the spans agree, and the amount of output does not. Which tool tells you about the second spawn, and what does the extra warning on it say that W1101 did not?

Exercise 13-4 (spelunking · lupin) — Two programs differ in one print. Explore both and read the verdicts: ex13-4a.lu prints the arrival order and the sum, ex13-4b.lu prints only the sum, and one of them is SCHEDULE-DEPENDENT. Same tasks, same channel, same schedules. Why do the verdicts differ, and what does that mean for how you design a divided job’s output?

Where this leaves the shelf

The count is divided and the answer is still 1540. What made that uneventful was not care; it was that the two shapes available to write it in are both shapes the machine can check. A task that touches only what it copies, reads only what is frozen, and sends its answer home needs no reasoning about ordering at all, and the explorer will say so in one line. A task that must share needs the whole lock set at once, and there is no spelling for taking them one at a time.

What you cannot do is the third thing: reach out of the closure and add to the parent’s variable. C and Go both let you write that and find out later — Go’s race detector will tell you about it in a run that happens to hit it, which is a different offer from being unable to write it. Rust refuses it too, and refuses it earlier than its reputation suggests: Send and the borrow checker close the same hole with different machinery, and wolf’s claim here is not that it caught something Rust misses. The claim is narrower and it is about the diagnostic. The accumulator in the closure is not a bug this book teaches you to avoid. It is a program that does not build, in both implementations, with a note that names the three shapes you meant instead.

Exercise 13-5 (extension · lupin) — grep, wolfished: write grep(text, pattern) -> List[str] ! {EmptyPattern} returning the matching lines. Substring search is yours to write with byte slices. Why is the empty pattern an error here, when POSIX grep happily matches it everywhere?

Exercise 13-7 (comprehension · lupin) — One Euler step for two bodies on a line, gravity only, equal masses. Before running: what is v1 + v2 after the step, and is your answer exact or approximate for f64 arithmetic?