Slavik Shynkarenko

notebook № 097 plates · 16 min to read, longer to play

Who frees the memory?

On giving things back

Every program borrows memory and has to give it back. The interesting part is who decides when. This notebook walks through the main answers languages have found, from doing it by hand to letting a collector sweep up, from counting references to proving ownership at compile time, and ends with a table of which one fits what.

I

plate I · by hand

You free it yourself

A running program keeps its short-lived values on the stack, which tidies itself up whenever a function returns. Anything that has to outlive the function, or whose size isn’t known in advance, goes on the heap: a big shared pool where the program asks for a block and, sooner or later, has to hand it back.

The oldest answer to “who hands it back?” is you do. In C you call malloc to get a block and free to return it. Nothing checks your work. That makes it fast and completely predictable, and it makes three classic bugs possible.

Forget to free and the program leaks: it slowly eats memory until something gives. Free too early and you get a use-after-free: the old pointer now reads whatever moved in next. Free twice and the allocator’s own bookkeeping gets corrupted. Use-after-free alone is behind a large share of serious security holes in big C and C++ codebases.

who uses it C, Zig, and C++ under its smart pointers. Most operating systems, browsers, databases and game engines are built on it.

Run “free too early” to the end. Where did the PIN come from?

fig. 9.1~/malloc-freechoose a mistake, step through
A simulation of a tiny heap with sixteen blocks. A real allocator reuses freed blocks much the same way, which is exactly why an early free shows somebody else’s data.
II

plate II · tracing

The janitor walks the heap

A tracing garbage collector flips the question round. The program just allocates and never frees. Every so often the collector starts from the roots (local variables, globals), follows every pointer it can reach and marks what it finds. Whatever it didn’t reach can’t be used again, so it’s swept away. That’s mark and sweep.

Leaks of the forgotten-free kind disappear, and so do use-after-free and double frees. Cycles are no problem either: two objects pointing at each other but at nothing reachable simply don’t get marked.

The price is paid in two currencies. Memory: a collector needs room to let garbage pile up between collections. And time: marking costs work in proportion to what’s alive, and simple collectors stop the program while they do it. Modern ones (Go’s, Java’s G1 and ZGC, .NET’s, V8’s in Chrome) do most of the work alongside the program and split the heap into young and old generations. ZGC advertises pauses under a millisecond. You still pay in CPU, you just pay in small coins.

who uses it Java and Kotlin, C#, Go, JavaScript, Ruby, OCaml, Haskell, Dart and most Lisps.

Slide headroom down to 1.2× and watch the red stripes. Then try 3×.

fig. 9.2~/mark-sweepheadroom, or collect now
A simulation of a stop-the-world mark-and-sweep collector. Bright dots are reachable, dashed rings are garbage the program can no longer reach and the collector hasn’t found yet. The strip below is time: red is the program paused.
III

plate III · counting

Everyone keeps a tally

Reference counting takes the opposite approach. Every heap object carries a number: how many references point at it. Copy a reference and the number goes up. Drop one and it goes down. At zero, the object is freed on the spot, no collector, no pause, and you know exactly when it happens.

That promptness is why Apple chose it. Swift and Objective-C use automatic reference counting (ARC): the compiler writes the increments and decrements for you. CPython counts too, which is why a file you stop using in Python usually closes right away.

Two catches. Every copy of a reference costs a write to memory, and atomic writes if threads share the object. And a cycle is invisible to it: a person who points at a flat whose tenant points back at the person keeps both counts at one forever. Swift asks you to mark one side weak. Python and PHP run a separate cycle collector now and then, just to find them.

who uses it Swift and Objective-C (ARC), Python, PHP, Perl, C++ shared_ptr, Rust’s Rc and Arc.

Run “a cycle”, then “a cycle, one side weak”. What changed when the last line ran?

fig. 9.3~/ref-countschoose a program, step through
Swift-style code, simplified. The number on each object is its reference count; dashed arrows are weak references, which don’t count.
IV

plate IV · ownership

One owner, many borrowers

Rust asked whether the compiler could do C’s bookkeeping, correctly, before the program ever runs. Its answer is ownership. Every value has exactly one owner, and when the owner goes out of scope the value is freed, right there, with a call the compiler puts in for you.

Hand a value to someone else and ownership moves: the old name is dead, and using it is a compile error, not a crash at 3 a.m. To let code look at a value without taking it, you lend it out as a borrow, &s. The borrow checker enforces one rule: many readers or one writer, never both, and no borrow outlives the value.

