← History

No Garbage Collector: the systems creed

Why systems languages refuse a garbage collector - the determinism and control you buy, the manual discipline you pay, and the spectrum of help from Forth's raw cells to C++'s RAII.

CC++ZigHareOdinForth

A garbage collector is a wonderful thing, and every language on C-lingua refuses to have one. That refusal is not nostalgia or machismo - it is the defining design choice of a systems language, the line that separates "the runtime decides when memory comes back" from "you decide, exactly, every time." This article is about that choice: what a GC actually does, why systems programmers reject it, what you gain (determinism, no pauses, control, a small footprint), what you pay (manual discipline and a class of bugs the GC would have eaten for you), and the surprisingly wide spectrum of help these seven languages offer in return - from Forth handing you raw cells to C++ running destructors on your behalf.

The thesis is simple and it runs through every section: a garbage collector trades control for safety, and a systems language makes the opposite trade. Everything below is an elaboration of that one sentence.

What a garbage collector actually does

Strip away the marketing and a tracing garbage collector is a background process that periodically answers one question: which heap objects can the program still reach? Starting from a set of roots (globals, the stack, registers), it walks every pointer, marks everything reachable, and reclaims the rest. The unreachable objects are, by definition, garbage - the program can never name them again - so freeing them is safe without the program ever saying free.

That is genuinely powerful. It eliminates use-after-free (you cannot free what is still reachable), eliminates double-free (the collector frees, never you), and eliminates the most common leak (forgetting to free reachable-then-orphaned objects). In exchange it demands three things a systems programmer is often unwilling to give:

  1. A runtime that runs your program and the collector, sharing the CPU.
  2. The right to pause your code - sometimes the whole program ("stop-the-world") - to walk the heap safely.
  3. Headroom: tracing collectors typically need a heap several times larger than the live set to amortize collection cost, and they decide when to reclaim, not you.

None of those is free, and in a systems context none of them is acceptable by default. The rest of this article is what you build instead.

The case against GC: determinism, pauses, control, footprint

Determinism: cleanup happens here, not eventually

In a manual language, an object's last byte is reclaimed at a point you can name in the source. In a GC language, it is reclaimed "sometime after it becomes unreachable, when the collector next runs." For pure memory that distinction is academic - but memory is rarely the only resource. A file handle, a socket, a mutex, a GPU buffer, a database transaction: these must be released promptly and in order, and a garbage collector, which only understands memory pressure, is the wrong tool to time their release. This is precisely why GC languages bolt on a second, manual mechanism for non-memory resources (try-with-resources, using, defer, finalizers-that-you-shouldn't-rely-on). Systems languages use one mechanism for everything because cleanup is deterministic to begin with.

// C++: the destructor is the single deterministic cleanup point - 
// for memory AND for the file, mutex, socket it might also hold.
{
    std::ifstream f("config.toml");   // file opened here
    auto buf = std::make_unique<char[]>(4096);
    // ... use f and buf ...
}   // <-- RIGHT HERE, in order: ~unique_ptr frees buf, then ~ifstream closes f.
    //     Not "eventually." Not "at the next GC." Here.

Pauses: latency you cannot schedule

A garbage collector reclaims memory on its schedule, and to do so it may pause your threads. Modern collectors are extraordinary - concurrent, generational, with sub-millisecond targets - but "sub-millisecond, usually" is a different promise from "never." For a batch job, a GC pause is invisible. For the things systems languages are written for - an audio callback that must fill a buffer every 5.8 ms or the speakers click, a game frame that has 16.6 ms to do everything, an engine-control unit, a high-frequency trading path, a kernel interrupt handler, a robot's control loop - a pause you did not schedule and cannot bound is a defect. The systems answer is to make memory management have no scheduler of its own at all: it happens synchronously, where you wrote it, taking exactly as long as the work in front of you.

Control: you know the layout, the cost, and the timing

Manual memory is not only about when - it is about what and where. With no collector you are free to put an object on the stack, inline it into its parent struct, pack a thousand of them contiguously for the cache, allocate them all from one arena and drop the arena, or hand a pointer to hardware that will DMA into it. A tracing GC constrains all of this: it generally needs every managed object on the heap, often needs object headers for its bookkeeping, may move objects to compact the heap (so a raw pointer you handed to a device driver is now dangling), and obscures the cost of an allocation behind the collector's amortized accounting. Systems languages keep the cost model legible: an allocation is a call you can see, a free is a call you can see, and the bytes are laid out the way you asked.

