GC Pacing and GOGC
Go’s garbage collector is concurrent, so the question “when should a collection start?” is itself an optimization problem: start too late and the heap blows past its budget before marking finishes; start too early and the program collects far more often than it needs to, burning CPU. The pacer is the runtime’s feedback controller that answers this question. It computes a heap goal — a target heap size for the end of the next cycle — from the live heap and the
GOGCknob, then picks a trigger point below that goal at which to kick off concurrent marking, aiming to have marking finish exactly as live allocation reaches the goal while holding garbage-collection CPU usage near 25% of available cores (Go GC guide; GC pacer redesign, Go 1.18).
The One Knob: GOGC
GOGC is the single, primary tuning parameter of the Go collector. It is set via the GOGC environment variable or runtime/debug.SetGCPercent, and it expresses, as a percentage, how much new heap memory the program may allocate between collections relative to the amount of live memory the last collection found. Its default is 100, meaning a program is allowed to allocate roughly an amount of new memory equal to its live set before the next collection runs — i.e. the heap roughly doubles between collections (Go GC guide).
GOGC is a direct CPU-versus-memory trade-off dial. Per the GC guide: doubling GOGC roughly doubles heap memory overhead and roughly halves GC CPU cost; halving it does the reverse. Higher GOGC ⇒ fewer, rarer collections ⇒ less CPU spent collecting ⇒ a bigger resident footprint. Lower GOGC ⇒ more frequent collections ⇒ more CPU ⇒ smaller footprint. Setting GOGC=off (or SetGCPercent(-1)) disables the GOGC-driven collection trigger entirely — the heap then grows unbounded unless GOMEMLIMIT is also set.
The Heap Goal Formula
The pacer’s first job each cycle is to compute the heap goal: the heap size it wants to not exceed by the time the next mark phase completes. The GC pacer redesign gives the formula used since Go 1.18:
heap_goal = live_heap + (live_heap + stacks + globals) × (GOGC / 100)Walking the symbols:
live_heap— the bytes the previous GC cycle proved to be reachable (live) heap objects. This is the anchor; the pacer always sizes the next cycle off the last cycle’s measured live set.stacks— the total size of all goroutine stacks. Stacks are GC roots and must be scanned, so they are work the collector must do.globals— the bytes of pointer-containing global (package-level) variables, likewise scanned roots.GOGC / 100—GOGCexpressed as a fraction (the redesign calls thisγ, gamma).GOGC=100⇒1.0.
The crucial design change in Go 1.18 was including stacks + globals in the term multiplied by GOGC. The philosophical justification in the redesign: GOGC is meant to be a knob over the trade-off between CPU and memory for all GC work, and stacks and globals are memory the collector must scan; pre-1.18 the formula ignored them, which mispriced programs with large stacks or many globals.
The GC guide’s worked example: suppose the previous cycle found live_heap = 8 MiB, with stacks = 1 MiB and globals = 1 MiB. Then at GOGC=100 the goal is 8 + (8+1+1) × 1.0 = 18 MiB; at GOGC=50 it is 8 + 10 × 0.5 = 13 MiB; at GOGC=200 it is 8 + 10 × 2.0 = 28 MiB. The same live set yields a tighter or looser budget purely as a function of GOGC.
A minimum heap floor applies: if the formula yields a goal below the floor it is rounded up, so tiny programs do not collect pathologically often. In the Go 1.26 runtime this floor is defaultHeapMinimum, 4 MiB by default, dropping to 512 KiB when the program is built with GOEXPERIMENT=heapminimum512kib (mgcpacer.go).
Mental Model
flowchart LR Prev["Previous cycle:<br/>live_heap measured"] --> Goal["heap_goal =<br/>live + (live+stk+gl)×GOGC/100"] Goal --> Trig["Pacer picks trigger T<br/>below the goal"] Trig -->|"allocation reaches T"| Mark["Concurrent mark phase starts"] Mark --> Race{"Race:<br/>marking vs allocation"} Race -->|"mark finishes first"| OK["Goal met — ideal"] Race -->|"allocation gaining"| Assist["Allocating goroutines do<br/>GC assists (slow down)"] Mark --> Done["Mark completes →<br/>measure new live_heap"] Done -->|"PI controller adjusts T<br/>for next cycle"| Trig
The diagram shows the pacer as a closed feedback loop. The heap goal is derived from the previous cycle’s live heap; a trigger point is chosen below it; when allocation crosses the trigger, marking begins; the pacer then watches the race between marking and continued allocation, deferring to GC assists if allocation is winning. After the cycle, the actual outcome feeds a proportional-integral controller that nudges the next trigger. The insight: the trigger is not a fixed fraction of the goal — it is searched for cycle by cycle, because the right trigger depends on the program’s allocation rate and the collector’s scan rate, both of which the runtime can only measure after the fact.
Mechanical Walk-through — How the Pacer Picks the Trigger
The heap goal tells the pacer where marking must finish. But marking is not instantaneous; it has to start early enough that, given the program’s allocation rate, marking completes before live allocation reaches the goal. The gap between the trigger and the goal is the runway. Picking the trigger is the pacer’s hard problem.
Pre-1.18, the pacer used a plain proportional controller to compute the trigger directly. That design suffered a steady-state offset error — a persistent gap between target and actual behavior that proportional control cannot eliminate. The Go 1.18 GC pacer redesign reframed the problem and switched to a proportional-integral (PI) controller.
The reframing: instead of computing the trigger directly, the pacer estimates the program’s consumption-to-mark ratio (cons/mark) — how fast the application consumes (allocates) heap relative to how fast the GC marks (scans) it, both measured per unit of CPU time. In the Go 1.26 runtime, mgcpacer.go defines this on the gcControllerState struct verbatim as:
// consMark is the estimated per-CPU consMark ratio for the application.
//
// It represents the ratio between the application's allocation
// rate, as bytes allocated per CPU-time, and the GC's scan rate,
// as bytes scanned per CPU-time.
consMark float64The actual cons/mark for a just-finished cycle is computed at the end of the cycle (in endCycle) — not as the design doc’s simplified (alloc/scan) × (u_target/u_measured), but as the live form below, which folds in idle GC utilization and the mutator’s share of CPU:
currentConsMark = (heapLive − triggered) × (utilization + idleUtilization)
─────────────────────────────────────────────────────────
scanWork × (1 − utilization)Walking the symbols: heapLive − triggered is the bytes allocated during the cycle (live heap at end minus the heap size when the cycle was triggered); scanWork is the heap + stack + globals bytes the collector scanned; utilization is the GC’s CPU share from dedicated and fractional mark workers plus assists, and idleUtilization is extra CPU idle Ps donated to marking. The numerator scales allocation by the total CPU available to the mutator-plus-idle, the denominator scales scan work by the CPU left to the mutator — the quotient is therefore “bytes allocated per CPU-ns” over “bytes scanned per CPU-ns” (mgcpacer.go, Go 1.26.0).
A PI controller smooths this raw measurement across cycles into a stable consMark estimate. The integral term accumulates past error and drives the steady-state offset to zero — the deficiency the old proportional-only pacer could never fix. From the smoothed ratio the pacer derives the runway (how many bytes of allocation marking will take) and places the trigger that far below the goal. The result: in steady state, marking reliably completes right as the heap reaches its goal, and almost no GC assists are needed.
The runtime clamps the trigger between a lower and upper bound — in the Go 1.26 source, between roughly 70% (minTriggerRatioNum = 45/triggerRatioDen = 64) and 95% (maxTriggerRatioNum = 61/64) of the distance from the marked heap to the goal — so a mis-estimate cannot push the trigger pathologically early or late (mgcpacer.go).
The pacer also computes a hard heap goal to bound how far a cycle may overshoot when live heap grows unexpectedly mid-cycle. Contrary to a common misreading, this is not a fixed 1.1× ceiling. In revise() the runtime sets hardGoal = (1.0 + GOGC/100) × heapGoal; the in-tree comment explains that at GOGC=100 this is twice the heap goal, “i.e. if … the heap and/or stacks and/or globals grow to twice their size, this limits the current GC cycle’s growth to 4x the original live heap’s size” (mgcpacer.go). GC assists are paced against the extrapolated steady-state ratio rather than this hard limit directly, which keeps assist intensity smooth instead of spiking when live heap unexpectedly grows.
The implementation of all this lives in mgcpacer.go, in the gcControllerState struct, which holds the live-heap estimate, the scan-work accounting, the PI controller state, and the assist ratio. gcControllerState.commit recomputes the goal and trigger at the end of each cycle; gcControllerState.revise updates the assist ratio (and the hard goal) mid-cycle as scan work is discovered.
The 25% CPU Target
The pacer’s second objective, alongside the heap goal, is to keep GC CPU usage near 25% of GOMAXPROCS during marking. This target has history: the 2014-era goal was 50% (Hudson, ISMM keynote); by 2018 it was lowered to 25%. The 25% is realized by the mark workers: the scheduler dedicates a fraction of Ps to background marking such that, on average, one quarter of the CPU is doing GC work while three quarters keep running the program. A non-integer 25% (e.g. on a 6-P machine, 25% of 6 = 1.5 Ps) is achieved by mixing dedicated and fractional mark workers — see GC Assists and Mark Workers.
When the program out-allocates the pacer’s expectation — allocation is winning the race against marking — the runtime falls back on GC assists: a goroutine that allocates is made to do a proportional amount of marking work before its allocation is granted, throttling it. Assists are the pacer’s emergency brake; in a well-paced steady state they are nearly absent, and significant time in runtime.gcAssistAlloc (the GC guide flags >5%) signals the program is allocating faster than the collector can keep up.
Code and Configuration Examples
Setting GOGC from the environment, before the process starts:
GOGC=200 ./myserver # looser: ~2× memory, ~0.5× GC CPU vs default
GOGC=50 ./myserver # tighter: ~0.5× memory, ~2× GC CPU vs default
GOGC=off ./myserver # disable the GOGC trigger entirelySetting it at runtime, and reading the old value back:
package main
import (
"runtime/debug"
"fmt"
)
func main() {
// SetGCPercent sets GOGC and returns the PREVIOUS value.
old := debug.SetGCPercent(200)
fmt.Println("previous GOGC was", old) // 100 by default
// -1 disables the GOGC-driven collector trigger.
debug.SetGCPercent(-1)
// runtime.GC() still forces a blocking collection on demand.
}Line-by-line: debug.SetGCPercent(200) raises GOGC to 200, loosening the heap goal so the heap may grow to roughly three times the live set before collecting (live + live×2.0), and returns the old value (100) — useful for save/restore. SetGCPercent(-1) disables the trigger; the heap then grows without bound unless GOMEMLIMIT caps it or the program calls runtime.GC() manually. The pacer parameters are otherwise not directly configurable — the redesign deliberately kept GOGC (plus, since 1.19, GOMEMLIMIT) as the only public knobs.
To observe pacing, set GODEBUG=gctrace=1:
gc 14 @2.711s 1%: 0.018+1.3+0.10 ms clock, 0.14+0.50/1.2/0+0.85 ms cpu,
8->8->4 MB, 9 MB goal, 0 MB stacks, 0 MB globals, 8 PThe 8->8->4 MB triple is heap-at-trigger → heap-at-mark-end → live-heap-after-sweep; 9 MB goal is the pacer’s computed heap_goal for this cycle. If the middle number consistently overshoots the goal, the pacer is being out-allocated and assists are kicking in.
Failure Modes and Common Misunderstandings
“GOGC controls how often GC runs in seconds.” No. GOGC is purely a memory ratio. How often that translates to in wall-clock time depends entirely on the program’s allocation rate. A program that allocates fast collects often; an idle program at the same GOGC collects rarely.
Allocation spikes overshoot the goal. Because the pacer sizes the next cycle from the previous live heap, a sudden burst of allocation within a cycle can blow past the heap goal before marking finishes. The runtime’s response is GC assists, which slow the bursting goroutines — visible as latency hits, not as missed collection.
Setting GOGC very low to “save memory.” This trades memory for CPU steeply, and at low enough values the program spends most of its time collecting. For a memory ceiling, GOMEMLIMIT is the correct tool — it caps memory without forcing a permanently high collection rate.
“GOGC and the 4 MiB floor mean small programs never GC.” They GC, but rarely — the floor just prevents collecting every few kilobytes.
Large live heaps make GOGC=100 expensive in absolute memory. GOGC=100 doubles a large live set just as readily as a small one; a 10 GiB live heap targets ~20 GiB. This is the original motivation for GOMEMLIMIT.
Alternatives and When to Choose Them
The only other GC knob is GOMEMLIMIT, added in Go 1.19 — a soft memory ceiling used together with GOGC, not instead of it. The common production recipe is GOGC at its default (or a chosen ratio) plus GOMEMLIMIT near the container limit: GOGC paces normal operation, GOMEMLIMIT clamps the worst case. A pattern from before GOMEMLIMIT existed was a “ballast” — a large dummy allocation that inflated the live heap to push the GOGC-derived goal upward; GOMEMLIMIT makes ballasts obsolete and the Go team recommends against them. Manual runtime.GC() calls are an alternative for batch programs with clear phase boundaries, but for servers the pacer almost always does better than hand-tuning.
Production Notes
The Go team’s guidance is that most programs should leave GOGC at 100 and instead set GOMEMLIMIT. Raising GOGC (e.g. to 200–400) is a legitimate latency optimization for services with memory headroom and high allocation rates: fewer collections mean fewer pacing events and fewer assist stalls — at the cost of a larger resident set. The pacer redesign explicitly aimed to eliminate assists in steady state, so a healthy service should show near-zero runtime.gcAssistAlloc in CPU profiles; sustained assist time means the allocation rate has outrun the collector and either GOGC should rise (if memory allows) or the allocation rate should drop (see Escape Analysis, sync.Pool Internals). GODEBUG=gctrace=1 and the runtime/metrics package (/gc/heap/goal:bytes, /gc/cycles/total:gc-cycles) are the primary tools for watching the pacer in production — see GC Tuning and Observability.
The Go 1.26 Green Tea redesign changed how marking scans memory (span-at-a-time instead of object-at-a-time, with SIMD), which raises the collector’s scan rate — but it does not change the pacer’s contract: the heap goal formula, the cons/mark PI controller, the trigger bounds, and the 25% CPU target all carry over unchanged. A faster scan rate simply lets the pacer place the trigger closer to the goal (a shorter runway) for the same allocation rate (Go 1.26 release notes).
See Also
- Go Garbage Collector — the concurrent collector this pacer drives
- Green Tea Garbage Collector — the Go 1.26 default marking algorithm the pacer schedules
- GOMEMLIMIT and the Soft Memory Limit — the second knob, a memory ceiling
- GC Assists and Mark Workers — how the 25% target and assist throttle are realized
- Stop-the-World Pauses — the brief STW phases bracketing the concurrent mark
- Tricolor Mark and Sweep — the marking algorithm the pacer schedules
- Write Barriers in Go — the hybrid barrier that keeps concurrent marking correct
- GC Tuning and Observability —
gctrace,runtime/metrics, and tuning workflow - GMP Scheduler Model — provides the Ps that mark workers run on
- Go Internals MOC — section 7, Garbage Collection