The reward is C’s speed and predictability without its three bugs, and safe sharing between threads too. The cost lands on the programmer: some perfectly good designs, graphs and doubly linked lists especially, have to be reshaped to please the checker, or reach for Rc and RefCell, which bring counting back.

who uses it Rust. C++’s unique_ptr and RAII are the unchecked ancestor; Swift and Mojo have since added ownership features of their own.

Try “use after move”, then “two writers”. Both are caught before anything runs.

fig. 9.4~/borrow-checkchoose a program, step through
Real Rust; the two error messages are the ones rustc prints, shortened. Stepping shows who owns the string at each line and where the compiler frees it.
V

plate V · compile-time counting

Counting at compile time

Lobster, a language for writing games, asked a gentler question than Rust: what if we keep reference counting, but let the compiler skip every count it can prove is pointless? It calls the result compile-time reference counting.

While it type-checks, the compiler picks one owner for each new allocation and treats every other use as a borrow, much like Rust. Borrows need no counting at all. The difference is what happens when the analysis can’t make it work: where Rust stops with an error, Lobster quietly inserts a single count update for that one use and carries on.

Its author reports that this removes around 95% of the runtime count operations. The rest stay, so memory is still freed the moment the last owner lets go, with no collector and no pauses, which is what a game running at 60 frames a second wants. Cycles still leak, so Lobster prints a cycle report when the program exits, telling you where to break them.

Miri, the GPU-first language I’m building, belongs to this family, even though its compiler calls the pass Perceus. What it actually runs is non-atomic counts on heap blocks. Parameters are lent by default, and a whole-program pass infers which ones must be owned. Locals borrow out of those owned roots, and out is the only way to get a mutable reference. It does this on its mid-level IR rather than in the type checker, which keeps the compiler’s verifiers in play, and on the workloads where it matters it removes about as many count updates as Lobster reports.

What Miri adds is the part that matters most for buffers: structs and collections have copy-on-write value semantics. Assignment shares the memory, the first write to a shared value copies it, and the uniqueness checks behind that are hoisted or deleted by later passes. As for cycles, the compiler refuses the most direct kind, an object stored into its own field.

who uses it Lobster, and Miri with copy-on-write values on top. The same goal, counting less by proving more, drives Swift’s ARC optimiser and the Perceus family of plate VI.

Turn on the analysis, then add “keep a target list”. One +1 comes back. Why that one?

fig. 9.5~/count-elisionanalysis on or off
An illustration in pseudo-code, not real Lobster. The counts are for this one loop; the 95% is the figure Lobster’s documentation gives for its own programs.
VI

plate VI · perceus

Counting precisely, reusing in place

Perceus, from Microsoft Research and first built for the Koka language, makes reference counting precise. The compiler writes every count update itself and drops each reference straight after its last use, not at the end of the scope. The paper calls this garbage free: at any moment, only memory that will still be used is kept.

Then comes the trick. Functional code never changes data; it builds new data from old. Mapping over a list allocates a new cell for every old one, and the old ones die. But if an old cell’s count is one at the moment it dies, nobody else can see it, so its memory is reused for the new cell, in place. Purely functional code gets the speed of mutation. The authors call it functional but in-place.

Lean 4, the proof assistant and language, uses the same family of techniques (its creator co-wrote the Perceus paper). Cycles stay reference counting’s blind spot here too: Perceus frees everything else promptly, and leaves cycles to the programmer.

Miri’s compiler names its counting pass after Perceus, but what it runs is closer to Lobster’s ownership inference, with copy-on-write values on top: that’s in plate V.

who uses it Koka and Lean 4.

Run all three modes, then tick “list is shared” and run “+ reuse” again.

fig. 9.6~/map-in-placemode · shared · run
A simulation of map(xs, x => x + 1) over five cells. The code is map with the counting each mode inserts, in a simplified form of the Perceus paper’s notation; the lit lines are the path taken.
VII

plate VII · the table

Which one, for what

Line them up and the pattern is plain: each model chooses who pays, and when. Manual memory charges the programmer. A tracing collector charges memory and a little time, in batches. Reference counting charges a small fee on every copy. Ownership charges the programmer once, at compile time. Lobster and Perceus try to keep counting’s promptness while sending most of its bill to the compiler.

