Back to blog

57 new() Calls, 2 free() Calls

the garbage collector was doing more than i thought

so i've been porting Pegasus from Go to Odin. Pegasus is a PEG parser generator — it takes a grammar, compiles it to bytecode, and runs input through a little VM. nothing crazy, but enough moving parts that memory management actually matters.

in Go this was a non-issue. you new() things, you pass pointers around, and the GC cleans up after you like a responsible adult. i never thought about it. that's kind of the point.

when i started the Odin port i kept the same habits. need a node? new(). need a slice of children? make(). it compiled, it ran, it even produced correct output. i was feeling good about it.

then i turned on the tracking allocator.

57 new, 2 free

Tracking Allocator Summary:
  Total allocations: 57
  Total frees:       2
  Leaked allocations: 55
  Leaked bytes:      14,832
  Peak usage:        16,104 bytes

  Leak #1: ast.odin:34 — new(AST_Node) — 128 bytes
  Leak #2: ast.odin:34 — new(AST_Node) — 128 bytes
  Leak #3: ast.odin:35 — make([dynamic]^AST_Node) — 64 bytes
  ... (52 more)

fifty-five leaks. the tracking allocator lit up like a christmas tree. every single AST node, every dynamic array of children, every intermediate result from the parser — all leaked. the two frees were the grammar struct and the bytecode compiler, which i'd been careful about because they were the "important" ones. turns out they all are.

the code that produced this mess looked like what you'd write in Go:

// the Go-brain approach
parse_sequence :: proc(p: ^Parser) -> ^AST_Node {
    node := new(AST_Node)
    node.kind = .Sequence
    node.children = make([dynamic]^AST_Node)

    for !at_end(p) {
        child := parse_element(p)
        append(&node.children, child)
    }

    return node
}

every call to parse_element does its own new(), which does its own new() calls deeper down. it's allocations all the way down and nobody is freeing anything. in Go this is fine. in Odin this is a memory leak factory.

two lifecycles, two arenas

the fix was staring at me once i actually thought about when things need to exist. there are really only two lifecycles in Pegasus:

long-lived stuff — the grammar definition and compiled bytecode. these get created once at startup and live until the program exits. basically singletons.

short-lived stuff — AST nodes, intermediate parse results, temporary strings. these get created during a parse and are useless after you've extracted the result.

arenas are perfect for this. instead of tracking every individual allocation, you just nuke the whole arena when you're done with that lifecycle.

Pegasus :: struct {
    // long-lived: grammar + bytecode
    permanent_arena: virtual.Arena,

    // short-lived: per-parse scratch space
    parse_arena: virtual.Arena,
}

pegasus_init :: proc(p: ^Pegasus) {
    virtual.arena_init(&p.permanent_arena)
    virtual.arena_init(&p.parse_arena)
}

pegasus_destroy :: proc(p: ^Pegasus) {
    virtual.arena_destroy(&p.permanent_arena)
    virtual.arena_destroy(&p.parse_arena)
}

now the parser passes the arena allocator down instead of using the default:

parse_sequence :: proc(p: ^Parser) -> ^AST_Node {
    allocator := virtual.arena_allocator(&p.pegasus.parse_arena)

    node := new(AST_Node, allocator)
    node.kind = .Sequence
    node.children = make([dynamic]^AST_Node, allocator)

    for !at_end(p) {
        child := parse_element(p)
        append(&node.children, child)
    }

    return node
}

and after each parse, you just reset the arena:

parse :: proc(p: ^Pegasus, input: string) -> Parse_Result {
    // reset scratch space from last parse
    free_all(virtual.arena_allocator(&p.parse_arena))

    result := run_parser(p, input)
    // extract what you need from the AST before it gets wiped
    return result
}

the tracking allocator agrees

Tracking Allocator Summary:
  Total allocations: 4
  Total frees:       4
  Leaked allocations: 0
  Leaked bytes:      0

four allocations, four frees, zero leaks. the four are: two arenas (init + destroy each), and that's it. all fifty-seven of those individual new() calls still happen inside the arena, but the arena handles them as one bulk operation.

the thing that surprised me is how little the code actually changed. the parser logic is identical — same structs, same flow, same everything. the only difference is passing allocator as the last argument to new() and make(). Odin makes this stupid easy because every allocation proc takes an optional allocator parameter. you don't need to refactor your types or wrap things in smart pointers or any of that.

i think the real lesson is that Go's GC wasn't just "handling memory" for me — it was hiding the fact that i had no mental model of when things should live and die. porting to Odin forced me to actually think about it, and the code is better for it. two lifecycles, two arenas, zero leaks.