Viscosity
Review
Part X · Drip in Rust · Day 40

159. A garbage collector in Rust

drip-rs’s collector is the TypeScript one turned inside out. It never decides to run by itself, it is handed its roots, its mark bits live in a bitmap of their own, and its freed slots go on free lists. Then 95 programs run with a collection before every allocation, to catch the one root you forgot.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Map drip-rs’s collect, blacken and sweep to the TypeScript heap’s collector.
  • Explain why roots are passed as &dyn Trace values and why the mark bits live outside the objects.
  • Explain how free lists, stress mode and never-reused slots turn a missing root into a panic.
  • Write mark and sweep over an arena with a free list, with tests.

Who decides to collect?

Chapter 58’s TypeScript heap collects from inside allocate. It finds the roots through heap.vm, the compiler’s functions, and an extraRoots stack that heap.push(v) fills to protect a half-built value. Chapter 145 showed why drip-rs can’t: the heap is part of the VM and can’t borrow it back. So the caller decides. Adapted from src/vm.rs:

fn alloc<T: HeapObject>(&mut self, obj: T)
    -> Gc<T> {
    if self.heap.should_collect() {
        self.collect_garbage();
    }
    self.heap.alloc(obj)
}

Heap::alloc itself never collects. should_collect() is true once bytes_allocated passes next_gc, or always in stress mode. The price is one rule, in the comment on Vm::alloc: everything the new object refers to must already be reachable from a root. That is what chapter 158’s closure argument checked.

Intuition

A librarian clearing out a storeroom. She doesn’t guess what people still need; each reading room hands her its list of call numbers. She ticks them off on a clipboard of her own, not on the books, follows every “see also” to tick more, and then shelves away everything left unticked, writing each empty shelf number on a “free” list for the next delivery.

Roots are trait objects

Anything that can report the handles it holds implements one trait, from src/gc.rs:

pub trait Trace {
    fn trace(&self, marks: &mut Marks);
}

impl Trace for Value {
    fn trace(&self, m: &mut Marks) {
        match *self {
            Value::Closure(c) => m.mark(c),
            // ...an arm per object kind,
            // none for nil, bool, number
        }
    }
}

There are impls for Gc<T>, StrRef, Table, Option<T>, [T] and Vec<T>, so self.stack.trace(marks) marks a whole stack. The collector takes its roots as a slice of trait objects [2]:

pub fn collect(&mut self,
               roots: &[&dyn Trace])

dyn is needed because one call mixes types. The VM passes &[&roots], its VmRoots of borrowed fields (stack, frames, globals, open upvalues, the "init" string). The compiler passes &[self.roots, &self.functions]: whatever the VM gave it, plus its own Vec<FunctionState> of half-built functions (chapter 152). --disassemble, with no VM at all, passes &().

Try itRoots of different types in one slice
struct Marks {
    bits: Vec<bool>,
}

trait Trace {
    fn trace(&self, m: &mut Marks);
}

// A handle: an arena index.
struct Handle(usize);

impl Trace for Handle {
    fn trace(&self, m: &mut Marks) {
        m.bits[self.0] = true;
    }
}

impl<T: Trace> Trace for Vec<T> {
    fn trace(&self, m: &mut Marks) {
        for t in self {
            t.trace(m);
        }
    }
}

// No roots at all.
impl Trace for () {
    fn trace(&self, _: &mut Marks) {}
}

// Borrowed fields, as in VmRoots.
struct VmRoots<'a> {
    stack: &'a Vec<Handle>,
    globals: &'a Vec<Handle>,
}

impl Trace for VmRoots<'_> {
    fn trace(&self, m: &mut Marks) {
        self.stack.trace(m);
        self.globals.trace(m);
    }
}

fn mark(
    n: usize,
    roots: &[&dyn Trace],
) -> Vec<bool> {
    let mut m = Marks {
        bits: vec![false; n],
    };
    for r in roots {
        r.trace(&mut m);
    }
    m.bits
}

fn main() {
    let s = vec![Handle(0), Handle(2)];
    let g = vec![Handle(5)];
    let vm = VmRoots {
        stack: &s,
        globals: &g,
    };
    // The compiler's half-built work:
    let compiling = vec![Handle(3)];
    println!("{:?}",
        mark(6, &[&vm, &compiling]));
    println!("{:?}", mark(6, &[&()]));
}
Open in Rust Playground ↗

