Home
Dec 21, 202512 min read 1 view

Go Garbage Collector: The Deeper Dive

golanggarbage-collectionruntimememory-management

Back in November I wrote a very basic overview of the Go garbage collector and promised a more technical article once I had properly read the official Go GC Guide. I finally did, so here it is.

The goal of this article is to understand what the GC actually does during a cycle, what the famous write barrier is for, and what the two knobs (GOGC and GOMEMLIMIT) really control.

Mark and Sweep

Go uses a concurrent mark-sweep collector. No compaction, no generations, which surprises people coming from the JVM. A GC cycle has two phases:

  1. Mark: starting from the roots (globals, goroutine stacks), follow every pointer and mark every object you can reach.
  2. Sweep: everything that wasn’t marked is garbage, so its memory can be reused for future allocations.

Simple in theory. The hard part is that Go does the marking while your program keeps running. And that creates a correctness problem.

The Tri-Color Abstraction

To reason about concurrent marking, the runtime uses the classic tri-color model. Every object is in one of three sets:

  • White: not visited yet. If it’s still white at the end, it gets swept.
  • Grey: visited, but its children haven’t been scanned yet.
  • Black: visited, and all of its children have been scanned. Done.

Marking starts with the roots in grey and everything else white. The GC repeatedly takes a grey object, scans its pointers (making the white objects it points to grey), and turns it black. When there are no grey objects left, marking is done.

The invariant that makes this correct: a black object must never point to a white object. If that ever happens, the white object could be missed and freed while still in use, the worst possible bug.

The Write Barrier

Here’s the problem: your program is running during the mark phase, and it mutates pointers. It can easily do this:

1// obj is already black (fully scanned)
2// tmp is white (not seen yet)
3obj.field = tmp   // black → white pointer. Not allowed!

That’s where the write barrier comes in. During the mark phase, the compiler makes every pointer write go through a tiny piece of runtime code. Conceptually:

1func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
2    if gcPhase == marking {
3        shade(ptr)       // make sure the new target isn’t missed
4        shade(*slot)     // and the old one either
5    }
6    *slot = ptr
7}

Go uses a hybrid of the classic Dijkstra and Yuasa barriers, it shades both the new pointer and the one being overwritten. The nice consequence of this design is that goroutine stacks don’t need to be re-scanned at the end of the cycle, which older Go versions had to do inside a stop-the-world pause.

So the write barrier is the tax you pay so the GC can run concurrently: every pointer write is slightly more expensive during marking, and in exchange the pauses stay tiny.

What Actually Pauses

“Concurrent” doesn’t mean zero pauses. There are two very short stop-the-world moments per cycle:

  • One at the start of the mark phase, to enable the write barrier on all goroutines.
  • One at mark termination, to make sure marking is really finished before sweeping starts.

Both are typically well under a millisecond. The heavy work (scanning the heap) happens concurrently, using up to 25% of GOMAXPROCS. There’s a subtlety though: if your program allocates faster than the GC can mark, allocating goroutines get drafted into doing mark assist work themselves. That’s the hidden cost, GC pressure doesn’t show up as pauses, it shows up as your goroutines running slower during the cycle.

GOGC: The Pacing Knob

When does a cycle even start? That’s the pacer’s job, and GOGC is its main input.

GOGC=100 (the default) means: run the next GC when the heap has grown by 100% of what was live after the last cycle. If 50 MB survived the last collection, the next cycle starts around 100 MB.

  • Higher GOGC → GC runs less often → less CPU spent on GC, more memory used.
  • Lower GOGC → the opposite.

It’s a straightforward memory/CPU trade-off. Doubling GOGC roughly halves the GC frequency.

GOMEMLIMIT

Since Go 1.19 there’s a second knob: GOMEMLIMIT, a soft limit on the total memory the runtime uses. As the heap approaches the limit, the GC runs more and more aggressively to stay under it.

The combination that makes the most sense for a containerized service is the one the GC guide itself suggests:

1GOGC=100 GOMEMLIMIT=800MiB   # in a container with 1 GiB

Normal pacing when there’s plenty of room, aggressive collection instead of an OOM kill when memory gets tight. The limit is soft, if the live heap genuinely doesn’t fit, Go prefers thrashing the GC over crashing, so you should still leave headroom.

I set this on the VPS running this blog, mostly out of principle. A Go blog server allocates so little that the GC is basically idle, but now at least I know what the setting does.

Watching It Happen

The easiest way to see all of this live is:

1GODEBUG=gctrace=1 ./myapp

Each cycle prints one line:

1gc 14 @2.104s 0%: 0.018+1.2+0.021 ms clock, ...

That’s the two stop-the-world pauses (0.018 and 0.021 ms here) around the concurrent mark phase (1.2 ms). Seeing sub-millisecond pauses on a real heap makes the whole design click.

What I Took Away

  • Go’s GC optimizes for low latency, and pays for it with throughput (write barriers, mark assists) and memory (no compaction, heap needs headroom).
  • The real cost of GC pressure is invisible in pause metrics, it’s mark assist slowing down your allocating goroutines.
  • The best “GC tuning” is still allocating less. sync.Pool, preallocated slices and avoiding accidental escapes do more than any knob.

Thanks for reading.