Footprint: pay for the live set, not a multiple of it

A GC buys its throughput with space. Collect too often and you spend all your time tracing; collect rarely and the heap balloons to several times the live data. On a server with 512 GB that is a tuning knob; on a microcontroller with 64 KB of RAM, a bootloader, or a kernel that is the memory manager, it is a non-starter. Manual memory uses what is live plus the allocator's modest overhead - which is why Forth historically fit an entire interpreter, compiler, and application in a few kilobytes, and why C and its descendants run on everything from a smart lightbulb to a supercomputer.

What you pay: the discipline and its failure modes

Honesty demands the other side of the ledger. A garbage collector exists because manual memory is hard, and removing it does not make the hard part disappear - it moves the hard part onto you. The collector was silently preventing a family of bugs; without it, those bugs are yours to prevent. Each is a violation of two questions you must answer for every allocation: who owns this, and until when is it valid?

char *p = malloc(16);
free(p);
strcpy(p, "oops");   /* USE-AFTER-FREE: p points to reclaimed memory */
free(p);             /* DOUBLE-FREE: undefined behavior */

This is the deal a systems language offers: you take responsibility for the bugs the collector would have prevented, and in return you get determinism, no pauses, full control, and a small footprint. The languages differ enormously in how much they help you hold up your end - and that spectrum is the rest of the article.

The spectrum of help

Refusing a GC does not mean refusing all assistance. Arrange the seven languages by how much the language does to keep your manual discipline honest, and a clear gradient appears:

Notice what stays constant across the whole gradient: none of them adds a garbage collector. Cleanup is always something you can point to in the source - a free, a defer, a destructor, an arena reset. That is the creed. Now the languages, in order of increasing assistance.

Forth: you are the allocator

Forth is the bottom of the spectrum and proud of it. There is no type system and, in the base language, barely an allocator. Memory is just cells (machine words) at addresses (numbers), read with @ (fetch) and written with ! (store). The oldest, most primitive allocation is the dictionary itself: HERE is the address of the next free byte, ALLOT advances it to carve out space, and CREATE names the result. Space carved this way lives for the program's life - there is no individual free.

CREATE BUF  256 CELLS ALLOT   \ name a 256-cell buffer in data space
42 BUF !                      \ store 42 in the first cell ( ! = store )
BUF @ .                       \ fetch ( @ ) and print -> 42

For genuinely dynamic memory there is the optional heap word set - ALLOCATE, FREE, RESIZE - mirroring C's malloc/free/realloc. Tellingly, these do not use a sentinel like NULL; they push an address and an ior (I/O result) status, and THROW turns a nonzero status into an exception. Every ALLOCATE is paired by hand with exactly one FREE:

\ Heap allocation via the OPTIONAL ALLOCATE/FREE word set.
: DEMO ( -- )
  1 CELLS ALLOCATE THROW   ( -- addr )      \ one cell; THROW on nonzero ior
  42 OVER !                ( addr -- addr )  \ store 42 at addr
  DUP @ .                  ( addr -- addr )  \ fetch and print -> 42
  FREE THROW               ( addr -- )       \ hand the block back; check ior
;
DEMO

There is no safety net of any kind: an address is just a number, a wrong @/! reads or writes raw memory silently, and there is nothing to forget to free because there is nothing keeping track. Forth does not reject the garbage collector so much as it rejects abstraction over memory altogether - which is exactly why it has lived in boot firmware and kilobyte-class embedded targets for fifty years.

C: the honest, minimal contract

C is the reference point for the whole field. Its heap API is tiny and brutally honest: malloc(size) returns at least size uninitialized bytes or NULL; calloc(n, size) zeroes them; realloc resizes, possibly moving the block; free returns it. The compiler enforces none of the rules - they live in your head and in conventions.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);   /* size from the pointer, not the type */
    if (p == NULL) {              /* malloc CAN fail: always check */
        perror("malloc");
        return 1;
    }
    *p = 42;
    printf("%d\n", *p);

    free(p);                      /* YOU free what YOU malloc */
    p = NULL;                     /* defuse the dangling pointer */
    return 0;
}

