GOGC, GOMEMLIMIT, and the Write Barrier: How the Go Garbage Collector Really Paces Itself

Ask most Go developers how the garbage collector works and you’ll get one of two answers: “it’s concurrent and low-pause” or “it’s a tricolor mark-sweep collector.” Both are true and neither is useful when your p99 latency spikes every 200 milliseconds, or when your container gets OOM-killed even though the heap “should” fit. The parts of the Go GC that actually determine your production behavior are its pacing math, its write barrier, and the two environment variables that control them: GOGC and GOMEMLIMIT.

This post walks through how the collector decides when to run, why it can run concurrently with your code at all, and how to tune both knobs with real numbers. Everything here is verifiable in the runtime source — mgcpacer.go, mbarrier.go, and mgclimit.go — and in the official GC guide.

The heap goal: when the GC decides to run

Go’s collector doesn’t run on a timer or when memory gets “low.” It runs when the heap grows to a computed target called the heap goal. The formula is simple:

heapGoal = liveHeap + (liveHeap * GOGC / 100) + stackAndGlobals

With the default GOGC=100, the goal is double the live heap. If your service holds 400 MB of live data, the next GC triggers when total heap reaches roughly 800 MB. After the cycle, the new goal is computed from whatever survived. The comments in the runtime’s mgc.go state it directly: with GOGC=100 and 4 MB in use, the next collection happens at 8 MB.

Two consequences follow immediately. First, GC frequency is proportional to allocation rate, not to live set size. A service churning through 1 GB of short-lived allocations per second collects far more often than a service holding 2 GB of long-lived data that allocates nothing — this is why reducing allocations is almost always the right optimization, and why “cache more” sometimes makes GC behavior worse by growing the live heap and spacing out cycles that are then much longer. Second, GOGC is a CPU-vs-memory dial: doubling it halves GC frequency and roughly doubles peak heap. The runtime even supports GOGC=off, which disables collection entirely except when forced — occasionally correct for short-lived batch jobs, catastrophic for servers.

Why GC can run concurrently: the write barrier

Mark phase runs mostly concurrently with your goroutines. That creates a race: while the collector is tracing reachable objects, your code is mutating pointers. The classic danger is the mutator hiding a live object — moving the only pointer to a white object somewhere the already-finished collector won’t look, causing it to be swept while still in use.

Go prevents this with a hybrid write barrier, documented in the runtime source (runtime/mbarrier.go). In pseudocode:

// executed by the compiler on every heap pointer write, during GC:
writePointer(slot, ptr):
    shade(*slot)                  // Yuasa: shade the OLD value being overwritten
    if current stack is grey:
        shade(ptr)                // Dijkstra: shade the NEW value being written
    *slot = ptr

The deletion barrier (shade(*slot)) catches the case where your goroutine moves the only pointer to an object from the heap to a stack: overwriting the heap slot shades the object that was there, so the collector will still see it. The insertion part (shade(ptr)) covers the reverse — installing a stack-held pointer into a heap object that’s already been scanned — and is only needed while the goroutine’s own stack hasn’t been scanned yet.

The payoff of this hybrid is that Go 1.8+ eliminated stack re-scanning, which was the dominant source of long pauses in earlier versions. Stacks are never re-scanned mid-cycle, and individual pauses are typically well under a millisecond. The cost is paid everywhere else: every pointer write into a heap object during GC has extra work attached, and the barrier generates mark work proportional to pointer-write volume. Programs that build huge linked structures (linked lists, pointer-heavy trees) during a GC cycle pay measurably more than programs writing flat slices — not because of the allocation itself, but because of barrier traffic.

One subtlety worth knowing: write barriers are only active during the GC mark phase (and briefly around it). In steady state between cycles, pointer writes are plain stores. So the barrier tax is proportional to how much of the time the program spends in mark phase — which brings us back to pacing.

Mutator assists: allocation pays GC tax

Here’s the mechanism behind most mysterious tail-latency spikes. The GC must finish its mark work before the heap overshoots the goal. If your goroutines allocate faster than the dedicated background mark workers can keep up, the runtime doesn’t just let the heap blow past the goal — it makes the allocating goroutines do mark work themselves. Each allocation during mark phase carries a computed assist debt, and a goroutine that allocates heavily can find itself spending significant time doing collector work before its next line of application code runs.

The assist ratio is recomputed during the cycle as remaining scan work divided by remaining heap budget (assistWorkPerByte in mgcpacer.go). As the heap approaches the goal, the ratio climbs and assists get punitive. This is the runtime enforcing a contract: the faster you allocate, the more GC work you personally perform. When someone reports “GC takes 30% of CPU” in a profile, a large share is frequently assist time inside hot allocation paths, not background worker time.

