How Go's GC Decides When to Collect: Heap Goal, Pacing and Mark Assist
Go's collector is paced by a controller that computes a heap goal from GOGC and GOMEMLIMIT, a trigger point below that goal, a 25%-of-GOMAXPROCS background mark budget, and a mutator assist ratio.
TL;DR: Go’s GC does not collect when a counter says so; it collects when the heap goal says so. The goal is
live heap × (1 + GOGC/100), capped byGOMEMLIMIT. The trigger is a point below the goal, computed from how fast you allocate versus how fast the collector can scan. Between trigger and goal, 25% of GOMAXPROCS marks in the background, and any mutator that allocates faster than the collector scans accumulates an assist debt it must repay by doing marking work itself.
Every garbage collector has to answer the same question: when do I collect? Some answer it with a timer. Some answer it with a free-list threshold. Go answers it with arithmetic on the size of the live heap, and the arithmetic is subtle enough that it deserves to be taken apart piece by piece.
The code lives in src/runtime/mgcpacer.go, and it is the same
machinery behind every number you have ever seen in GODEBUG=gctrace=1
output.
The knobs
There are two user-facing knobs, and they do different jobs.
GOGC is a multiplier on the live heap. GOGC=100 means roughly
“collect when the heap has grown to twice the live heap” — roughly,
because stacks and globals count too (see below). GOGC=off disables
automatic collection entirely.
GOMEMLIMIT is a soft limit on total memory used by the runtime.
It is a limit, not a target: the collector will do whatever it takes
to get under it, including running GC more often than GOGC would
suggest.
The controller holds both, plus a pile of derived state:
// src/runtime/mgcpacer.go:92
type gcControllerState struct {
// Initialized from GOGC. GOGC=off means no GC.
gcPercent atomic.Int32
// memoryLimit is the soft memory limit in bytes.
//
// Initialized from GOMEMLIMIT. GOMEMLIMIT=off is equivalent to MaxInt64
// which means no soft memory limit in practice.
//
// This is an int64 instead of a uint64 to more easily maintain parity with
// the SetMemoryLimit API, which sets a maximum at MaxInt64. This value
// should never be negative.
memoryLimit atomic.Int64
// heapMinimum is the minimum heap size at which to trigger GC.
// For small heaps, this overrides the usual GOGC*live set rule.
//
// When there is a very small live set but a lot of allocation, simply
// collecting when the heap reaches GOGC*live results in many GC
// cycles and high total per-GC overhead. This minimum amortizes this
// per-GC overhead while keeping the heap reasonably small.
//
// During initialization this is set to 4MB*GOGC/100. In the case of
// GOGC==0, this will set heapMinimum to 0, resulting in constant
// collection even when the heap size is small, which is useful for
// debugging.
heapMinimum uint64The heap goal
The goal is the size the heap is allowed to reach by the end of the
current cycle. It is computed at the end of the previous cycle, in
commit:
// src/runtime/mgcpacer.go:1290
// Compute the next GC goal, which is when the allocated heap
// has grown by GOGC/100 over where it started the last cycle,
// plus additional runway for non-heap sources of GC work.
gcPercentHeapGoal := ^uint64(0)
if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {
gcPercentHeapGoal = c.heapMarked + (c.heapMarked+c.lastStackScan.Load()+c.globalsScan.Load())*uint64(gcPercent)/100
}
// Apply the minimum heap size here. It's defined in terms of gcPercent
// and is only updated by functions that call commit.
if gcPercentHeapGoal < c.heapMinimum {
gcPercentHeapGoal = c.heapMinimum
}
c.gcPercentHeapGoal.Store(gcPercentHeapGoal)Read that arithmetic carefully. heapMarked is the live heap as
measured by the last mark phase. The goal is:
heapMarked + (heapMarked + stacks + globals) * GOGC/100The stacks and globals are in there because scanning them is work the
collector has to do, and that work has to be paid for by some amount
of allocation. A program with 100 MB of live heap and 20 MB of
scannable stacks is not really running with a 100 MB live set; it is
running with a 120 MB one, and at GOGC=100 the goal lands at
100 + (100 + 20) = 220 MB, not 200.
Then heapGoalInternal picks the smaller of the GOGC-based goal and
the memory-limit-based goal, with a couple of adjustments:
// src/runtime/mgcpacer.go:1000
func (c *gcControllerState) heapGoalInternal() (goal, minTrigger uint64) {
// Start with the goal calculated for gcPercent.
goal = c.gcPercentHeapGoal.Load()
// Check if the memory-limit-based goal is smaller, and if so, pick that.
if newGoal := c.memoryLimitHeapGoal(); newGoal < goal {
goal = newGoal
} else {The interesting part is the else branch: when the memory limit is
not the binding constraint, the goal is nudged upward to guarantee
the sweeper has runway, and to guarantee a minimum distance between
where the GC triggered and where it will finish.
The trigger
The trigger is where the GC actually starts. It is always strictly below the goal, and the gap between them is the “runway” — the amount of allocation the program can do between the collector starting and finishing.
// src/runtime/mgcpacer.go:1239
// Compute the trigger from our bounds and the runway stored by commit.
var trigger uint64
runway := c.runway.Load()
if runway > goal {
trigger = minTrigger
} else {
trigger = goal - runway
}
trigger = max(trigger, minTrigger)
trigger = min(trigger, maxTrigger)
if trigger > goal {
print("trigger=", trigger, " heapGoal=", goal, "\n")
print("minTrigger=", minTrigger, " maxTrigger=", maxTrigger, "\n")
throw("produced a trigger greater than the heap goal")
}
return trigger, goalThe runway itself is an estimate. The controller measures, each cycle, the ratio between the bytes your program allocates per unit of CPU time and the bytes the collector scans per unit of CPU time — the “cons/mark” ratio — and multiplies it by the expected scan work to figure out how many bytes you will allocate while the collector is busy.
The bounds minTrigger and maxTrigger keep the trigger inside a
sane window:
// src/runtime/mgcpacer.go:1160
const (
// These constants determine the bounds on the GC trigger as a fraction
// of heap bytes allocated between the start of a GC (heapLive == heapMarked)
// and the end of a GC (heapLive == heapGoal).
//
// The constants are obscured in this way for efficiency. The denominator
// of the fraction is always a power-of-two for a quick division, so that
// the numerator is a single constant integer multiplication.
triggerRatioDen = 64
// The minimum trigger constant was chosen empirically: given a sufficiently
// fast/scalable allocator with 48 Ps that could drive the trigger ratio
// to <0.05, this constant causes applications to retain the same peak
// RSS compared to not having this allocator.
minTriggerRatioNum = 45 // ~0.7
// The maximum trigger constant is chosen somewhat arbitrarily, but the
// current constant has served us well over the years.
maxTriggerRatioNum = 61 // ~0.95
)So the trigger sits between roughly 70% and 95% of the way from the live heap to the goal. Too early and you are allocating black in a nearly-permanent GC, which grows RSS; too late and there is no runway left when the collector needs it.
The phases
The GC moves through three phases, and the write barrier’s behaviour is a function of them:
// src/runtime/mgc.go:250
const (
_GCoff = iota // GC not running; sweeping in background, write barrier disabled
_GCmark // GC marking roots and workbufs: allocate black, write barrier ENABLED
_GCmarktermination // GC mark termination: allocate black, P's help GC, write barrier ENABLED
)There is also a separate axis for how concurrent the cycle is:
// src/runtime/mgc.go:667
// gcMode indicates how concurrent a GC cycle should be.
type gcMode int
const (
gcBackgroundMode gcMode = iota // concurrent GC and sweep
gcForceMode // stop-the-world GC now, concurrent sweep
gcForceBlockMode // stop-the-world GC now and STW sweep (forced by user)
)and a separate axis for what caused the cycle:
// src/runtime/mgc.go:686
const (
// gcTriggerHeap indicates that a cycle should be started when
// the heap size reaches the trigger heap size computed by the
// controller.
gcTriggerHeap gcTriggerKind = iota
// gcTriggerTime indicates that a cycle should be started when
// it's been more than forcegcperiod nanoseconds since the
// previous GC cycle.
gcTriggerTime
// gcTriggerCycle indicates that a cycle should be started if
// we have not yet started cycle number gcTrigger.n (relative
// to work.cycles).
gcTriggerCycle
)gcTriggerHeap is the normal case. gcTriggerTime is the
two-minute forced collection, and gcTriggerCycle is runtime.GC().
The background mark budget: 25%
The controller dedicates a fixed fraction of the CPU to marking in the background, and makes up any shortfall with mutator assists:
// src/runtime/mgcpacer.go:16
const (
// gcGoalUtilization is the goal CPU utilization for
// marking as a fraction of GOMAXPROCS.
//
// Increasing the goal utilization will shorten GC cycles as the GC
// has more resources behind it, lessening costs from the write barrier,
// but comes at the cost of increasing mutator latency.
gcGoalUtilization = gcBackgroundUtilization
// gcBackgroundUtilization is the fixed CPU utilization for background
// marking. It must be <= gcGoalUtilization. The difference between
// gcGoalUtilization and gcBackgroundUtilization will be made up by
// mark assists. The scheduler will aim to use within 50% of this
// goal.
//
// As a general rule, there's little reason to set gcBackgroundUtilization
// < gcGoalUtilization. One reason might be in mostly idle applications,
// where goroutines are unlikely to assist at all, so the actual
// utilization will be lower than the goal. But this is moot point
// because the idle mark workers already soak up idle CPU resources.
// These two values are still kept separate however because they are
// distinct conceptually, and in previous iterations of the pacer the
// distinction was more important.
gcBackgroundUtilization = 0.25Twenty-five percent of GOMAXPROCS. On a 16-core box, four cores’ worth of dedicated marking. That number is turned into a worker count at the start of each cycle:
// src/runtime/mgcpacer.go:398
// Compute the background mark utilization goal. In general,
// this may not come out exactly. We round the number of
// dedicated workers so that the utilization is closest to
// 25%. For small GOMAXPROCS, this would introduce too much
// error, so we add fractional workers in that case.
totalUtilizationGoal := float64(procs) * gcBackgroundUtilization
dedicatedMarkWorkersNeeded := int64(totalUtilizationGoal + 0.5)
utilError := float64(dedicatedMarkWorkersNeeded)/totalUtilizationGoal - 1
const maxUtilError = 0.3
if utilError < -maxUtilError || utilError > maxUtilError {
// Rounding put us more than 30% off our goal. With
// gcBackgroundUtilization of 25%, this happens for
// GOMAXPROCS<=3 or GOMAXPROCS=6. Enable fractional
// workers to compensate.
if float64(dedicatedMarkWorkersNeeded) > totalUtilizationGoal {
// Too many dedicated workers.
dedicatedMarkWorkersNeeded--
}
c.fractionalUtilizationGoal = (totalUtilizationGoal - float64(dedicatedMarkWorkersNeeded)) / float64(procs)
} else {
c.fractionalUtilizationGoal = 0
}25% of 3 procs is 0.75, which cannot be rounded to a whole number of
workers without a 30%+ error. That is what the fractional worker is
for: a worker that runs on a fraction of a P’s time, self-preempting
when it has used its share. idle workers are the third kind — they
run only when the P has nothing else to do, and their time is
essentially free because the CPU was going idle anyway.
Mark assist: the mutator pays for its own garbage
If the background workers cannot keep up, the mutator has to help. The controller computes an assist ratio — scan work per byte allocated — and the allocator charges every allocation against it:
// src/runtime/mgcpacer.go:583
// Compute the mutator assist ratio so by the time the mutator
// allocates the remaining heap bytes up to heapGoal, it will
// have done (or stolen) the remaining amount of scan work.
// Note that the assist ratio values are updated atomically
// but not together. This means there may be some degree of
// skew between the two values. This is generally OK as the
// values shift relatively slowly over the course of a GC
// cycle.
assistWorkPerByte := float64(scanWorkRemaining) / float64(heapRemaining)
assistBytesPerWork := float64(heapRemaining) / float64(scanWorkRemaining)
c.assistWorkPerByte.Store(assistWorkPerByte)
c.assistBytesPerWork.Store(assistBytesPerWork)Two numbers, reciprocals of each other. assistWorkPerByte is how much
scanning one byte of allocation earns you. assistBytesPerWork is how
many bytes of allocation one unit of scanning work repays.
The debt accounting lives on the goroutine. Every allocation subtracts from the debt; when the debt goes negative, the goroutine must assist:
// src/runtime/malloc_stubs.go:169
func deductAssistCredit(size uintptr) {
assistG := getg()
if assistG.m.curg != nil {
assistG = assistG.m.curg
}
assistG.gcAssistBytes -= int64(size)
if assistG.gcAssistBytes < 0 {
gcAssistAlloc(assistG)
}
}Note that gcAssistBytes is negative when you owe. The assist
routine turns that negative number into an amount of work:
// src/runtime/mgcmark.go:559
// Compute the amount of scan work we need to do to make the
// balance positive. When the required amount of work is low,
// we over-assist to build up credit for future allocations
// and amortize the cost of assisting.
assistWorkPerByte := gcController.assistWorkPerByte.Load()
assistBytesPerWork := gcController.assistBytesPerWork.Load()
debtBytes := -gp.gcAssistBytes
scanWork := int64(assistWorkPerByte * float64(debtBytes))
if scanWork < gcOverAssistWork {
scanWork = gcOverAssistWork
debtBytes = int64(assistBytesPerWork * float64(scanWork))
}gcOverAssistWork is 64 KiB of scan work. If your debt is small, you
do that much anyway — over-assisting on purpose, so that the next few
thousand allocations do not each trigger a trip through the scheduler.
Then the assist runs, and the work done is credited back:
// src/runtime/mgcmark.go:749
// Record that we did this much scan work.
//
// Back out the number of bytes of assist credit that
// this scan work counts for. The "1+" is a poor man's
// round-up, to ensure this adds credit even if
// assistBytesPerWork is very low.
assistBytesPerWork := gcController.assistBytesPerWork.Load()
gp.gcAssistBytes += 1 + int64(assistBytesPerWork*float64(workDone))Before doing its own work, an assist tries to steal credit from the background workers’ credit pool, and if it cannot pay off its debt after working, it parks on the assist queue until the collector hands it credit.
The soft memory limit
GOMEMLIMIT puts the collector in a different regime. The goal is no
longer derived from the live heap; it is derived from how much memory
the runtime has mapped and how much of the limit is already spoken
for:
// src/runtime/mgcpacer.go:1071
// In practice this computation looks like the following:
//
// goal := memoryLimit - ((mappedReady - heapFree - heapAlloc) + max(mappedReady - memoryLimit, 0))
// ^1 ^2
// goal -= goal / 100 * memoryLimitHeapGoalHeadroomPercent
// ^3
//
// Let's break this down.
//
// The first term (marker 1) is everything that contributes to the memory limit and isn't
// or couldn't become heap objects. It represents, broadly speaking, non-heap overheads.Three adjustments: subtract the non-heap overhead, subtract any overage past the limit, and subtract a few percent of headroom so that pacing inaccuracies do not immediately push you back over. The headroom has a floor:
// src/runtime/mgcpacer.go:66
// memoryLimitMinHeapGoalHeadroom is the minimum amount of headroom the
// pacer gives to the heap goal when operating in the memory-limited regime.
// That is, it'll reduce the heap goal by this many extra bytes off of the
// base calculation, at minimum.
memoryLimitMinHeapGoalHeadroom = 1 << 20One megabyte, because the pacer’s inputs get fuzzy on small heaps.
What to measure
The runtime exposes the numbers as metrics:
// src/runtime/metrics/description.go:289
{
Name: "/gc/heap/goal:bytes",
Description: "Heap size target for the end of the GC cycle.",
Kind: KindUint64,
},
{
Name: "/gc/heap/live:bytes",
Description: "Heap memory occupied by live objects that were marked by the previous GC.",
Kind: KindUint64,
},/gc/heap/goal:bytes minus /gc/heap/live:bytes is the runway you
have. If the goal is barely above the live heap, your live set is
growing. If the goal is huge, you are over-allocating.
GODEBUG=gctrace=1 prints the same numbers per cycle, along with the
CPU time split between assists, dedicated workers, fractional workers
and idle workers.
Trade-offs
What GOGC gives you: a direct, predictable trade between heap
size and GC frequency. GOGC=100 means “spend memory to spend less
CPU”. GOGC=50 halves the garbage-to-live ratio. GOGC=off means the
program must manage its own memory.
What it costs: a larger heap means a larger live set to scan next cycle, and the scan cost is superlinear in cache behaviour even if linear in bytes.
What assists give you: the collector never has to stop the world to keep up with an allocation-heavy program. The mutator pays in latency instead, which is usually the better trade.
What it costs: assists are per-goroutine latency spikes on the allocation path, and a program that allocates in tight loops will feel them as jitter.
What the soft memory limit gives you: a ceiling on RSS that survives a change in the program’s allocation behaviour.
What it costs: the pacer becomes conservative in a way that is hard to predict, and if the limit is set too low the program spends its time in GC rather than doing work.
Caveats
GOGC is a multiplier, not a target. The heap goal is also
raised by the size of your stacks and globals, and by a floor of a few
megabytes. A program with a large live set and GOGC=10 still runs a
big heap.
The memory limit is soft. GOMEMLIMIT is a target the runtime
tries to respect; it is not a limit the OS enforces, and the runtime
can exceed it if it must (the goroutine stacks of a million goroutines
are not the GC’s business).
gcController.heapLive over-estimates. It counts whole spans when
it updates, because it is updated when a span is fetched from an
mcentral. The comment says so explicitly: “It’s better to overestimate
than to underestimate because 1) this triggers the GC earlier than
necessary rather than potentially too late”.
The assist ratio is approximate. The two ratio fields are updated atomically but not together, so they can be briefly skewed relative to each other. The values move slowly, so it does not matter in practice.
GODEBUG=gctrace=1 is the tool, not the metrics. The metrics tell
you the goal and the live heap; the trace tells you where the CPU
went.
Wrapping up
The one thing to remember: the collector starts at the trigger, not at the goal, and finishes at the goal. The gap between them is runway, and the runway is what keeps the GC from having to stop the world.
The part people get wrong: GOGC=100 does not mean “collect when
the heap doubles”. It means “collect when the heap grows by 100% of
the live heap plus the scannable stacks and globals”, floored at a
few megabytes, and then capped by whatever GOMEMLIMIT says.
What to go do about it: turn on GODEBUG=gctrace=1 and read the
“goal” number next to your actual heap size. If they are far apart,
you have slack and can afford a lower GOGC. If they are close, you
are running out of runway and should look at allocation rate before
touching anything else.
Further reading
src/runtime/mgcpacer.go— the whole pacer: heap goal, trigger, assist ratio, worker counts, and the memory-limit goal.src/runtime/mgc.go— the GC phases, trigger kinds, GC modes, andgcMarkDone’s termination check.src/runtime/mgcmark.go—gcAssistAllocandgcAssistAlloc1, the mutator assist path and the assist queue.src/runtime/malloc_stubs.go—deductAssistCredit, where every allocation is charged.src/runtime/mgcwork.go— thegcWorktype and the scan-work credit accounting that feeds the assist ratio.src/runtime/mgcsweep.go—gcPaceSweeper, the other half of the pacing story.src/runtime/mgcscavenge.go—gcPaceScavenger, the memory-limit counterpart of the heap goal.src/runtime/metrics/description.go— the metrics that expose the goal and the live heap.