C's entire reason for having no GC is that it was built to write the things a GC would run on top of - Unix, kernels, allocators themselves. You cannot implement malloc in a language that assumes malloc already exists and a collector is sweeping behind you. The discipline is unaided but the cost model is perfectly transparent: every allocation and every free is a visible call, the layout is exactly what you declared, and there is no runtime between you and the machine. The price is that the failure modes above are entirely yours - which is why the cheapest defensive habit is p = NULL after free (so a stray reuse crashes loudly on NULL instead of corrupting silently) and why you build with AddressSanitizer (-fsanitize=address) or Valgrind, external tools that instrument every access and report the exact line of a leak, overflow, or use-after-free.

HolyC: classic C, per-task heaps, ring 0

HolyC is Terry A. Davis's C dialect, the native language of TempleOS - a complete operating system (kernel, 64-bit JIT compiler, editor, language) that Davis wrote essentially alone, an undeniably remarkable feat of single-handed systems engineering. Its memory model is classic C with capitalized builtins and one structurally interesting twist. Allocation is MAlloc (uninitialized) and CAlloc (zeroed); release is Free, and Free(NULL) is a safe no-op. Because the JIT is the shell, top-level statements run directly - there is no required main:

// Runs directly at the TempleOS prompt as the file compiles - no main() needed.
I64 *p = MAlloc(sizeof(I64));   // 8 bytes from THIS task's data heap
*p = 42;
Print("%d\n", *p);             // prints 42
Free(p);                        // return it; Free(NULL) is allowed

The twist is that the heap is per-task. MAlloc draws from the current task's data heap by default, so an allocation's lifetime can be bound to its task: when the task dies, its entire heap is reclaimed wholesale, and a short-lived task is forgiven its individual leaks. This is the same idea as an arena - bulk reclamation of same-lifetime data - built into the task model rather than the library. (MSize reports the real allocated size, which rounds large requests up to a power of two, and HeapCtrlInit spins up an independent heap.)

What HolyC deliberately omits is protection: everything runs in 64-bit ring 0 in one flat address space with no virtual memory, by design - Davis conceived TempleOS as a transparent machine the hobbyist could fully understand, a modern Commodore 64. A GC would have been antithetical to that transparency, and so would an MMU; the cost, accepted openly, is that a double-free or wild write can corrupt the whole system with nothing to catch it. The model is honest, minimal, and entirely of a piece with the rest of the project.

Hare: C's model, with alloc and free as keywords

Hare keeps C's manual, GC-free, minimal-runtime model but folds allocation into the language. alloc and free are keywords: alloc(init) reserves storage and initializes it in one step, computes the size from the type automatically, and yields a typed pointer. defer keeps the matching free beside the allocation and runs it at scope exit.

use fmt;

export fn main() void = {
	let p: *int = alloc(42)!;  // heap int initialized to 42; size inferred; ! aborts on OOM
	defer free(p);             // manual ownership, defer'd release

	fmt::println(*p)!;         // prints 42
};

Two differences from C are worth naming. First, allocation failure is a checked nomem error: alloc yields a tagged union you must handle, and the idiomatic ! (used above) asserts it - aborting the program on exhaustion rather than ever handing you an unchecked null. You can instead propagate it with ?, or opt into a nullable result - let p: nullable *int = alloc(42); - and handle exhaustion yourself with a match. Either way the failure case is impossible to forget silently. Second, slices are auto-sized too, so you never write 64 * size(int) by hand. Ownership is still entirely yours to track; Hare's contribution is removing the ceremony - the size arithmetic, the always-checking, the easy-to-misplace free - while keeping the determinism. Its rejection of GC is part of a larger commitment to a tiny, freezable specification and a minimal runtime (it links straight through malloc when using libc): nothing hidden, nothing scheduled behind your back.

Zig: no hidden allocations, failure in the type system

Zig's rule is the most radical clarification of the no-GC stance: there is no global allocator and no hidden allocation anywhere in the language or standard library. Any code that allocates takes an std.mem.Allocator parameter, so the caller always chooses the strategy - and you can see, at every call site, exactly where memory is touched. Allocation can fail, so it returns an error union you must try; defer schedules the matching free, and errdefer runs only on the error path.

const std = @import("std");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();              // reports leaks at program exit
    const allocator = gpa.allocator();

    const p = try allocator.create(i32); // !*i32 - allocation may fail
    defer allocator.destroy(p);          // runs at scope exit, on every path

    p.* = 42;
    std.debug.print("{d}\n", .{p.*});    // prints 42
}