The same machinery has a safety valve for the opposite problem. When a soft memory limit forces GCs to run back-to-back (more on that below), the runtime’s CPU limiter — a leaky bucket documented in mgclimit.go — caps GC at roughly 50% of CPU to prevent a death spiral where the collector starves the application entirely. If the limiter engages, the heap is allowed to exceed the limit rather than livelock the program. That’s why GOMEMLIMIT is explicitly a soft limit: in an unsolvable situation, the runtime chooses OOM over 100% GC CPU.

GOMEMLIMIT: the second knob

GOGC has a blind spot: it only sees the heap. Stack memory, runtime structures, and — critically for containers — non-heap memory are invisible to it. On a Kubernetes pod with a 4 GiB limit, a Go service with a small live heap will happily keep its heap goal at 2× live, then get killed by the kernel when total memory (heap + stacks + runtime + cgo) crosses the cgroup ceiling. Before Go 1.19, the workaround was the debug.SetMemoryLimit-shaped hack of setting GOGC ultra-low, tanking throughput.

GOMEMLIMIT (Go 1.19+) fixes this. It sets a soft ceiling on total runtime-managed memory, and the pacer derives a secondary heap goal from it — the runtime computes the goal as the limit minus all non-heap memory it accounts for, minus a small headroom (there’s a fixed minimum of 1 MiB and a percentage-based reduction, per mgcpacer.go). When the derived goal is lower than the GOGC-based goal, the tighter one wins:

heapGoal = min(gogcGoal, memoryLimitGoal)

Concretely: suppose your container has a 4 GiB limit, non-heap runtime memory is about 500 MB, and the live heap is 600 MB. With default GOGC=100 alone, the goal is ~1.2 GB heap and everything is fine. Now a traffic surge pushes live heap to 2.5 GB. GOGC alone would set a goal of 5 GB — past the cgroup ceiling. With GOMEMLIMIT=3800MiB, the memory-derived goal clamps to roughly 3.3 GB minus headroom, so GC runs earlier and more often as you approach the limit instead of the kernel killing the pod.

The recommended configuration in the official guide is to set GOMEMLIMIT to about 90–95% of the container limit, leaving room for memory the runtime doesn’t manage (OS page cache for file I/O, non-Go libraries, the binary itself). Setting it exactly equal to the cgroup limit is a common footgun: any unmanaged memory pushes total usage past the ceiling and you get OOM-killed even though the GC honored its limit.

Tuning in practice

The two knobs compose into a simple decision tree.

Latency-sensitive, memory is cheap: leave GOGC=100 or raise it (GOGC=200, GOGC=300) to reduce GC frequency, and set GOMEMLIMIT as the safety net. Fewer cycles mean fewer assist events and less barrier overhead — and with the limit in place, the larger heap can’t surprise you.

Memory-constrained container: set GOMEMLIMIT to ~90% of the cgroup limit first. Only then consider GOGC — with a limit set, a low GOGC is usually unnecessary because the limit already forces early collections when the heap grows. Many teams run GOGC=100 + a well-chosen GOMEMLIMIT and never touch either again.

Throughput batch jobs: if the job is short-lived and the machine is otherwise idle, GOGC=off plus a generous GOMEMLIMIT is a legitimate pattern — collect only when approaching real memory pressure. The official guide endorses exactly this combination.

Verify with the GC cycle log lines (GODEBUG=gctrace=1). A healthy line looks like:

gc 42 @120.5s 0%: 0.1+2.4+0.2 ms clock, 1.6+0.9/4.8/1.2+3.2 ms cpu, 310->344->298 MB, 342 MB goal, 8 P

Read it as: cycle 42, GC consumed 0% of CPU, the heap went from 310 MB → 344 MB at sweep start → 298 MB live after, and the goal was 342 MB. Warning signs: CPU percentage in the low double digits (GC too aggressive — raise GOGC or check for an accidental tiny memory limit), the live value (third number) creeping up monotonically (a leak — tuning won’t save you), and goal values far below your container ceiling while memory pressure alarms fire (check for non-heap memory the limit can’t see).

What this means for the code you write

The pacing model explains several folklore rules with one idea. Allocating fewer, larger objects stretches the interval between cycles. Pointer-free []byte buffers cost less to mark than pointer-rich object graphs, because scanning a pointer-free region is trivial. Pooling with sync.Pool works best for objects that survive exactly the GC cycle — the pool itself is cleared on cycle boundaries. And writing barrier-heavy code (churning huge pointer graphs) during peak traffic costs more than the allocation counters suggest.

You don’t need to internalize the pacer’s full math — a lot of careful engineering went into making the defaults good. But when a Go service misbehaves, the shape of the problem is almost always visible in these terms: the heap goal is in the wrong place, assists are punishing an allocation-heavy hot path, or the memory limit is fighting a live heap that genuinely doesn’t fit. Reading a gctrace line with this model in mind turns the collector from a black box into a system you can reason about — and usually, into one you only need to tune once.

Leave a Reply

Your email address will not be published. Required fields are marked *