open atlas
↑ Back to track
Go, zero to senior GO · 06 · 02

GC mechanics and tuning: tri-color mark-sweep, the pacer, GOGC and GOMEMLIMIT without myths

Go runs a concurrent tri-color mark-sweep GC with write barriers and two sub-ms STW pauses. GOGC trades heap headroom for cycle frequency; GOMEMLIMIT adds a soft ceiling that can death-spiral near the limit. No compaction, no generations — deliberate choices, not gaps.

GO Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The recommendation service got a cost-saving ticket: the pod requested 4 GiB but the live heap was only 1.2 GiB, so someone set the memory request to 1.5 GiB and added GOMEMLIMIT=1400MiB “to be safe.” It sailed through staging. In production, Friday’s catalog refresh briefly pushed live heap to 1.3 GiB — 93% of the limit — and the service didn’t OOM. It did something worse: it stayed up. The GC, cornered between a heap it couldn’t shrink and a ceiling it couldn’t cross, started running cycles back-to-back, burning 40%+ of CPU on marking. p99 went from 18 ms to 900 ms. Dashboards showed no crash, no OOM event — just a service quietly spending half its brain on garbage collection. The on-call engineer restarted pods for an hour before anyone graphed GC CPU. The limit wasn’t wrong by much: 200 MiB of headroom was the difference between a tuned service and a death spiral.

By the end of this lesson you’ll know where the real GC cost hides (it isn’t the pauses), how to read GOGC and GOMEMLIMIT without guessing, and how to spot the death-spiral before it pages you.

Tri-color mark-sweep, running while your code runs

Go’s collector is a concurrent mark-sweep: it finds live objects and frees the rest, with almost all the work happening while your goroutines keep running. The mark phase is modeled as tri-color: every object is white (not seen yet — presumed garbage), grey (seen, contents not yet scanned), or black (seen and fully scanned). Marking starts from the roots — global variables and every goroutine stack — colors them grey, then drains the grey set: scan an object’s pointer fields, grey out whatever they reference, blacken the object. When no grey remains, every reachable object is black, and everything still white is garbage. Sweeping then returns white spans to the allocator lazily, amortized into subsequent allocations.

Two stop-the-world pauses bracket the cycle, both typically under a millisecond, usually tens to hundreds of microseconds: one to start the mark phase (enable the write barrier on every P), one to terminate it (confirm no grey work remains). That headline number is real — but it is not the cost of the GC. The cost is concurrent: ~25% of GOMAXPROCS reserved for background marking while a cycle runs, plus mark assists — when a goroutine allocates faster than the background workers can scan, it is conscripted to mark in proportion to its allocation. Sub-millisecond pauses are not free; they are pauses converted into throughput tax.

When you see GC described as “low-pause,” remember: the pause moved — it didn’t disappear.

The write barrier: why concurrent marking doesn’t lose objects

Marking concurrently with mutation has a classic hazard: your code can copy a pointer to a white object into an already-black object and then erase the only other reference. The collector, done with the black object, would never revisit it — the white object gets swept while reachable. Go closes this hole with a write barrier: during marking, every pointer store goes through a small runtime hook. Go’s hybrid barrier (since 1.8) shades the old pointer being overwritten (deletion/Yuasa style) and, for stack writes, the new one (insertion/Dijkstra style) — the combination guarantees no reachable object stays white, and it is what eliminated the need to re-scan goroutine stacks during termination, capping the final pause. The price: every pointer write in your program is slightly more expensive while a cycle runs. Pointer-dense data structures (linked lists, trees of small nodes, map[string]*Thing) are doubly taxed — more for the barrier to intercept, more for marking to chase. This is the mechanical argument for value-oriented design: []Item beats []*Item not by ideology but by barrier and scan economics.

Quiz

During concurrent marking, your code stores a pointer to object W (white) into object B (already black), then deletes the only other path to W. Why doesn't the GC free W?

The pacer, GOGC, and GOMEMLIMIT — two knobs, two regimes

When does a cycle start? The pacer aims to finish marking exactly as the heap reaches its target: roughly live heap × (1 + GOGC/100) (plus stacks and globals in the modern formula). With the default GOGC=100, a service holding 1 GiB live triggers GC around 2 GiB — your memory ceiling is roughly double your live heap. Raise GOGC (200, 400) and cycles get rarer: less GC CPU, more RAM; lower it and you trade the other way. The pacer also self-corrects using feedback from previous cycles, and conscripts allocating goroutines via mark assists when they outrun it — which is why allocation-heavy code sees latency degrade before memory looks scary.

