16. Region transfer: fearless messaging
Chapter 12 sent a region through a channel and said that chapter 16 was where the trick became a technique. Two questions were left open. The first is how big the graph is allowed to be, and the answer is the one chapter 8 gave about cycles: it does not matter, because the region is the unit and the region moves whole.
Here is the ring from §8.4 — three documents, each pointing at the one after it and the one before it, the ends met — handed to a proc that never allocated any of it:
struct Doc { title: str, words: int, newer: handle Doc, older: handle Doc }
fn walker(inbox: channel[region], out: channel[str], docs: Pool[Doc], head: handle Doc) -> int {
let store = inbox.recv() else |_| { return 1 }
var cur = head
var line = ""
in store {
for _ in 0..4 { line = "{line}{docs[cur].title} "; cur = docs[cur].newer }
}
out.send(line)
0
}
fn main() -> !int {
let inbox = channel[region](1)
let out = channel[str](1)
let store = region(pool(Doc))
var docs = in store { Pool[Doc]() }
var hs = in store {
var h = List[handle Doc]()
for _ in 0..3 { (mut h).push((mut docs).reserve()) }
h
}
in store {
var titles = List[str]()
(mut titles).push("regions")
(mut titles).push("moves")
(mut titles).push("errors")
for i in 0..3 {
(mut docs).init(hs[i], Doc {
title: copy titles[i],
words: (i + 1) * 100,
newer: hs[(i + 1) % 3],
older: hs[(i + 2) % 3],
})
}
}
let w = spawn proc walker(inbox, out, docs, hs[0])
inbox.send(move store)
print(out.recv() else |_| { return 1 })
0
}
$ lupin ring.lu
regions moves errors regions
16.1 ch.send(move r)
Four documents printed from a three-document ring, which is how you know the walk went round. Everything interesting about that program is in one line:
inbox.send(move store)
The region changes owner. Not the documents — the region, one value, and with it every object inside it, every string inside those, and every edge between them, including the two that point backwards and the one that closes the loop. The send moves one word. There is no traversal to serialize the graph, no pass to fix up pointers on the other side, and no step whose cost depends on how many documents the shelf holds. A million documents and three move at the same price, because the price is the region’s.
The safety argument is equally short, and it is not new. A region has one
owner (§8.3). move transfers the owner (chapter 7). After the send, the
sender’s store is empty and the language enforces that with the trap
chapter 12 printed — use-after-move, citing [mem.tier0.move.2], the
same clause as a moved string field. So at every instant exactly one
proc can reach the graph, and “who else is touching this?” is not a
question the program can ask, because it has no way to spell a second
reacher.
Notice what crosses besides the region. docs and hs[0] are
parameters of walker: a pool and a handle, which are an offset table
and an index-plus-generation. They are Copy-shaped values, which is
why they cross a signature under the rule §12.1 gave for channel
payloads, and they are meaningless without the region — a handle into a
region you do not own resolves to nothing. That is the shape of a moved
graph in wolf: the addresses are indices, so they survive the move, and
the memory they index arrives under new ownership in the same message.
Two procs, one word
The receiver above is a proc rather than a task, and the substitution
cost nothing: spawn proc walker(...) where chapter 12 wrote
spawn(fn() { ... }). That is worth pausing on, because it is the claim
the whole part has been building toward. A failure domain boundary and a
task boundary are the same boundary as far as data is concerned. The
rules that make a send safe are the memory model’s, they were fixed in
Part 2, and adding fault isolation on top of them required no new rule
about data at all.
Which means a design can move procs around late. Start with tasks in one scope because the work is a pipeline; discover that one stage corrupts its own state and should die alone; promote that stage to a proc. The data path does not change, the channel does not change, and the payload rule does not change.
When inference merges what you know is disjoint
One honest cost of a granule this size. Region inference (§8.1) puts allocations in the region the surrounding code is using, and it is conservative: two collections built in the same window live in the same region, so moving one moves both, and the send that should have handed over a batch hands over the batch and the scratch space it was built with.
The ladder out of that has three rungs and you should climb them in order.
Restructure. Build the thing you are going to send in its own
window. let batch = region() and in batch { … } around the part that
travels is not a workaround; it is the same act as choosing which
variables are locals of which function, and it reads that way afterwards.
Most merges dissolve here.
Annotate. When the two lifetimes genuinely interleave in one function, name both regions and open them explicitly — §8.6’s two open windows, checked disjoint by the compiler, with the border rule doing the policing. The cost is two names and a brace; the benefit is that the disjointness is now a claim the compiler is holding you to rather than a comment.
Reach for unsafe. Last, rarely, and with chapter 9’s discipline:
one door, a # Safety note that says which region the pointer belongs
to, and an audit surface that a reviewer can grep. If you find yourself
here for a messaging problem, the shape is almost always wrong — the
first two rungs handle the cases that arise from real programs, and this
one is for the cases that arise from libraries.
Exercise 16-1 (comprehension · lupin) — The sender builds a list
inside a region — two pushes — and sends the region. Predict: does the
receiver’s in r2 { … } block run before or after both pushes are
visible, and what synchronization made that true?
fn main() -> !int {
let ch = channel[region](1)
var got = 0
scope s {
s.spawn(fn() {
let r = region()
let xs = in r {
var v = List[int]()
(mut v).push(41)
(mut v).push(1)
v
}
ch.send(move r)
})
let r2 = ch.recv() else |_| { return 1 }
got = in r2 { 42 }
}
print("received {got}")
0
}
Exercise 16-2 (fingers · lupin) — Make the transfer carry real
freight: build a two-element list in main, send the region to a
receiving task, and have the receiver sum the list it never built.
Print the sum from the receiver’s side.
Exercise 16-3 (extension (break-it-on-purpose) · lupin) — Construct
the smallest program in which a sender touches a region after sending
it with move. Predict the exact trap kind before running — it is one
you met in chapter 7, not a new one.
16.2 Freeze, then share
move is the wrong verb when the answer is “everybody, forever.” Two
shards both want the published shelf, neither writes to it, and there is
nothing to hand over:
struct Doc { title: str, words: int }
fn reader(store: List[Doc], out: channel[int]) -> int {
var sum = 0
for d in store { sum += d.words }
out.send(sum)
0
}
fn main() -> !int {
let out = channel[int](2)
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 a = spawn proc reader(store, out)
let b = spawn proc reader(store, out)
let x = out.recv() else |_| { return 1 }
let y = out.recv() else |_| { return 1 }
print("two procs read one snapshot: {x} and {y}")
0
}
$ lupin published.lu
two procs read one snapshot: 1540 and 1540
One shelf, two failure domains reading it at once, no lock, and no copy:
store crossed both signatures by reference because it is imm, and
imm data has no writers to race with. That is §8.5’s cadence doing
concurrency work — freeze was irreversible for memory-model reasons in
Part 2, and irreversibility is exactly what makes it safe to hand to a
proc that might die while reading, because there is nothing a dying
reader can leave half-done.
The two verbs divide the world cleanly enough to make a decision table that fits in four lines:
shape of the data verb what the receiver gets
one consumer, will mutate it move the only reference there is
many readers, nobody writes freeze a reference, no copy, no lock
many readers, rebuilt periodically freeze a new edition each rebuild
two writers neither redesign: one owner per datum
The third row is the one people miss. freeze does not mean forever —
it means immutable for the life of that region. A routing table rebuilt
every thirty seconds is built mutable in a fresh region, frozen, and
published; readers holding the old edition keep reading it until they
pick up the new one, and the old region dies when its last reader lets
go. Immutable-per-edition is the pattern; there is no need for a lock at
any point in it, and no reader ever sees a half-built table.
The fourth row is not a gap. Two writers to one datum is a design with
no owner for the datum, and the language’s answer is to make you pick
one: give it one owner and send messages to it (§14.3’s
mailbox), or split it so each writer owns a piece. Mutex and when
(§12.4) exist for the residue where genuinely shared mutable state is the
right answer, and the residue is smaller than most codebases assume.
Exercise 16-4 (comprehension · lupin) — Ten squares are frozen into
table; two tasks each read one entry and send it back. Predict the
printed number, and answer precisely: how many copies of the table exist
while both tasks read it?
Exercise 16-5 (design) — For each payload, choose move or
freeze and defend it in one sentence: (a) a parsed configuration read by
every worker for the process lifetime; (b) a request’s parse tree handed
from parser to executor; (c) a routing table rebuilt every 30 seconds and
read constantly; (d) a 2 GB index segment consulted by eight shards.
16.3 The honest lineup
Four languages, one workload: a producer builds a graph, a consumer walks it. Each of these designs is in production at a scale wolf has not approached, and each pays for message-passing safety at a different moment. The comparison is about when the bill arrives.
Erlang copies. A send deep-copies the message into the receiver’s
heap, so the receiver reads private memory and no coordination exists to
get wrong. The bill arrives at the send, in full, proportional to the
size of the graph. For small messages it is invisible; for a
million-node tree it is the program’s dominant cost, and the standard
remedy — a binary large enough to be reference-counted instead of
copied — is a special case in the runtime rather than a general answer.
What the copy buys is worth stating plainly, because it is something no
move can: the same send works to a process on another machine, with the
same semantics. Erlang bought distribution, and paid for it here.
Go shares. A channel of pointers costs one word, exactly as wolf’s region does, and nothing in the type system says the sender has stopped writing. The convention is that it has; the enforcement is the race detector, which finds what the test happens to execute. A multiword value written while another goroutine reads it can also tear — Go’s memory model documents this — so the failure mode is not only stale data but data that never existed. Go’s answer is discipline and tooling, and it works at enormous scale, with the caveat that “we forgot the convention here” is a bug class rather than a compile error.
Rust locks. The safe spelling for a shared mutable graph is an
Arc<Mutex<T>>, and it is real code that compiles:
pub fn shared_mutable(docs: Vec<Doc>) -> u32 {
let shelf = Arc::new(Mutex::new(docs));
let mut handles = Vec::new();
for _ in 0..2 {
let shelf = Arc::clone(&shelf);
handles.push(thread::spawn(move || {
let guard = shelf.lock().expect("the shelf's lock is poisoned");
guard.iter().map(|d| d.words).sum::<u32>()
}));
}
handles
.into_iter()
.map(|h| h.join().expect("a reader panicked"))
.sum()
}
The bill arrives on every access: a refcount at the clone, a lock and an
unlock per read, and a poisoned-lock case to handle in code that never
writes. Rust’s own answer to that is to drop the Mutex when nothing
writes —
let shelf = Arc::clone(&shelf);
handles.push(thread::spawn(move || {
shelf.iter().map(|d| d.words).sum::<u32>()
}));
— which is the same program as §16.2’s frozen shelf, spelled by hand in the type. That is the fairest thing to say about the comparison: Rust can express both of wolf’s answers, and requires the programmer to know which one this data is, encode it in a type, and rewrite the type when the answer changes.
Wolf moves the region. The bill arrives at compile time, and it is the move check. What the receiver pays to read is nothing.
The one place wolf’s answer is structurally harder than the others is the cyclic graph, and it is instructive to see what the others do with it. Rust cannot make the ring out of references, so it makes it out of indices:
pub struct Node {
pub title: &'static str,
pub newer: usize,
pub older: usize,
}
That is an arena, hand-rolled, with usize where a pointer wanted to be
and no check that an index belongs to the arena it is used with. The
program in §16.1 is the same idea with the arena in the language and the
index carrying a generation, which is what turns a stale reference from
an exploit into a trap (§8.7). So the honest summary of the cyclic case
is not that wolf can do something Rust cannot; it is that both do the
same thing, and one of them checks it.
Where wolf pays and the others do not: distribution. move is an
in-process transfer of ownership over memory that is not going anywhere,
and it does not become a network protocol by adding a keyword. Erlang’s
copy is expensive because it is a serialization, and a serialization is
what crosses a machine boundary. That trade is decided (v1 procs are
in-process) rather than pending, and a wolf program that needs to talk to
another machine writes a protocol, like a C or Rust or Go program does.
Exercise 16-6 (design) — The same workload — a producer builds a
million-node tree, a consumer walks it — in four systems: Erlang (copying
send), Go (send a pointer), Rust (Arc<Mutex<Tree>>), wolf
(ch.send(move r)). For each, name what the transfer costs at the moment
of send, and what it costs the receiver to be safe while reading. One
of the four pays at a different time than the others — which?
Exercise 16-7 (extension · lupin) — A maze is a graph, and a graph is a region’s favorite payload. Carve a 5×5 maze with a seeded generator (per-cell wall bitmasks: 1=N 2=E 4=S 8=W, depth-first carve, a small linear-congruential step for direction choice), building the wall table inside a region. Send the region to a solver task; the solver breadth-first-searches it in place and prints the shortest-path distance from corner to corner. Seed 1: run it. Before you do, answer: how many times is the wall table copied between carver and solver?
Exercise 16-8 (comprehension + schedule play · lupin) — Change the
carve seed to 2 and run the program three times, including once under
lupin run … --seed=7. Predict: which of the two seeds in play changes
the printed distance, and which cannot — and why does this program print
the same distance under every scheduler seed?
Exercise 16-9 (comprehension · wolf + lupin) — This program declares
a channel of bare List[int] — not Copy, not imm, not a region, not
sync:
fn main() -> !int {
let ch = channel[List[int]](1)
0
}
Predict the verdict this program earns and the rule behind it, and explain
why each of the four admitted payload classes is safe where a bare List
is not.