Project 3: An Incremental Collector
Build the write barrier, which is the thing the GC chapter described and deliberately did not implement.
Effort: a week. Value: after this you can read Go's mgc.go or Lua's lgc.c and know exactly what
every barrier call is protecting.
Start With the Number
Do not build this because incremental collectors sound advanced. Build it if your measurement says to:
cargo bench --bench gc 2>&1 | grep -A6 'pause distribution'
runs: 184 p50: 1.1ms p95: 6.8ms max: 31.4ms
The max is what lands in your p99 latency. If your policy-engine workload has a max pause of
1 ms and a request budget of 50 ms, stop here and write that down — a correct decision not to build
something is a result.
If the max is tens of milliseconds on a heap you expect to grow, continue.
The Problem
Marking becomes interruptible, so the program runs between mark steps and can break the tri-color invariant:
1. Collector marks table A BLACK (scanned; all children grey or black)
2. PROGRAM runs: A.x = B where B is WHITE and reachable from nowhere else
3. Collector finishes. B was never greyed. B is swept. A.x DANGLES.
The invariant: no black object may reference a white object without a grey object on some path to it. Stop-the-world maintains it for free, because the program cannot run. Incremental does not.
The Barrier
Two classic flavors, and you should implement one and understand the other:
| Barrier | On A.x = B | Named for | Used by |
|---|---|---|---|
| Incremental-update (Dijkstra) | if A is black and B is white → grey B | Dijkstra et al., 1978 | Lua's luaC_barrier |
| Snapshot-at-the-beginning (Yuasa) | grey the old value of A.x before overwriting | Yuasa, 1990 | Many concurrent collectors |
| Hybrid | both, on different object kinds | Go |
#![allow(unused)] fn main() { #[inline] fn barrier(&mut self, parent: Handle, child: Value) { // The cost: this runs on EVERY pointer store into a heap object. Table // field writes, array stores, upvalue writes, metatable assignment. // Make the common case one predictable branch. if self.gc_phase == Phase::Marking && self.marks[parent.index as usize] // parent is black { if let Some(h) = child.as_handle() { if !self.marks[h.index as usize] { self.grey.push(h); } // grey the child } } } }
Every mutation site must call it, and there are more than you think:
Table::set (hash part) Table::set (array part) SET_LIST
SET_UPVAL (closed upvalues) setmetatable userdata field setters
the module cache insert Engine::set_global Engine::stash
Finding all of them is the project. A missed barrier is a use-after-free that appears only under
memory pressure, only when the collector happens to be mid-mark — which is precisely why
--gc-stress needs an incremental sibling:
#![allow(unused)] fn main() { // --gc-incremental-stress: run ONE mark step per allocation, so the collector // is almost always mid-mark and every missing barrier fires immediately. }
The Work Budget
Incremental means bounded steps, and choosing the bound is a real decision:
| Policy | Notes |
|---|---|
| N objects greyed per step | Simple; pause varies with object size |
| N bytes traced per step | Lua's approach (GCSTEPSIZE, stepmul); pause is more uniform |
| Proportional to allocation since the last step | Self-tuning: allocate more, collect more. Lua's stepmul does this |
Lua's tuning knobs — pause and stepmul — are worth reading about, because they are the two
parameters every incremental collector ends up exposing and neither has an obvious default.
The Measurement
| Stop-the-world | Incremental | Δ | |
|---|---|---|---|
| p50 pause | |||
| p95 pause | |||
| max pause | the point | ||
| total GC time | expect this to get worse | ||
policy_10k throughput | expect this to get worse | ||
| barrier overhead (mutation-heavy bench) |
Expect throughput to get worse. Incremental collection trades total time for pause distribution, and the barrier costs a branch on every store. If your total GC time did not increase, either your barrier is missing or your benchmark does not mutate.
That tradeoff — worse mean, better tail — is the finding, and being able to state it from your own numbers is the deliverable.
Deliverables
- The pause-distribution measurement first, and a written decision to proceed (or not).
-
Phased collection:
Idle → Marking → Sweeping, with bounded steps. -
A write barrier at every mutation site; a checklist of sites in
docs/gc.md. -
--gc-incremental-stressrunning one step per allocation. - The whole corpus green under it.
- A step-budget policy, with the two tuning knobs exposed and documented.
- The before/after table, including the throughput regression.
-
ADR-017, superseding ADR-006's "non-incremental" clause.
Where It Gets Hard
- Finding every mutation site. Make
Table's fields private and route every write through one method that calls the barrier. Structural prevention beats an audit. - The sweep phase must not free objects allocated during the sweep. New objects are allocated black (or in a separate "new" colour) so they survive the cycle they were born in.
- Weak structures, if you have them. Interact badly with incremental marking; Lua has a whole phase for them.
- Reasoning about correctness. Write the invariant at the top of
gc.rsand check every change against it. This is the one place in the curriculum where a written invariant is genuinely the only defense.
Where to Read
rg -n 'luaC_barrier|luaC_barrierback|GCSpropagate|singlestep|luaC_step' lgc.c
- Lua's
lgc.c— a production incremental collector in ~1,200 lines, with both barrier flavors (luaC_barrierforward,luaC_barrierbackfor tables) and thepause/stepmultuning. - Go's
runtime/mbarrier.go— the best plain-English explanation of hybrid write barriers in existence, in the comment block. - Dijkstra, Lamport, Martin, Scholten & Steffens (1978) and Yuasa (1990) — the two barriers, from the source.
- Jones, Hosking & Moss, The Garbage Collection Handbook, chapters 15–16.
Validation / Self-check
- State the tri-color invariant and the three-step sequence that breaks it.
- Give the two barrier flavors, what each does on a store, and who uses which.
- List every mutation site in Ember that needs a barrier. How did you make finding them structural?
- Why must objects allocated during a sweep survive it?
- What does
--gc-incremental-stressdo that--gc-stresscannot? - Report your before/after table. Which metric got worse, and why is that expected?
- Was it worth it for your workload? Answer from the max-pause number you started with.
Next: Project 4 — Coroutines.