GOMEMLIMIT (Go 1.19+) is the second regime: a soft limit on the runtime’s total memory (heap, stacks, runtime structures — not C allocations). As usage approaches it, the GC runs more aggressively than GOGC alone would dictate, trying to stay under. Use it when the real constraint is a container: GOMEMLIMIT to ~90–95% of the pod limit turns “OOMKilled at 2 a.m.” into “more GC under pressure.” The failure mode is the Hook: live heap near the limit leaves the GC nothing to free, so it runs continuously. The runtime caps GC at 50% of CPU to guarantee forward progress — a designed-in choice that prevents a complete wedge but still means your service can legally spend half its CPU marking. Leave headroom: if live heap is 1.3 GiB, a 1.4 GiB limit is a trap; 2 GiB is a budget.

// Observing the GC honestly in production:
// GODEBUG=gctrace=1 prints one line per cycle to stderr —
// gc 412 @1572.337s 2%: 0.06+312+0.04 ms clock, ...,
//        1290->1310->1190 MB, 1400 MB goal, ...
// read: 2% of CPU on GC since start; 0.06 ms and 0.04 ms STW around
// 312 ms of concurrent mark; heap 1290 MB at start, 1190 MB live after;
// goal 1400 — live is 85% of goal: this service is near the spiral.

debug.SetGCPercent(200)                    // GOGC, at runtime
debug.SetMemoryLimit(1900 << 20)           // GOMEMLIMIT, bytes
var m runtime.MemStats
runtime.ReadMemStats(&m)                   // m.NumGC, m.PauseNs, m.GCCPUFraction

The myths: no compaction, no generations — and why

Two things engineers “know” about GCs that Go’s GC deliberately does not do. No compaction: objects never move once allocated. Fragmentation is contained instead by a size-class allocator (~70 classes; an 18-byte object lives in a 24-byte slot, in a span dedicated to that class — worst-case internal waste is bounded). Non-moving is a choice that buys cheap, stable interop (pointers handed to syscalls or cgo stay valid) and no copy phase competing for memory bandwidth. No generations: there is no nursery; every cycle scans the full live set. The generational hypothesis — most objects die young — is largely pre-handled in Go by escape analysis: the youngest, shortest-lived values never reach the heap at all, dying on stacks for free. The Go team prototyped a generational collector and found the win didn’t justify the cost: generations require an even more expensive write barrier to track old-to-young pointers, complicating the concurrent design that keeps pauses sub-millisecond. These are tradeoffs, not gaps — but they mean Go’s GC rewards a small live set and a low allocation rate, and gives you no nursery to hide a churn-heavy design behind.

Quiz

A service with a 1.3 GiB live heap runs with GOMEMLIMIT=1400MiB. Traffic is normal, nothing leaks. What is the most likely production symptom?

Recall before you leave
  1. 01
    Walk through one GC cycle: the two pauses, what runs concurrently, and where the real CPU cost lives.
  2. 02
    When do you reach for GOGC vs GOMEMLIMIT, and what is the death-spiral case with the soft limit?
Recap

Go’s collector is a concurrent tri-color mark-sweep: objects move white→grey→black as marking drains from roots, two stop-the-world pauses measured in tens to hundreds of microseconds bracket the cycle, and the hybrid write barrier — shading overwritten pointers — guarantees that concurrent mutation never hides a reachable object, which is also what removed stack re-scanning from the final pause. The honest cost is concurrent: ~25% of GOMAXPROCS during marking plus mark assists that tax allocation-heavy goroutines directly, so allocation rate turns into latency before memory looks alarming. The pacer triggers cycles near live×(1+GOGC/100) — at the default, your ceiling is about twice your live heap. GOMEMLIMIT adds a soft total-memory cap for containers; set it with headroom, because a live heap parked near the limit forces back-to-back cycles, capped at 50% CPU by design — the Hook’s service spent half its brain marking while every dashboard stayed green. Pointer-dense structures pay double (barrier traffic plus scan work), which is the mechanical case for value-oriented data. And the two famous absences are choices: no compaction (size-class spans bound fragmentation; pointers stay valid for syscalls and cgo) and no generations (escape analysis already lets the young die on stacks; a prototype generational collector wasn’t worth the heavier barrier). The GC rewards exactly two things: a small live set and a low allocation rate. Now when you reach for a GC knob, ask first whether your live heap has real headroom below its GOMEMLIMIT, and check GC CPU fraction before touching GOGC — the answers usually point back to allocation rate, not tuning.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.