8. Regions: memory in the shape you meant
You have written this program before. A server that builds up parse trees, buffers, and half-rendered responses for one request, then throws the whole lot away when the response goes out. In C you built an arena for it. In Rust you fought the borrow checker to encode it. Wolf spells it in one word.
Chapter 7 ended at a ceiling. A tree of single owners can say who owns one document; it has no way to say “these five thousand objects live and die together.” This chapter is that sentence, made sayable — and once it is sayable, the two problems that closed chapter 7 stop being problems, because they were the same problem wearing different clothes.
8.1 You already think in regions
Here is the shelf’s request path, whole:
struct Doc { title: str, words: int }
fn main() -> !int {
var served = 0
region request {
var docs = List[Doc]()
(mut docs).push(Doc { title: "regions", words: 900 })
(mut docs).push(Doc { title: "moves", words: 640 })
served = docs.len
}
print("{served} documents, one free")
0
}
$ lupin request.lu
2 documents, one free
Inside the braces, allocation is ordinary: build a list, push documents into it, let the list grow its buffer. At the closing brace all of it — the list, the buffer, both documents, every string they own — is freed in one motion. Not two frees, not four, not one per object. One.
A region is that arena, checked: the compiler proves nothing escapes it alive, and the whole thing dies at a point you wrote down. Wolf did not invent the granule. It checks the one you already believed in.
Three programs make the case, and you have written at least one of them.
The per-request arena. A server handles a request: parse the
headers, build a response, send it. Everything the request touched dies
when the response is flushed, except the response bytes. C programmers
hand-roll this with arena_alloc and one arena_free; the discipline
is enforced by code review and a wiki page.
The per-frame scratch. A game loop computes collision pairs and a display list, draws them, and starts over sixty times a second. The survivor is the game state. Everything else is garbage before the monitor has finished refreshing.
The world state. The store, the connection pool, the interned strings: allocated at startup, alive until the process is. Nothing frees them because nothing has to.
Each of those is a group of allocations sharing a death. That is the whole idea, and it is older than most of the languages that cannot express it. What C gives you is the mechanism without the check: an arena and the standing possibility that a pointer into it outlives it. What tracing garbage collection gives you is the check without the shape: the objects die eventually, in an order and at a moment nobody wrote down. Wolf gives you both halves — you name the group, the compiler holds you to it.
Exercise 8-1 (comprehension · prose) — Three programs you have met or written: (a) a web server handling one request — parse the headers, build a response, send it; (b) a compiler pass — read an AST, produce a transformed AST, discard the scratch; (c) a game loop — each frame computes collision pairs and a display list, then draws. For each, name the group of allocations that share a death, the moment they all die, and the one value (if any) that must survive. No wolf required; the point is that the regions were already there.
8.2 The block form
The shelf takes commands on one line, and parsing one command allocates: a list of fields, the substrings inside it, whatever the parse builds on the way. None of it outlives the command.
struct Doc { title: str, words: int }
fn parse(line: str) -> Doc {
var parts = List[str]()
for w in line.words() { (mut parts).push(w) }
Doc { title: parts[1], words: parts.len - 2 }
}
fn main() -> !int {
var counted = 0
region command {
let d = parse("put regions the wolf runs at dusk")
print("{d.title}: {d.words} words")
counted = d.words
}
print("{counted} counted, the parse freed")
0
}
$ lupin command.lu
regions: 5 words
5 counted, the parse freed
Read parse again and notice what is not in it. No region parameter. No
annotation on the return type. No opinion, anywhere in that function,
about where its memory comes from — and parse is where all the
allocation happens.
That is the default, and it is the chapter’s largest claim: an
allocation lands in the region that is open around it. Inside the
region command block, the current region is command, and every
allocation made under that brace — in main, in parse, in push, in
whatever words() does — lands in command. Outside the block the
current region is main’s own, and the same code lands there instead.
A function does not choose where its results live. Its caller does, by
standing in the region it wants, which is how the best C APIs have
always worked, minus the parameter you had to thread through eleven
call frames to make it work.
The consequence is worth stating flatly, because it is the difference
between a memory discipline you adopt and one you write in: the wolf
program above has exactly one region annotation, the word region that
opens the block in main, and there is no second one anywhere. Not in
the signature, not on the struct, not on the parameter. A hundred allocations under that brace
need no more words than one allocation does. You mark where the group
lives; you never mark what belongs to it.
What the brace promises
The promise is one-directional. Everything allocated inside the block is freed at the closing brace, so nothing allocated inside the block may still be needed after it. Break that and the compiler says so:
struct Doc { title: str, words: int }
fn main() -> !int {
var newest = Doc { title: "", words: 0 }
region command {
newest = Doc { title: "regions", words: 900 }
}
print("{newest.title}")
0
}
error[E1010]: `newest` still holds a value allocated in region `command` when the region is freed
--> ./s3.lu:8:9
|
7 | region command {
| ------- region `command` is created here
8 | newest = Doc { title: "regions", words: 900 }
| ^^^^^^ the value flows out of the region here
| ------------------------------------ allocated here, into region `command`
9 | }
| - the region is freed here — everything in it is freed wholesale, as one unit
|
= note: to keep the value, allocate it where it must live: build it outside the region block, or
aim the allocation at a longer-lived region explicitly (`let r = region()` … `in r { …
}`); widening the region block to cover every use also works. Two keep-alive
alternatives change the ownership instead: `freeze` the region (immutable forever) or
make the value a `shared` cell (reference-counted, never dangles).
The diagnostic tells the story in the three moments that make it a story: the region is created here, the value flows out here, the region is freed here. There is no fourth moment, and the word “lifetime” appears nowhere in it — because the region is the lifetime, written down, with a brace at each end.
Three repairs, and they are three different statements about the
program. Build newest outside the block, and the value lands in a
region that outlives the command. Widen the block until it covers the
last use, and the group grows to match. Or aim the allocation somewhere
longer-lived on purpose, which is §8.3. The note also rules out the
repair you were about to try: copy inside the block does not help,
because a copy is a fresh allocation in the current region, which is
the one about to die.
Exercise 8-2 (fingers · lupin) — Sum the first hundred integers
using a list a helper function builds — with the helper writing no
region code at all — inside region tmp { }. State where fill’s list
is allocated, and what happens to it at the closing brace.
Exercise 8-3 (comprehension · wolf) — One assignment tries to smuggle a region value past the brace:
struct Node { value: int }
fn main() -> !int {
var keep = Node { value: 0 }
region tmp {
keep = Node { value: 7 }
}
if keep.value == 7 { 0 } else { 1 }
}
Predict the compiler’s verdict, and — before reading the diagnostic — list the three code locations you expect it to point at.
8.3 Regions are values
A block is the right shape for a region whose life fits inside a function. The shelf’s store does not: it is created at startup, lives until shutdown, and is touched from a dozen places in between. So a region is also an ordinary value you can bind, pass, and store:
struct Doc { title: str, words: int }
struct Shelf { docs: List[Doc] }
fn add(mut s: Shelf, d: Doc) {
(mut s.docs).push(d)
}
fn main() -> !int {
let store = region()
var shelf = in store { Shelf { docs: List[Doc]() } }
in store { add(mut shelf, Doc { title: "regions", words: 900 }) }
in store { add(mut shelf, Doc { title: "moves", words: 640 }) }
print("{shelf.docs.len} docs, store closed={store.is_closed()}")
0
}
$ lupin shelf.lu
2 docs, store closed=true
region() makes one. in store { … } opens a window into it: for the
length of that block, store is the current region, and allocations
land there — the same rule as §8.2, with the region named by a value
instead of a brace. Between windows the region sits closed, holding its
data, which is what is_closed() reports and what makes the last line
read true even though the shelf’s two documents are alive inside it.
Closed is not dead. Closed means nobody is allocating right now.
Two spellings, one concept. region command { … } creates a region,
opens it for the block, and frees it at the brace; let r = region()
plus in r { … } splits those three acts apart so you can put them in
different places. Use the block when the region’s life is a scope, and
the value when it is not. add cannot tell the difference and does not
have a parameter for one.
A region value carries a strategy — how its interior is managed —
chosen where the region is made and nowhere else. region() is the
arena: bump a pointer, free the lot. region(rc) counts its interior
instead, for a region whose objects die at different times.
region(pool(Doc)) lays out a slab of Doc-shaped slots, which is
§8.4’s subject. The block form takes the same strategies after a colon —
region store: pool(Doc) { … } — and means exactly what the value form
means.
A region value is used once
A region has one owner. Bind it, and that binding is the region; hand it somewhere else and the old name is empty, exactly as chapter 7 taught for every other value. What is new is that the open window counts as a use in progress. While a block is open, the region cannot be handed anywhere:
struct Doc { title: str, words: int }
fn main() -> !int {
region store {
var docs = List[Doc]()
(mut docs).push(Doc { title: "regions", words: 900 })
let published = freeze store
0
}
}
error[E1005]: region `store` is open, so its handle cannot move or freeze
--> ./s6.lu:9:32
|
6 | region store {
| ----- region `store` is open here, until its block ends
...
9 | let published = freeze store
| ^^^^^ the transfer happens inside the open window
|
= note: a region transfers or freezes as a closed subtree only — the open window pins its
handle. End the `region`/`in` block first, or move the value before opening the region.
The interpreter enforces the same rule by running into it:
$ lupin pin.lu
pin.lu: trap(region-fault): `store` (region #1) is open here; a region is frozen or transferred as a closed subtree [mem.region.freeze.3] at 194..206
$ echo $?
3
Two implementations, one sentence: a region moves as a closed subtree or not at all. The reason is the same reason §8.2’s escape is an error, one level up. An open window is code standing inside the region; handing the region away while somebody is standing in it would leave that code in a room that now belongs to someone else. End the block, then hand it over.
Exercise 8-4 (fingers · lupin REPL) — In the REPL: define a
one-field struct, create a region with region(rc), allocate one value
into it with in r { … }, and look at :regions before and after
freeze r. Predict the two state words you will see before you look.
Exercise 8-5 (comprehension · lupin) — A region is being sent somewhere while a window into it is still open:
fn main() -> !int {
let ch = channel[region](1)
let r = region()
in r {
var xs = List[int]()
(mut xs).push(1)
ch.send(move r)
0
}
}
Predict the event, its trap kind, and the static error code the trap line will mention.
8.4 Cycles are fine here
The shelf keeps its documents in recency order, and the honest data structure for recency order is a doubly-linked ring: each document points at the one used after it and the one used before it, and the ends meet. It is the structure most caches are built on, and it is the one folklore says a safe language will not let you write.
Wolf lets you write it. Here is the shelf’s ring, built:
struct Doc { title: str, words: int, newer: handle Doc, older: handle Doc }
fn main() -> !int {
region store: pool(Doc) {
var docs = Pool[Doc]()
var titles = List[str]()
(mut titles).push("regions")
(mut titles).push("moves")
(mut titles).push("errors")
let n = titles.len
var hs = List[handle Doc]()
for _ in 0..n { (mut hs).push((mut docs).reserve()) }
for i in 0..n {
(mut docs).init(hs[i], Doc {
title: copy titles[i],
words: (i + 1) * 100,
newer: hs[(i + 1) % n],
older: hs[(i + n - 1) % n],
})
}
A handle is an index into a pool, carrying the generation the slot was
at when the handle was issued. reserve takes a slot and hands back its
handle; init fills it. Splitting those two acts is what closes the
cycle without a null ever existing: by the time any document is
initialized, every handle it needs already has a name, so newer and
older can both be filled in with real values on the first try. No
Option, no placeholder, no “we will patch this up in a second pass”
comment.
Now walk it, forward and back:
var cur = hs[0]
for _ in 0..4 {
print("newer -> {docs[cur].title}")
cur = docs[cur].newer
}
cur = hs[0]
for _ in 0..2 {
cur = docs[cur].older
print("older -> {docs[cur].title}")
}
0
}
}
$ lupin ring.lu
newer -> regions
newer -> moves
newer -> errors
newer -> regions
older -> errors
older -> moves
The forward walk prints four documents and the fourth is regions
again, because it is a ring. The backward walk from regions reaches
errors and then moves, because the back-links are real links and not
a comment claiming they are. There is no unsafe in that program, no reference counting, no
interior mutability wrapper, and no lifetime.
The rule that permits it is one sentence: safety in wolf is checked per region, not per object. Inside a region, point wherever you like. Every object under that brace is freed at the same instant, so no edge between two of them can ever dangle — there is no moment at which one end is gone and the other is still looking. Cycles, back-pointers, sibling links, parent pointers, an intrusive list threaded through objects that also live in a tree: all of it is free, because none of it can outlive anything else in the region.
What is checked instead
Something has to be checked, or the guarantee is a slogan. What the compiler polices is the region border — every edge that leaves one region and enters another.
You have met the first border rule already — §8.2’s E1010, a value from a dying region held by something that outlives it. The second is that a region can be owned by exactly one thing. Store a region inside a value and that value owns it:
struct Doc { title: str, words: int }
struct Shelf { store: region }
fn main() -> !int {
let s = region()
let d = in s { Doc { title: "regions", words: 900 } }
let shelf = Shelf { store: move s }
print("{d.title} closed={shelf.store.is_closed()}")
0
}
$ lupin holder.lu
regions closed=true
move s is chapter 7’s verb doing chapter 7’s job: s hands its region
to the shelf, and no second name for it survives. Because ownership of a
region works that way, the regions in a running program form a forest —
each one owned by at most one other, no loops among them — and a graph
with no loops has an order to die in. That is the fact the whole design
rests on, and it is enforced by the ordinary move rules rather than by a
special case.
So the model has two levels and they have opposite rules. Between regions: single ownership, a forest, checked. Inside a region: anything you like, checked by the fact that it all dies at once. Cyclic data is not an exception the language grudgingly permits. It is what the inside of a region is for.
Coming from Rust: the ring above is the program the folklore is about, and the folklore is not wrong — it is describing a real cost honestly. Rust’s answer is
Rc<RefCell<T>>withWeakback-edges, and it works:pub struct Doc { pub title: &'static str, pub newer: RefCell<Weak<Doc>>, pub older: RefCell<Weak<Doc>>, } pub fn ring(titles: &[&'static str]) -> Vec<Rc<Doc>> { let nodes: Vec<Rc<Doc>> = titles .iter() .map(|title| { Rc::new(Doc { title, newer: RefCell::new(Weak::new()), older: RefCell::new(Weak::new()), }) }) .collect(); let n = nodes.len(); for i in 0..n { *nodes[i].newer.borrow_mut() = Rc::downgrade(&nodes[(i + 1) % n]); *nodes[i].older.borrow_mut() = Rc::downgrade(&nodes[(i + n - 1) % n]); } nodes }Count what it costs. Every link is two words instead of one and a refcount pair to maintain. Every traversal step is an
upgradethat can fail and aborrowthat can panic — the aliasing check moved from compile time to run time, which is the tradeRefCellexists to make. The nodes start out withWeak::new()placeholders, so there is a window in which the ring is not yet a ring. And the returnedVecis not a convenience: it is the only owner in the program. Drop it and every link is instantly dead, because a ring ofWeakholds nothing up. Rust’s other answer — aVecof nodes plus integer indices, which is what production crates likeslotmapandgenerational-arenapackage — is closer to wolf’s, and the resemblance is not an accident: wolf’shandleis that idea with the generation check made part of the language instead of part of a dependency. What wolf adds is that the arena has a name, a scope, and a compiler that knows the arena’s contents cannot escape it.
Exercise 8-6 (fingers · lupin) — Build a five-node doubly-linked
ring in a pool region: each node points next and prev. Then prove
both directions work: walk five steps forward from the head (where do
you land?), and two steps backward. Rust folklore says this program
requires unsafe or Rc<RefCell<…>>; say in one sentence why wolf’s
checker does not object here.
Exercise 8-7 (extension · prose) — Grow the ring into the full cache: an
LRU with sentinel head and tail, unlink and push_front as the only
two link operations, promotion on get, and eviction of tail.prev at
capacity. Trace it by hand: after put a, put b, get a, put c at capacity
2, what does the front-to-back walk print, and how many pointer writes
does each of the two link operations perform?
8.5 Freeze
The shelf serves reads while it takes writes, and the reads want a version of the store that will not move under them. Build it, then say one word:
struct Doc { title: str, words: int }
struct Shelf { docs: List[Doc] }
fn main() -> !int {
let store = region()
var shelf = in store { Shelf { docs: List[Doc]() } }
in store { (mut shelf.docs).push(Doc { title: "regions", words: 900 }) }
in store { (mut shelf.docs).push(Doc { title: "moves", words: 640 }) }
let published = freeze store
print("{shelf.docs[0].title} and {shelf.docs[1].title}, readable forever")
0
}
$ lupin publish.lu
regions and moves, readable forever
freeze store consumes the region value and promotes everything in it
at once: the shelf, the list, the buffer, both documents, every string.
No copy is made and nothing is walked — the promotion is a fact about
the region, so it costs one state change no matter how much is inside.
Afterward the data is readable from anywhere, by anybody, for as long as
the program runs.
freeze r is not a lock, to be taken and released. It is a cadence:
after it, the region is immutable, shareable, permanent, and there is no
unfreeze. Like the Tristan chord, the suspension does not resolve;
unlike the Tristan chord, this is the point.
The compiler holds the deal at every write that could break it:
struct Doc { title: str, words: int }
fn main() -> !int {
var snapshot = freeze region { Doc { title: "regions", words: 900 } }
snapshot.words = 1200
snapshot.words
}
error[E1012]: `snapshot.words` is frozen, so it cannot be assigned through
--> ./s10.lu:7:5
|
6 | var snapshot = freeze region { Doc { title: "regions", words: 900 } }
| ------------------------------------------------------ the freeze happens here — the promotion to `imm` is deep and permanent
7 | snapshot.words = 1200
| ^^^^^^^^^^^^^^ this needs the data to be mutable
|
= note: `freeze` promotes the whole graph to `imm`: shareable from anywhere, forever, and never
writable again. Build the value completely before freezing it, or keep a mutable copy
(`copy`) alongside the frozen one.
And the interpreter faults on the same line, citing the same clause:
$ lupin revise.lu
revise.lu: trap(region-fault): region #1 is frozen: `imm` data is immutable forever [mem.region.freeze.1] at 136..157
$ echo $?
3
Note what the diagnostic marks as the cause: not the write’s own line
alone, but the freeze that made the write illegal, several lines up.
Freezing is deep — it reaches the whole graph, not the binding you
happened to name — and it is permanent, so the fix is never “unfreeze
it.” Build the value completely and freeze last, or keep a mutable
copy beside the frozen one and freeze that separately when it settles.
Why permanence buys speed
There is a reason the deal is one-way rather than a mode you toggle. Frozen data has no writers, ever, anywhere in the program, for the rest of its run. That is a fact about every load from it, and a fact the compiler can rely on is a fact the optimizer can spend: loads hoist out of loops, repeated field reads collapse into one, values propagate through calls the optimizer cannot see into. Frozen data also needs no synchronization to share, because synchronization is what you buy to order writes against reads, and there are no writes. Part 3 takes that last sentence and builds a concurrency model on it.
Exercise 8-8 (comprehension · wolf + lupin) — A struct type with a
strong shared edge back to itself:
struct Node { value: int, next: shared Node }
fn main() -> !int { 0 }
main builds nothing. Predict each tool’s verdict anyway, then explain
the asymmetry: which tool is answering “could any program with this type
leak,” and which is answering “did this program fault”?
Exercise 8-9 (comprehension + spelunking · wolf) — One write after a freeze:
struct Config { limit: int }
fn main() -> !int {
var cfg = freeze region { Config { limit: 42 } }
cfg.limit = 7
cfg.limit
}
Predict the verdict and, from --explain-level knowledge, the two
repairs the note will offer.
Exercise 8-10 (comprehension · lupin) — The dynamic half of the
same contract: create a pool region, freeze the region value, then call
reserve on the pool. Predict the trap kind and the clause tag.
8.6 Open, and open again
The shelf now has two regions with different lifetimes: the store, which outlives every command, and the scratch the command parser uses. Real work needs both open at the same instant — read the command out of scratch, write the document into the store:
struct Doc { title: str, words: int }
fn main() -> !int {
region store {
var docs = List[Doc]()
region command {
var parts = List[str]()
for w in "put regions 900".words() { (mut parts).push(w) }
(mut docs).push(Doc { title: copy parts[1], words: parts.len })
}
print("{docs[0].title}, {docs.len} on the shelf")
0
}
}
$ lupin both.lu
regions, 1 on the shelf
Two regions are open across that inner block, and the code writes through both windows in the same statement. Nothing was declared, nested in a special way, or annotated to make it legal. It reads as ordinary because it is meant to be the ordinary case.
The rule under it is worth a moment, because it is not the rule you
would guess from the syntax. The open regions do not have to be nested,
and nesting in the source is not what makes them legal. What makes them
legal is that neither one contains the other. store does not own
command and command does not own store — they are siblings in the
forest §8.4 described — and two windows into disjoint data can be open
at once without either being able to reach what the other is writing.
Turn that around and you have the one shape that is refused: a region that is open, and inside it a window into a region it owns.
struct Shelf { scratch: region }
fn main() -> !int {
let s = region()
region store {
let shelf = Shelf { scratch: move s }
let n = in shelf.scratch { 1 }
n
}
}
error[E1011]: this would open region `s` while region `store` is still open
--> ./s14.lu:9:17
|
7 | region store {
| ----- region `store` is opened here, and is still open
8 | let shelf = Shelf { scratch: move s }
9 | let n = in shelf.scratch { 1 }
| ^^^^^^^^^^^^^^^^^^^^^^ the second open window starts here
|
= note: region `store` owns region `s`, so its open window already reaches this data. Two
regions may be open at once only when neither owns the other (they are siblings in the
region forest) — close the first block before opening this one, or open the child
through its own scope after the owner's window ends.
$ lupin nested.lu
nested.lu: trap(region-fault): region #1 is not disjoint from the already-open `store` (region #2): one owns the other, and an owner's open window reaches its child's data. `[mem.region.multiopen]` discharges disjointness with distinctness of affine values, which does not imply it across an `iso` edge [mem.region.multiopen] at 155..177
$ echo $?
3
The shelf owns its scratch region, so the window into store already
reaches everything the scratch holds. Opening the scratch as well would
put one location behind two live windows, which is chapter 7’s
exclusivity rule at the size of a region. The general statement is in
the compiler’s own words:
$ wolf --explain E1011
E1011: this would open a region while a region that contains it is open
Any number of regions may be open at once, provided none of them
contains another: the open set must be an antichain in the region
forest ([mem.region.multiopen]). Sibling regions have disjoint data,
so mutating through both windows at once is safe — but an owner's
window already reaches everything its child region holds, so opening
the child (or the owner) while the other is open would put one
location behind two live mutable windows. The diagnostic marks both
open sites. Close the first block before opening the second, or
restructure so the two regions are siblings — neither stored inside
the other — and open them together freely.
“Antichain” is the precise word and it needs no graph theory to use:
take the set of regions you have open, and no one of them may be an
ancestor of another. Siblings, cousins, and unrelated strangers, in any
number; parent and child, never. The intuition to carry is the one the
store/command pair demonstrates — regions that hold different data
open together freely, and the compiler’s job is only to notice when two
names turn out to be one place.
This is the part of the region design that goes furthest past what has been built before: the systems wolf learned regions from allow one open window at a time. Holding several is what makes the ordinary program above ordinary, and holding several safely is what the affinity rule of §8.3 pays for — since a region value has exactly one name, two different names are two different regions, and the compiler needs no alias analysis to know it.
Exercise 8-11 (comprehension · lupin) — Two region values, two
nested in windows, reads and writes crossing both:
fn main() -> !int {
let a = region()
let b = region()
var total = 0
in a {
var xs = List[int]()
(mut xs).push(1)
in b {
var ys = List[int]()
(mut ys).push(2)
total += xs[0] + ys[0]
}
(mut xs).push(3)
total += xs[1]
}
print("{total}")
0
}
Predict the printed total. Then the antichain question: of the shapes
(1) in a { in b { } }, (2) in a { in a { } }, (3)
in a { } in a { } — sequential reopen — which are legal? Answer from
the rule that region values are affine and windows must be into
distinct regions, then check the one the program demonstrates.
8.7 shared and handle
Regions answer the case where a group of objects shares a death. Two cases remain, and they are the ones where the granule is genuinely the wrong tool: a value whose death nobody can name in advance, and a value that other code may still be pointing at when you kill it.
Wolf gives each of them a type, and the way to choose between them is not the mechanism. It is the failure contract — what you want to happen on the day the target is gone.
shared: it will not be gone
struct Doc { title: str, words: int }
struct Entry { doc: weak Doc }
fn main() -> !int {
let doc = shared (Doc { title: "regions", words: 900 })
let held = doc.clone()
let entry = Entry { doc: doc.downgrade() }
let seen = entry.doc.upgrade() else |_| { return 1 }
print("{held.title}, {doc.strong_count()} strong, index sees {seen.words}")
0
}
$ lupin shared.lu
regions, 3 strong, index sees 900
shared Doc is a reference-counted cell. clone adds an owner, and the
document lives while any owner holds it — so a shared reference never
dangles, and reading through one needs no check at all. The count reads
3 because there are three strong owners at that line: doc, the
clone, and the one upgrade produced on the line above.
weak Doc is the other half, and the shelf’s index is where it belongs:
a back-edge that observes without keeping alive. A weak is not a
reference you read — it is a question you ask. upgrade answers with
the value or with nothing, and the else is not optional, because the
type is how the language makes you decide what “nothing” means for your
program.
The price of “never dangles” is that the counts must be able to reach zero, which they cannot do around a loop. Wolf rejects the shape rather than the program:
struct Doc { title: str, words: int, related: shared Doc }
fn main() -> !int { 0 }
error[E1006]: `Doc` holds a strong `shared` path back to itself
--> ./s17.lu:4:38
|
4 | struct Doc { title: str, words: int, related: shared Doc }
| ^^^^^^^ this `shared` edge closes the cycle Doc → Doc
|
= note: strong `shared` references drop their target when the last count drops, so a strong
cycle would keep itself alive forever — and wolf has no cycle collector
([mem.shared.rc.2]). Break the back-edge: make this field `weak Doc` (upgrade to reach
the value without keeping it alive) or `handle Doc` (a generational index that faults if
the target is gone). If the structure is genuinely cyclic, keep the whole graph in one
region instead — intra-region cycles are safe and freed wholesale
([mem.region.intra.1]).
The check is on the type, at its definition, with no program in sight —
because a strong cycle in the type is a leak in some program with that
type, and refusing the type is how you refuse all of them at once. Wolf
has no cycle collector, and the diagnostic says why in one clause: a
leak is not an answer either. The three repairs it offers are the
chapter in miniature. Make the back-edge weak, and it stops keeping
anything alive. Make it a handle, and it faults instead of leaking. Or
put the cycle in a region, where §8.4 already showed it costs nothing.
handle: it may be gone, and you will know
The shelf evicts. Something else may be holding the document it evicts, and pretending otherwise is how caches produce bugs that reproduce once a week.
struct Doc { title: str, words: int }
fn main() -> !int {
region store: pool(Doc) {
var docs = Pool[Doc]()
var recent = List[handle Doc]()
let h = (mut docs).reserve()
(mut docs).init(h, Doc { title: "regions", words: 900 })
(mut recent).push(h)
print("cached: {docs[recent[0]].title}")
(mut docs).remove(h)
print("cached: {docs[recent[0]].title}")
0
}
}
$ lupin evict.lu
cached: regions
evict.lu: trap(stale-handle): handle into pool#0 slot 0 carries generation 0, the slot is at generation 1; a stale handle is a deterministic fault in every profile, never UB [mem.shared.handle.2] at 392..407
$ echo $?
3
The first read succeeds. remove frees the slot and bumps its
generation; the handle in the recent list still carries generation 0;
the mismatch is the fault. Read the trap’s own sentence twice, because
it is the whole difference between a handle and a pointer into a freed
arena: deterministic, in every profile, never UB. The same program
faults the same way in a release build, on every machine, at the same
read. A C program with a dangling index into a recycled slab gets the
new occupant’s data and keeps going.
Cost is an index bounds check and a generation compare — two loads and a branch, on a line that was going to load from that slot anyway. What you buy with them is that “the target is gone” is an event your program can be told about instead of a silence it has to survive.
Choosing
The table is the section, and it is short on purpose. Read down the first column until a row is true of your field, and take that row.
| The question | Answer | Use | What failure looks like | Cost |
|---|---|---|---|---|
| Does the target die with everything around it? | yes | plain edge inside the region | none — nothing outlives anything | zero |
| Can the target disappear while you hold this? | no, and you want it kept alive | shared | none — it will not be gone | a count, mostly compiled away |
| Can the target disappear while you hold this? | yes, and you must be told | handle | trap(stale-handle), deterministic | index + generation check |
| Can the target disappear while you hold this? | yes, and “gone” is a fine answer | weak inside shared | upgrade yields nothing; you handle it | a count and a branch |
| Is the graph cyclic? | yes | one region, plain edges | none | zero |
| Is the graph cyclic across regions? | yes | rethink the granule — make it one region | — | — |
Two notes on the cost column, both honest. Reference counting is not
free, but most of it is removable: wolf’s counts are inserted by the
compiler rather than by you, so a count that provably cannot reach zero
in a scope is a count that is never updated — the technique Perceus and
Lobster established, and the reason shared is a reasonable default
rather than a last resort. And “zero” in the first and fifth rows means
zero: an edge inside a region is a machine word holding an address, with
nothing attached and nothing to maintain.
The last row is the one people argue with, so here is the argument. If a cycle wants to cross a region border, the border is in the wrong place — two things that point at each other are one thing that dies together, and you have drawn a line through the middle of an object. Move the line. That is what “rethink the granule” means, and it is advice, not a diagnostic; the compiler will only tell you that the edge you wrote is not allowed.
Exercise 8-12 (comprehension · lupin) — A handle is used after its slot is gone:
struct Node { value: int }
fn main() -> !int {
region r: pool(Node) {
var pool = Pool[Node]()
let h = (mut pool).reserve()
(mut pool).init(h, Node { value: 1 })
(mut pool).remove(h)
let v = pool[h].value
v
}
}
Predict the trap kind, and — the part worth being precise about — what the trap line will say about generations.
Exercise 8-13 (design) — Four fields, one decision each: (a) a
parent pointer in a tree whose nodes a region owns; (b) an edge in a
social graph where nodes are deleted while neighbors hold references;
(c) a config blob read by every task for the process’s whole life; (d) a
cache entry another subsystem may hold while the cache evicts it. For
each: shared, weak, handle, or a plain intra-region edge — and
name the failure contract you chose, not only the shape.
8.8 What the machine does
A region is an arena, and an arena is the oldest trick in systems programming: a block of memory and a pointer into it. Allocating adds the object’s size to the pointer. Freeing the region resets the pointer to the start, or hands the block back, and does nothing per object. That is why the shelf’s request path frees a list, its buffer, two documents and their strings in “one motion” — the motion is an assignment.
Bump allocation also puts objects allocated together next to each other, which is the layout a hardware cache wants. Here that layout is a side effect of where the allocator’s pointer was, rather than something you went looking for with a profiler.
The second fact is the one C cannot state. Two distinct regions never
share a byte, and the compiler knows which region each pointer aims at,
so a write through one and a read through the other cannot be the same
location. That is the restrict promise, derived rather than asserted —
chapter 7 made the same point at the size of a parameter, and this is
the same trade at the size of an arena. C has no way to write “these two
pointers came from different arenas” in a type, and no way to check it
if it did.
The honest part. The claim wolf makes is not that arenas are faster than malloc; that has been known for thirty years, which is why every serious C codebase has one. The claim is a distributional one: most C programs do not ship with a disciplined arena everywhere, because keeping the discipline correct by hand across a team is work, and the failure mode is a use-after-free at 3 a.m. Wolf programs get the discipline by default and the correctness by construction, so the comparison that matters is not against C-with-arenas but against the C that actually gets written. That claim is a measurement, and Part 4 is where it gets measured rather than asserted.
The cost, stated plainly. A region holds its memory until it dies, so
a granule that is too big holds memory longer than the program needs it.
A request region freed per request is right; a region freed per
connection, holding a thousand requests’ scratch, is a leak with a
scope. When objects inside one group genuinely die at different times,
that is what region(rc) is for — the same region shape, counted
interior, memory returned as the counts fall. Choosing the granule is
the one piece of design work regions ask of you, and it is the piece
that was always your job.
Credit where it is owed. Region inference that costs no annotations is Cyclone’s result, measured on ported C in the early 2000s; the ownership discipline that makes a region’s interior free to alias is Verona’s, and holding more than one region open at a time is where wolf goes past it. Neither project is a footnote in this design — between them they are the design, with the surface rewritten for a reader who would rather not say the word “region” twice.
Exercise 8-14 (spelunking · wolf) — Run wolf --explain E1012 and
read it against exercise 8-9. Find: the sentence that explains why
frozen data needs no locks, the phrase that makes the promotion
transitive, and the reason “readable forever” is a performance claim,
not only a safety one.
The shelf, in the shape it meant
Chapter 7 left the shelf with two problems and called them one ceiling. Both are gone, and neither needed a smarter borrow.
The first was that two parts of the program wanting the same document had to copy it and watch the copy drift. Inside a region they do not copy: both hold an edge to one document, and an edge inside a region is a machine word. The recency ring in §8.4 is that fact used on purpose — three documents, six links among them, and no copy anywhere.
The second was that a tree of single owners has no way to say “these five thousand objects live and die together.” That sentence is now one word and a brace, and everything under the brace inherits it without saying a word.
What the shelf costs in annotations, counted honestly: one region per
group, one freeze where a snapshot is published, and a type on the
fields that cross a border. No lifetimes, no region parameters, no
annotation on any function that merely allocates — which is most of
them. The memory architecture is visible in the places where it is a
decision and invisible everywhere else, which is the same principle
chapter 7 applied to mutation, one granule up.
One consequence of this chapter belongs to Part 3, and it is worth seeing from here. A closed region is a self-contained subtree of memory with exactly one owner, which is precisely the property that makes it safe to hand to another worker — no shared references to audit, no locks to take, nothing else pointing in. Freezing gives the other half: data with no writers is data any number of readers can read at once. Those two sentences are the whole of wolf’s concurrency safety story, and you have read them in a chapter about memory.
Exercise 8-15 (extension · lupin) — A text adventure’s world is a
cyclic graph: rooms point at each other in four directions, and “north
then south” must come home. Build three rooms — den, ridge, river bank —
in a pool region, close the cycles with two-phase init, and walk the
path north, east, west, south, printing the room at each step. Predict
the four lines before running; the fourth is the one that checks you
wired south self-loops honestly.
Exercise 8-16 (extension · lupin) — wc, wolfished: count the
lines and words of a multiline block, but store every line in a scratch
region while counting — then let the region die and print the counts
after it is gone. State what survives the brace and why this program’s
memory use at peak is “the text, once” rather than “the text, twice.”