Mark bits outside the objects

TypeScript objects have an isMarked field in their header. Put a mark bit in the arena and try to mark what an object refers to:

fn blacken(&mut self, i: usize) {
    for &r in &self.nodes[i].refs {
        self.mark(r); // sets a mark bit
    }
}

rustc 1.97 (trimmed):

error[E0502]: cannot borrow `*self` as
  mutable because it is also borrowed
  as immutable
13 | for &r in &self.nodes[i].refs {
   |           ------------------- immutable
14 |     self.mark(r);
   |     ^^^^^^^^^^^^ mutable borrow

Tracing reads objects; marking writes bits. With both in one struct, the read borrow and the write borrow overlap. So drip-rs keeps the bits in a separate Marks value, one Vec<bool> per arena plus the gray stack, and lifts it out of the heap while it works. Adapted from Heap::collect:

let mut marks =
    std::mem::take(&mut self.marks);
marks.reset(self);
for root in roots {           // 1. mark
    root.trace(&mut marks);
}
while let Some(o) = marks.gray.pop() {
    self.blacken(o, &mut marks); // 2.
}
self.interned.retain_keys(    // 3.
    |s| marks.strings[s.handle.index()]);
self.sweep(&marks);           // 4.
self.marks = marks;

mem::take swaps an empty Marks in and hands back the real one [3]. Now blacken(&self, o, &mut marks) borrows the heap shared and the bitmap mutably, two separate values. reset resizes every bit vector to its arena and clears it, so each collection starts from a fresh bitmap, and sweep never has to unmark survivors, as the TypeScript sweep does. The vectors’ memory is reused from one collection to the next.

The gray stack holds a Gray, an enum with one variant per object kind that can refer to others. Strings and natives have none: marking one turns it black at once, where TypeScript pushes every object gray.

Step throughOne collection: bitmap, gray stack, free lists
Three arenas with their mark bits and free lists, the gray stack and the string tablemarks.reset(self)stringsfree: []0"Point"01"tmp"0classesfree: []0Point0instancesfree: []0a01b0gray: Vec<Gray>(empty)interned (weak)"Point", "tmp"Small boxes under the slots: the Marks bitmap, one bool per slot.roots: [&a], where a is instances slot 0.Dashed slot: freed (None).
The bitmap was taken out of the heap with mem::take, then reset: one false per slot, sized to each arena as it is now. Objects carry no mark bit of their own. The only root is a, instance 0.
1/7
Definition

drip-rs’s arena GC is mark and sweep over one arena per object type. Marking fills a mark bitmap, a Vec<bool> per arena kept outside the objects and cleared at the start of every collection, starting from roots passed in as &dyn Trace. Sweeping visits every arena slot, frees each live one whose bit is false, and pushes its index on that arena’s free list, which insert pops before growing the arena.

Sweep and the free list

Sweep runs the same loop over all eight arenas (stamped out by a small macro_rules!), freeing with Arena::remove, from src/object.rs:

pub fn remove(&mut self, index: usize,
              reuse: bool) -> Option<T> {
    let object = self.slots[index].take();
    if reuse && object.is_some() {
        self.free.push(index as u32);
    }
    object
}

The slot becomes None and the object is dropped, with its Box<str> or its tables. The TypeScript sweep unlinks the object from its list instead. Pacing is the same in both VMs: subtract what was freed from bytes_allocated, and set next_gc to twice what is left, but at least 1 MiB.

A collection before every allocation

A missing root is the classic collector bug, and it hides: it bites only if a collection runs at the wrong moment. Stress mode makes every moment that moment: should_collect() is always true, so the heap collects before every allocation. It also sets poison_freed, so remove never puts a slot on the free list. A stale handle then finds None and panics with “use after free”, instead of quietly reading whatever object moved in. That does the job of the TypeScript heap’s poisoning, which deletes a dead object’s fields.

tests/stress_gc.rs runs the 95 corpus programs in closure/, class/, inheritance/, string/, function/, super/, method/ and field/ this way. To see it catch something, I deleted one line from a scratch copy of drip-rs, self.open_upvalues.trace(marks) in VmRoots. The normal corpus suite still passed, 246 of 246. The stress suite panicked on its sixth program, class/local_reference_self.drip (reformatted a little):

{
  class Foo {
    returnSelf() { return Foo; }
  }
  print Foo().returnSelf();
}
test class/local_reference_self.drip
  ... FAILED
---- class/local_reference_self.drip
  panicked: use after free: an
  object of type ObjUpvalue was
  collected while still in use

The method captures the local Foo. At OP_CLOSURE, the new upvalue was reachable only from the open list, now unrooted, so the closure’s own allocation freed it. The backtrace showed the panic later, inside blacken, when a collection traced the method closure’s dangling handle.

Under the hood

A Vec<bool> spends a byte per mark bit, since bool is one byte [4], where a packed bitset would need an eighth of that. And reset and sweep visit every slot, freed ones too, where the TypeScript sweep walks only its list of allocated objects. In exchange, the bitmap is a plain value that the borrow checker can see is separate from the heap, and a slot index is enough to find any object’s bit. Both are choices a later optimization could revisit.

Your turn

ExerciseMark, trace, sweep

Here is a one-arena heap in drip-rs's style. A handle is an index into objs; a freed slot is None; alloc reuses an index from free before growing. Write collect(roots):

1. Make a fresh mark bitmap, vec![false; n], and a gray stack. Mark each root and push it gray. 2. Pop gray objects until none are left, marking and pushing each object they refer to that isn't marked yet. 3. Sweep: for every unmarked slot that holds an object, take it out (the slot becomes None), count it in freed, and push its index on free, unless poison is set.

The bitmap is a local, separate from self, so you can read self.get(h).refs while you write marks.

struct Obj {
    refs: Vec<usize>,
}

struct Heap {
    objs: Vec<Option<Obj>>,
    free: Vec<usize>,
    // Never reuse a freed slot.
    poison: bool,
    freed: usize,
}

impl Heap {
    fn new(poison: bool) -> Heap {
        Heap {
            objs: Vec::new(),
            free: Vec::new(),
            poison,
            freed: 0,
        }
    }

    fn alloc(
        &mut self,
        refs: Vec<usize>,
    ) -> usize {
        let obj = Some(Obj { refs });
        let top = self.free.pop();
        if let Some(i) = top {
            self.objs[i] = obj;
            return i;
        }
        self.objs.push(obj);
        self.objs.len() - 1
    }

    fn get(&self, h: usize) -> &Obj {
        let o = self.objs[h].as_ref();
        o.expect("use after free")
    }

    // Free every object the roots
    // can't reach.
    fn collect(
        &mut self,
        roots: &[usize],
    ) {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • In drip-rs, allocation never collects. The VM asks should_collect() and calls collect with its roots, so a new object’s references must already be reachable.
  • Roots are &dyn Trace values: the VM’s borrowed fields, the compiler’s functions, or ().
  • Mark bits live in a separate bitmap, taken out of the heap with mem::take and cleared at each collection, so tracing (reads) and marking (writes) never borrow the same thing.
  • Sweep frees unmarked slots and pushes them on per-arena free lists; the weak string table drops unmarked strings first.
  • Stress mode collects before every allocation and never reuses a slot, so a missing root panics at once. 95 programs run this way.

Check your understanding

0/6
  1. 1.

    Who decides when drip-rs collects garbage?

  2. 2.

    Why does collect take roots as &[&dyn Trace]?

  3. 3.

    What would go wrong if drip-rs kept a mark bit inside each object, in the arena?

  4. 4.

    After a sweep, why does drip-rs not unmark the survivors, as the TypeScript sweep does?

  5. 5.

    In stress mode, why must freed slots not be reused?

  6. 6.

    With open_upvalues removed from the VM’s roots, why did all 246 corpus programs still pass normally?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Project Developers. Trait objects (The Rust Reference). The Rust Reference; source in src/types/trait-object.md of github.com/rust-lang/reference. doc.rust-lang.org/reference/types/trait-object.html (checked 2026-10-31)
  3. [3] The Rust Project Developers. Function std::mem::take (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/mem/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/mem/fn.take.html (checked 2026-10-31)
  4. [4] The Rust Project Developers. Function core::mem::size_of (std::mem::size_of), Rust standard library documentation. Rust standard library API documentation; source in library/core/src/mem/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/mem/fn.size_of.html (checked 2026-10-31)