This goes beyond ergonomic C in two ways that directly address the "what you pay" ledger. First, allocation failure is in the type system: !*i32 will not compile if you ignore the error, so you cannot forget the case a GC language would have papered over. Second, the DebugAllocator (formerly GeneralPurposeAllocator) detects leaks, double-frees, and use-after-free in debug builds - the discipline a GC enforced at runtime is instead checked at test time, with the matching free verified rather than merely hoped for. And because the allocator is just a parameter, swapping std.heap.ArenaAllocator in means arena.deinit() frees everything at once with no per-object destroy - bulk reclamation as a first-class, drop-in choice. Zig's slogan, "if it isn't written, it doesn't happen," is the anti-GC creed stated as a language principle.

Odin: the implicit context allocator

Odin threads an implicit context through every Odin-convention procedure, and that context carries an allocator. The builtins new (one object), make (slice/map/dynamic array), free, and delete all route through context.allocator with no explicit argument - Zig's explicitness traded for convenience, while keeping the swappability.

package main

import "core:fmt"

main :: proc() {
	p := new(int)        // allocates via context.allocator; returns ^int
	defer free(p)        // returns it to that same allocator

	p^ = 42
	fmt.println(p^)      // prints 42
}

The payoff is that you can replace the allocator for a whole scope and every nested call inherits it, no parameter threading required - which makes arenas, the systems answer to "free a subsystem in one operation," the natural default:

import "core:mem/virtual"

arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
context.allocator = virtual.arena_allocator(&arena)
defer virtual.arena_destroy(&arena)   // ONE call frees the whole region
// every new/make below now allocates from the arena, then dies with it

Odin is emphatic that there is no RAII and no hidden destructor running behind your back - defer is the only cleanup hook, and it is explicit. This is a deliberate philosophy, not an omission: in a data-oriented language built for games and high-performance work, the programmer knowing precisely when memory dies is the whole point. The implicit context gives you reach without a runtime; the arena gives you bulk reclamation without a collector. Odin also ships context.temp_allocator, a growing arena you wipe with free_all(context.temp_allocator) once per frame.

C++: the most help, still no collector

C++ sits at the top of the spectrum and is the clearest proof that rejecting a GC need not mean rejecting safety. It inherits C's heap and adds the most important idea in manual memory management: RAII - Resource Acquisition Is Initialization. The raw primitives exist (new/delete, new[]/delete[], and C's malloc/free), and mismatching them is undefined behavior - but idiomatic C++ almost never writes them. Instead it ties each resource's lifetime to an object whose destructor performs cleanup, deterministically, on every exit path including an exception unwinding the stack.

#include <iostream>
#include <memory>

int main() {
    auto p = std::make_unique<int>(42);  // unique owner
    std::cout << *p << '\n';             // prints 42
    return 0;                            // ~unique_ptr runs delete HERE
}                                        // no leak, no double-free, no manual free

Ownership models are encoded directly in the type system:

That last point is the crux of the whole article. Reference counting is the one form of automatic reclamation systems languages do sometimes embrace - because, unlike tracing GC, it is deterministic (the free happens exactly when the count hits zero, where you can point to it) and needs no stop-the-world pause and no heap headroom. The price is the cost per reference operation and the inability to collect cycles. C++ offers it as an opt-in tool (shared_ptr), not a mandatory runtime. That is the systems posture in miniature: here is a powerful automatic mechanism, with its costs printed on the label, for the cases where you want it - and a unique_ptr or a plain stack object for the overwhelming majority where you do not.

The creed, stated plainly

Line the seven up by who is responsible for cleanup and how much the language helps:

From Forth's total exposure to C++'s automatic, destructor-driven RAII, the assistance varies by an order of magnitude - but the floor never moves: cleanup is always something you can point to in the source, never something a background collector decides for you. That is the trade, made openly and on purpose. A garbage collector gives you safety by taking the timing, the layout, and the cost out of your hands. A systems language gives you the timing, the layout, and the cost - and hands you the responsibility that comes with them. Whether that is a burden or a gift depends entirely on what you are building. For a kernel, an allocator, an audio engine, a bootloader, a game's frame loop, or an OS one man writes alone - it is unmistakably a gift, and refusing the collector is the first article of the creed.