Garbage Collection: Mark-and-Sweep
Writing a Compiler/Interpreter: Advanced
Chapter 8 · Garbage Collection: Mark-and-Sweep
Chapter 7 gave objects a way to outlive the call that created them — a closure keeps its captured upvalues alive indefinitely, for as long as anything still needs them. Nothing so far has ever gotten rid of one. Closure and Upvalue objects have been piling up in memory since Chapter 6, with nothing ever asking whether a given one is still needed. This chapter builds a real collector to answer that question — and along the way, finds a genuinely surprising reason it's needed at all, in a VM hosted inside a language that already has its own garbage collector.
An Honest Framing, First
malloc'd Closure stays allocated forever unless something explicitly frees it. This course's own VM is written in Python, which already reference-counts every object and reclaims it automatically the instant nothing points to it anymore. Building a mark-and-sweep collector here is a genuine, correctly-working algorithm — not a simulation — but it's worth being direct about why: it's the real mechanism a systems language needs, demonstrated faithfully, even though Python's own memory management would keep this specific VM safe with or without it. Whether that makes this chapter's own collector purely redundant turns out to have a more interesting answer than "yes" — see below.
What's on the Heap
Only two kinds of object get created dynamically, during execution, potentially many times, with a genuine question of whether each one is still needed later: Closure and Upvalue. Both allocation points from Chapter 7 now record into an explicit self.heap list.
Roots: Where Reachability Starts
An object is garbage only if nothing reachable from the program's own currently-live state points to it, directly or indirectly. That live state — the roots — is everything currently on the value stack, every value stored in globals, the closure each active call frame is running, and any upvalue still open (backing a live stack slot right now).
Mark: Following References Transitively
A root is only the start. A closure can hold upvalues; a closed upvalue can hold a value that's itself a closure (a closure captured by another closure, stored as its own upvalue's frozen value). Marking has to follow those references outward, not just mark the roots themselves.
Sweep: Discarding What Isn't Marked
Verified: a Real Collection Cycle
collect_garbage(): 8 objects on the heap — the top-level script's own closure, makeAdder's closure, and one [Closure, Upvalue] pair for each of the three calls. After: 3 survive — makeAdder's own closure, keep's closure, and keep's captured upvalue. 5 collected: the two throwaway adder closures from makeAdder(20) and makeAdder(30) (each immediately discarded by the very next OP_POP, per Chapter 4's own expression-statement rule), their two upvalues, and — genuinely unexpected until checked — the top-level script's own closure, which nothing references anymore once the program has finished running.
keep(100) again, after collect_garbage() has already run: 110, correct. Removing an object from self.heap is bookkeeping only — the real Python object keep refers to was never touched.
makeAdder results in separate globals (a, b, c) instead of discarding two of them: collect_garbage() removes exactly 1 object — again, only the now-unreferenced top-level script closure. Every closure and upvalue the program itself created stays reachable through globals, and none of it is touched.
The Twist: This Collector Isn't Actually Redundant
The honest framing at the top of this chapter said Python would keep this VM memory-safe with or without a collector of its own. Checking that directly turns up something worth correcting.
sys.getrefcount() on one of the two discarded adder closures before collect_garbage() runs shows a live reference count greater than zero — self.heap itself is holding a genuine, counted Python reference to an object nothing in the running Wisp program cares about anymore. Only after collect_garbage() removes it from self.heap does that reference actually go away.
self.heap only ever grows via append() in alloc_closure/capture_upvalue; nothing removes anything from it except collect_garbage() itself. Without ever calling it, every closure and upvalue a Wisp program has ever created — including every one immediately discarded, like the two adder closures above — would stay referenced by self.heap forever, growing without bound for the entire life of the program. Python's own reference counting was never the obstacle; the VM's own tracking structure, built specifically so a mark-and-sweep collector would have something to sweep, is what turns out to need exactly the collector this chapter built to fix it.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Roots include every active frame's own closure, and every open upvalue | Chapter 6's own CallFrame and Chapter 7's own open_upvalues dict — this chapter reuses both structures directly as sources of truth for reachability, adding nothing new to track |
| A closed upvalue can hold a closure, which mark_object follows transitively | Chapter 7's own closure-capturing-a-closure scenarios (Exercise 3's nested forwarding) — the same shapes that made closures hard to compile make them non-trivial to trace for reachability too |
The VM's own self.heap list is itself a real reference, not neutral bookkeeping | A general lesson about instrumentation: adding a tracking structure to observe a system can change that system's own behavior — here, literally introducing the memory leak this chapter's own collector then fixes |
| A no-garbage program collects almost nothing (just the finished top-level script) | Confirms the collector is conservative and correct in the boring case, not just the dramatic one — a collector that swept something still reachable would be a far worse bug than one that's merely unnecessary |
Hands-On Exercises
Before running this chapter's own makeAdder example, predict by hand exactly how many objects will be on vm.heap once the program finishes (counting every Closure and Upvalue allocated by name), and which specific ones will survive a collect_garbage() call. Then run it and check your prediction against the actual heap contents.
Using sys.getrefcount(), confirm directly that one of the two discarded adder closures from this chapter's own example still has a positive Python reference count immediately before collect_garbage() runs, and explain specifically which line of code is responsible for that reference existing at all.
Rewrite this chapter's own makeAdder example so that all three calls' own results are stored in separate globals (a, b, and c) instead of two being discarded, then call all three. Run collect_garbage() and confirm how many objects it actually removes. Explain why the number isn't zero, even though nothing the program created is unreachable.
Chapter 8 Quick Reference
- Heap: every
ClosureandUpvalueever allocated, tracked inself.heapfrom the moment each is created - Roots: the value stack,
globals, each active frame's own closure, and every currently open upvalue - Mark: transitive — a closure marks its own upvalues; a closed upvalue marks a closure it happens to hold
- Sweep: anything on the heap left unmarked after
mark_roots()is removed; every mark resets for the next cycle - Verified: a real program with genuine garbage — 8 objects, exactly 5 correctly collected, exactly 3 correctly kept and still functional afterward
- Verified: a program with nothing genuinely unreachable collects almost nothing (just the finished top-level script's own closure)
- The twist, verified:
self.heapitself holds a real, counted Python reference — without this chapter's own collector, that append-only list would leak every discarded closure and upvalue for the life of the program, regardless of Python's own reference counting - Next chapter: Classes, Instances & Method Dispatch in Bytecode — the last major feature, verified against Course 1's own tree-walking dispatch