fig. 9.7~/comparehow it works · where it fits
How each model works
ModelWho uses itFrees memoryYou writeRun-time costPausesCyclesSafety
ManualC, Zig, C++ (raw)When you call freeEvery free, at the right momentNone beyond the allocatorNoneYour jobLeaks, use-after-free, double free
Tracing GCJava, C#, Go, JavaScript, Ruby, HaskellLater, in batches, when the collector runsNothingMarking work, write barriers on pointer stores, 1.5–3× memory headroomShort and concurrent in modern collectorsCollectedMemory-safe
Reference countingSwift, Objective-C, Python, PHP, shared_ptrThe moment the count hits zeroweak on back-pointersA count update on every copy; atomic if shared across threadsNone, but a big structure can cascadeLeak, unless weak refs or a cycle collectorMemory-safe
Ownership & borrowingRustWhen the owner goes out of scopeCode the borrow checker acceptsNoneNoneHard to build; leak with RcProven at compile time, threads included
Compile-time RCLobster, Miri (with copy-on-write values)The moment the last owner lets goNothing, almost alwaysLobster reports about 5% of plain RC’s count updates remainNoneLeak; Lobster reports them at exit, Miri refuses direct self-cyclesMemory-safe
PerceusKoka, Lean 4Right after the last useNothingPrecise counts; unique data reused in placeNoneLeakMemory-safe
Where each model fits
ModelWeb & backend servicesmemory-safe · quick to write · leaks unlikelyGames & real-timeno pauses · low CPU overhead · quick to writeSystems & embeddedno collector · no hidden cost per copy · memory-safeData & GPU pipelineshigh throughput · buffers freed promptly · quick to writeBest for
Manual0 of 3misses: memory-safe, quick to write, leaks unlikely2 of 3misses: quick to write2 of 3misses: memory-safe2 of 3misses: quick to writeKernels, allocators and drivers, where nothing else may run
Tracing GC3 of 3has all three1 of 3misses: no pauses, low CPU overhead1 of 3misses: no collector, no hidden cost per copy2 of 3misses: buffers freed promptlyServices and apps where shipping fast beats exact timing
Reference counting2 of 3misses: leaks unlikely2 of 3misses: low CPU overhead2 of 3misses: no hidden cost per copy2 of 3misses: high throughputUI apps and anything holding files, sockets or GPU handles
Ownership & borrowing2 of 3misses: quick to write2 of 3misses: quick to write3 of 3has all three2 of 3misses: quick to writeSystems software that must be fast and safe at once
Compile-time RC2 of 3misses: leaks unlikely3 of 3has all three2 of 3misses: no hidden cost per copy3 of 3has all threeGame code that wants easy memory without frame hitches
Perceus2 of 3misses: leaks unlikely3 of 3has all three2 of 3misses: no hidden cost per copy3 of 3has all threeFunctional and data-transform code that should run like imperative code
Under each kind of work are the three things it needs most (my picks). The dots count how many of those the model provides, read off the first table, and the note names what’s missing. They rate the memory model, not any one language’s libraries. The memory headroom is a rule of thumb; real collectors are tunable.

The lines blur in practice. Python counts and traces. Rust programs reach for Rc when a graph needs several owners. C++ mixes manual memory, RAII and shared_ptr in one codebase. Even the theory says they’re relatives: a 2004 paper showed tracing and counting are two ends of one spectrum, and every real collector sits somewhere in between.

If you remember one thing, make it this: memory is always freed by someone, at some time, for some price. The interesting choice is which of the three you’d rather fix. The compiler notebook shows where in the pipeline passes like Perceus do their work.

Switch to “where it fits” and pick the column closest to what you build. Which row would you have guessed?

Questions people ask

What is the difference between garbage collection and reference counting?

A tracing garbage collector runs now and then, follows every pointer from the program’s roots and frees whatever it didn’t reach, so it handles cycles but needs spare memory and some pause time. Reference counting keeps a count on every object and frees it the moment the count hits zero: prompt and predictable, but it pays on every copy and leaks cycles unless something else catches them.

What is Perceus reference counting?

Perceus is a reference-counting scheme from Microsoft Research, first built for the Koka language. The compiler inserts the count updates itself and drops each reference right after its last use, so no garbage is kept around. When an object’s count is one at the moment it dies, its memory is reused in place for the new value, which lets purely functional code update data in place.

Does Rust have a garbage collector?

No. Rust’s compiler gives every value one owner and frees it when the owner goes out of scope, and the borrow checker proves at compile time that no reference outlives the value. For data that really needs several owners, the standard library offers reference-counted pointers, Rc and Arc, which the programmer picks explicitly.

Related material

elsewhere in the notebooks

further reading

← all notebooks