Home About Me

A Different Way to Think About Go’s Garbage Collector

I’ve written about Go’s garbage collector before, but only in a fairly compressed, high-level way. So why revisit it? Partly because it’s worth another look, and partly because retelling the full GC pipeline from start to finish isn’t the most interesting way to approach it.

Go’s GC can sound simple when you summarize it quickly, but once you start looking closer, a lot of details show up. Rather than repeat the standard walkthrough, it makes more sense to look at a few specific questions that reveal how the collector actually behaves.

Four questions worth asking

When digging into Go’s garbage collector, these are the kinds of questions that come up:

  1. If two objects reference each other, can they become impossible to collect?
  2. Why does Go’s marking process feel more like BFS than DFS?
  3. Is it possible for GC to never run at all?
  4. Why is Go’s GC neither compacting nor generational?

None of these questions has to come with one official answer. What follows is simply a way of understanding the design choices and behavior.

Cyclic references do not trap objects forever

A common confusion starts here: people hear that Go does not allow cyclic package imports, and then wonder whether cyclic object references are also a problem. They are not the same thing.

Take this example:

package main

type User struct {
    Name string
    Info *UserInfo
}

type UserInfo struct {
    Age int
    U   *User
}

func main() {
    go func() {
        user := &User{
            Name: "LinkinStar",
        }
        userInfo := &UserInfo{
            Age: 24,
        }
        user.Info = userInfo
        userInfo.U = user
    }()
}

This compiles without any issue, and the two objects clearly point to each other. But that does not mean the GC will fail to reclaim them.

The reason is straightforward: Go’s GC is not based on reference counting. It does not decide whether an object lives or dies by tallying how many pointers refer to it. Instead, it starts from the root set and traces reachable objects outward.

So if two objects only point to each other, but nothing reachable from the roots points to them anymore, both become unreachable and can be collected.

object reachability diagram

In a graph like this, if A and D reference each other but are no longer reachable from any root, then both are garbage. Mutual references alone are not enough to keep them alive.

Why the marking process is closer to BFS than DFS

BFS is breadth-first search, DFS is depth-first search. In Go’s tri-color marking model, traversal proceeds layer by layer, which naturally raises the question: why this shape instead of a depth-first walk?

There is no single formal answer here, but a few points make the choice feel reasonable.

1. There are many objects, but reference depth is usually not very large

In GC workloads, the number of objects can be huge, but the reference chains between them are often not especially deep. If you imagine the object graph as a tree, it tends to be broad, with many roots or near-root references, rather than extremely tall.

2. Reference changes often happen near the leaves

In real programs, mutations tend to occur toward the lower parts of the object graph. With a DFS-style strategy, you may end up marking deep structures early, only to have references change in ways that make parts of the previous traversal less useful. A broader traversal order can fit concurrent marking more naturally.

3. DFS commonly implies recursion or deep stack management

A recursive DFS means function calls, stack growth, and stack unwinding overhead. That is not an attractive fit for a runtime component that has to handle massive object graphs efficiently and repeatedly.

So while this is not a strict proof of why Go does it this way, BFS-style progression aligns well with the shape of object graphs and with the needs of a concurrent collector.

Can GC fail to trigger forever?

Normally, Go’s GC can be triggered in several ways:

  1. The heap reaches the GC growth threshold.
  2. Enough time passes—roughly two minutes via sysmon.
  3. runtime.GC() is called.

At first glance, that sounds exhaustive. But there actually is a situation where GC can fail to make progress.

Consider this code:

package main

import (
    "runtime"
    "time"
)

func main() {
    go func() {
        i := 0
        for i < 10 {
            i--
            i++
        }
    }()

    go func() {
        array := make([]int, 1000)
        for {
            array = make([]int, 1000)
            array = append(array, 1)
            time.Sleep(time.Millisecond * 10)
        }
    }()

    runtime.GC()
    time.Sleep(time.Second * 60)
}

If you run this with GODEBUG="gctrace=1", you may find that nothing gets printed.

The key is this part:

go func() {
    i := 0
    for i < 10 {
        i--
        i++
    }
}()

For GC to start safely, Go needs goroutines to reach a safe point. In simple terms, a safe point is a place where the runtime can reliably stop or inspect execution, and function calls are one of the typical ways to get there.

This loop contains no function calls at all. That means it may fail to provide a safe point, and the runtime cannot proceed with GC the way it wants to. Once you add any function call inside that loop, GC can trigger again.

That is also a practical warning: avoid writing infinite or effectively non-terminating loops with no function calls inside them. In real-world code this is uncommon, but when it happens, it can interfere with runtime behavior in surprising ways.

Why Go’s GC is neither compacting nor generational

The runtime source states this very clearly:

  • The GC runs concurrently with mutator threads, is type accurate (aka precise), allows multiple GC thread to run in parallel. It is a concurrent mark and sweep that uses a write barrier. It is non-generational and non-compacting. Allocation is done using size segregated per P allocation areas to minimize fragmentation while eliminating locks in the common case.

So the design is explicit: no generations, no compaction.

Generational GC would help efficiency—but adds complexity

A generational collector can be very effective because most objects die young, while only a smaller set survives for a long time. That is exactly why generational GC is so common in systems like Java.

So why not in Go?

One obvious reason is complexity. A generational design can improve performance, but it also makes the collector much more complicated to implement and maintain. Go’s existing GC is already good enough for many workloads, and runtime design is always about trade-offs, not abstract perfection.

Compaction helps memory layout—but Go pushes that concern elsewhere

Compaction is useful because it reduces fragmentation and can reclaim pages more cleanly after objects die. Without compaction, freed memory can leave holes behind.

But in Go, part of that concern is handled by the memory allocator rather than by the GC itself. Allocation is already organized with size classes and per-P allocation areas, aiming to minimize fragmentation up front. In that model, GC focuses on reclaiming dead objects, while memory management handles layout and reuse.

So the answer is not that generational collection and compaction are useless. It is that Go chose a simpler runtime architecture where those benefits were weighed against implementation cost and the behavior of the allocator.

One tool that makes GC easier to inspect

Printing GC logs with gctrace is useful, but sometimes raw logs are not enough. If you want a more visual way to inspect runtime behavior, Go has built-in tracing support.

You can add this to your program:

package main

import (
    "os"
    "runtime/trace"
)

func main() {
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }
    defer f.Close()
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()

    // Your program here
}

After letting the program run for a while, use:

go tool trace trace.out

Then open the generated trace view in the browser, and you can inspect GC activity across the whole program in a much more intuitive way.