Go GC Mechanism

Contents

Go’s GC went through three stages: mark-and-sweep (full STW) → tri-color marking + insertion/deletion write barriers (concurrent marking) → tri-color marking + hybrid write barrier. This post follows that evolution, then covers today’s trigger conditions, the full cycle, and tuning.

One-Paragraph Recap

Go’s GC is fundamentally concurrent mark-and-sweep. Full-cycle STW marking pauses grow with the heap, so tri-color marking makes it concurrent: white, grey, and black mean unscanned, queued for scanning, and fully scanned, the collector runs a BFS from the roots, and whatever is still white when the grey queue empties is garbage. The problem with concurrent marking is that the program keeps mutating references while marking runs, so a black object can be handed a pointer to a white one, and the white object never gets scanned; heap pointer writes therefore pass through a write barrier: the insertion barrier shades the new value, the deletion barrier shades the old value being overwritten. Better to over-mark than to miss a live object, at the cost of floating garbage collected next cycle. The hybrid barrier merges both: overwriting a heap reference unconditionally shades the old value, and the new value is also shaded while the writer’s stack is unscanned, so stacks never need a rescan. Each cycle has only two short STW pauses (mark setup and mark termination), concurrent marking is the main phase, and sweeping is lazy, spread across normal execution. GC is triggered four ways: the heap reaching the GOGC threshold (default 100, i.e. the heap doubling), a 2-minute sysmon fallback, an explicit runtime.GC() call, and the GOMEMLIMIT soft limit. For tuning, start with GODEBUG=gctrace=1; raise GOGC when memory is plentiful, and set GOMEMLIMIT in containers.

Stage 1: Mark-and-Sweep (Go 1.3 and earlier)

Both marking and sweeping stopped the world. Marking had to pause the program: while the program runs it keeps mutating references, so the mark result can’t be trusted — a live object could be collected as garbage.

The pause scaled with heap size and object count: the bigger the heap, the longer the stop. Latency-sensitive programs couldn’t take it. That’s the root reason for everything that followed.

Stage 2: Tri-Color Marking + Write Barrier (Go 1.5+)

Tri-color marking

During marking, every object is one of three colors:

  • White: not scanned yet; possibly garbage.
  • Grey: discovered, but its references haven’t been scanned yet; waiting in the queue.
  • Black: scanned; all its references have been processed; definitely live.

The flow: start from the roots (global variables, stacks, registers) and mark them grey; then keep taking objects from the grey queue, grey their referents, and turn them black; when the grey queue is empty, whatever is still white is garbage and gets collected. This is basically a breadth-first search (BFS) of the object graph: the grey queue is the BFS queue, black objects are the dequeued nodes.

Diagram Code
flowchart LR
    subgraph roots[Roots]
        R[globals / stacks / registers]
    end
    subgraph heap[Heap]
        A["A (black)"]
        B["B (grey)"]
        C["C (white)"]
        D["D (white)"]
    end
    R --> A
    A --> B
    A -. not scanned .-> C
    D -. no refs, garbage .-> X[collected]
flowchart LR
    subgraph roots[Roots]
        R[globals / stacks / registers]
    end
    subgraph heap[Heap]
        A["A (black)"]
        B["B (grey)"]
        C["C (white)"]
        D["D (white)"]
    end
    R --> A
    A --> B
    A -. not scanned .-> C
    D -. no refs, garbage .-> X[collected]
flowchart LR
    subgraph roots[Roots]
        R[globals / stacks / registers]
    end
    subgraph heap[Heap]
        A["A (black)"]
        B["B (grey)"]
        C["C (white)"]
        D["D (white)"]
    end
    R --> A
    A --> B
    A -. not scanned .-> C
    D -. no refs, garbage .-> X[collected]
flowchart LR
    subgraph roots[Roots]
        R[globals / stacks / registers]
    end
    subgraph heap[Heap]
        A["A (black)"]
        B["B (grey)"]
        C["C (white)"]
        D["D (white)"]
    end
    R --> A
    A --> B
    A -. not scanned .-> C
    D -. no refs, garbage .-> X[collected]

Tri-color marking isn’t a replacement for mark-and-sweep; it’s the concurrent version. Recording “how far each object has been processed” in a color lets marking advance incrementally: scanned objects turn black, pending ones stay grey, and the program keeps running. The sweep phase is unchanged.

The problem with concurrent marking: black referencing white

Marking runs concurrently while the program mutates the heap. A black object (already scanned, known live) may receive a new pointer to a white object that hasn’t been scanned. If nothing intercepts this, that white object will never be marked — it gets collected as garbage, and the next access to it crashes the program.

The fix is a write barrier: during GC, every heap pointer write is intercepted and the relevant objects are greyed.

Strong and weak tricolor invariants

Concurrent marking must prevent two kinds of corruption:

  1. A black object directly referencing a white object. The white object hangs under a black one, no grey object can reach it, and it never gets scanned.
  2. The grey-to-white reachability being broken. A grey object’s reference to a white one is overwritten, and the white object loses its path to discovery.

These map to two invariants:

  • Strong tricolor invariant: no black object may reference a white object. Breaks condition 1.
  • Weak tricolor invariant: a black object may reference a white one, as long as that white object is still reachable from some grey object. Breaks condition 2.

How does a white object know whether a black object is upstream of it? It doesn’t need to. The invariants aren’t maintained by objects checking themselves; the write barrier guarantees them on every pointer write: when a black object is about to receive a white reference, the barrier greys the white object on the spot — the “black → white” state is dismantled before it even forms.

Insertion barrier (Dijkstra)

When a pointer is written, grey the newly pointed-to object. Prevents black-to-white misses. For example, if the program writes a new pointer into a black object and the target is still white, it’s greyed immediately and enters the grey queue:

Diagram Code
flowchart LR
    B["B (black object)"] -->|"writes a new pointer"| W["W (white object)"]
    W -->|"insertion barrier: grey it"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"writes a new pointer"| W["W (white object)"]
    W -->|"insertion barrier: grey it"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"writes a new pointer"| W["W (white object)"]
    W -->|"insertion barrier: grey it"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"writes a new pointer"| W["W (white object)"]
    W -->|"insertion barrier: grey it"| GQ["grey queue"]

Go 1.5-1.7 actually shipped this barrier, applied unconditionally to heap pointer writes (shade(ptr) before writing the slot).

Why “Dijkstra”? Write barriers first appeared in Dijkstra’s 1978 paper On-the-fly garbage collection: an exercise in cooperation — tri-color marking itself comes from the same paper. The barrier’s rules are named after their author, hence the Dijkstra barrier.

Deletion barrier (Yuasa)

The other classic barrier. When a pointer is written, grey the object that the overwritten reference used to point to. Prevents a grey object from dropping its reference to a white one and leaving it unscanned:

Diagram Code
flowchart LR
    G["G (grey object)"] -->|"overwrites reference to W"| NEW["new object"]
    G -.->|"old reference pointed to"| W["W"]
    W -->|"deletion barrier: grey it"| GQ["grey queue"]
flowchart LR
    G["G (grey object)"] -->|"overwrites reference to W"| NEW["new object"]
    G -.->|"old reference pointed to"| W["W"]
    W -->|"deletion barrier: grey it"| GQ["grey queue"]
flowchart LR
    G["G (grey object)"] -->|"overwrites reference to W"| NEW["new object"]
    G -.->|"old reference pointed to"| W["W"]
    W -->|"deletion barrier: grey it"| GQ["grey queue"]
flowchart LR
    G["G (grey object)"] -->|"overwrites reference to W"| NEW["new object"]
    G -.->|"old reference pointed to"| W["W"]
    W -->|"deletion barrier: grey it"| GQ["grey queue"]

The cost: the barrier may grey and “rescue” an object that nobody references anymore. It survives this round and is only collected in the next one — floating garbage. Better to over-rescue than to miss. Note that there’s no such thing as “an object marked for deletion, removed in the next round”: white objects are collected right after marking ends. What actually gets delayed to the next round is floating garbage, rescued by the barrier by mistake.

Why “Yuasa”? This barrier comes from Taiichi Yuasa’s 1990 paper Real-time garbage collection on general-purpose machines, named after its author as well. Go didn’t use it before 1.8; the hybrid barrier introduced its idea.

Why stacks are scanned twice

The write barrier is only instrumented on heap pointer writes. Stack pointer writes don’t go through the barrier, purely for performance — stack writes are too frequent to intercept every time.

But marking is concurrent and the program keeps running. That raises the question of the insertion-barrier era: the stack is unguarded, so how do you guarantee it isn’t hiding white objects?

Go’s answer: scan twice.

  • First pass (at mark start): scan every goroutine’s stack; all stack references get greyed.
  • Second pass (mark termination STW): re-scan all stacks and grey the references that appeared during marking.

Why re-scan a stack that was already scanned? A scanned stack turns black, but that black is a “frozen snapshot” — once its goroutine runs again and writes new pointers onto the stack, the blackness is no longer trustworthy. With no stack write barrier recording those writes, the runtime has to be conservative: any scanned stack whose goroutine has run again reverts to grey and waits for the re-scan. Re-scanning walks every goroutine’s stack, so STW time scales with the number of goroutines — tens to hundreds of milliseconds are possible. This was the main source of pauses in Go 1.5-1.7, and the problem Stage 3 solves.

Diagram Code
flowchart LR
    subgraph t1[Mark start]
        A1[scan all stacks once
grey their references] --> A2[stacks temporarily black] end subgraph t2[During marking] B1[goroutine keeps running
writes new refs on the stack] --> B2[stack reverts to grey
no barrier records them] end subgraph t3[Mark termination STW] C1[re-scan all grey stacks
grey the new refs] end t1 --> t2 --> t3
flowchart LR
    subgraph t1[Mark start]
        A1[scan all stacks once
grey their references] --> A2[stacks temporarily black] end subgraph t2[During marking] B1[goroutine keeps running
writes new refs on the stack] --> B2[stack reverts to grey
no barrier records them] end subgraph t3[Mark termination STW] C1[re-scan all grey stacks
grey the new refs] end t1 --> t2 --> t3
flowchart LR
    subgraph t1[Mark start]
        A1[scan all stacks once
grey their references] --> A2[stacks temporarily black] end subgraph t2[During marking] B1[goroutine keeps running
writes new refs on the stack] --> B2[stack reverts to grey
no barrier records them] end subgraph t3[Mark termination STW] C1[re-scan all grey stacks
grey the new refs] end t1 --> t2 --> t3
flowchart LR
    subgraph t1[Mark start]
        A1[scan all stacks once<br>grey their references] --> A2[stacks temporarily black]
    end
    subgraph t2[During marking]
        B1[goroutine keeps running<br>writes new refs on the stack] --> B2[stack reverts to grey<br>no barrier records them]
    end
    subgraph t3[Mark termination STW]
        C1[re-scan all grey stacks<br>grey the new refs]
    end
    t1 --> t2 --> t3

Could you skip the first pass and scan only at termination? No. The first pass feeds the whole object graph reachable from stacks into the grey queue, where it gets digested during concurrent marking. Without it, stack roots are absent throughout marking: every stack-reachable object is only discovered when the termination STW scans the stacks, and all that marking work piles up inside the pause — the stall becomes uncontrollable. The termination re-scan is the correctness safety net (a stack turns grey whenever its goroutine has run, so it’s always re-scanned), but it can’t save performance — both passes are needed.

Note that mark progress is continuous across the two STWs: the grey queue and the black objects all survive; STW only pauses the program, and marking resumes from the grey queue once the world starts again. New references found by the second pass join the existing mark flow — nothing is restarted from scratch.

Stage 3: Tri-Color Marking + Hybrid Write Barrier (Go 1.8+)

Motivation: eliminating stack re-scanning

Stage 2’s stack re-scan made STW grow with the number of goroutines. Go 1.8 merged the insertion and deletion barriers into a hybrid write barrier (pseudocode from runtime/mbarrier.go):

writePointer(slot, ptr):
    shade(*slot)          # grey the old value, unconditional
    if the writer's stack hasn't been scanned:
        shade(ptr)        # grey the new value
    *slot = ptr
  • Deletion part, unconditional: any write that overwrites a heap reference greys the old object. This stops the program from hiding an object by moving the only pointer to it onto the stack — to hide it you must first unlink it, and the unlink greys it on the spot.
  • Insertion part, conditional: the new value only needs greying while the writer’s stack is still grey (unscanned).

Why must the new value be greyed while the stack is unscanned? The write installs the new value into a black object — already scanned, its references never processed again. Once that edge exists, the write barrier is the only thing guarding it. The stack is grey and will eventually be scanned, but a stack scan sees the references at the moment of scanning: the program can overwrite the stack copy of this pointer right after writing it (variable goes out of scope, slot reused), and the scan will find nothing. Waiting for the stack scan means the new value may already be “a white object inside a black one, with no grey protection at all” — a miss. So the new value must be greyed at the instant of the write and pushed to the grey queue, not later:

// marking in progress; this goroutine's stack hasn't been scanned yet
var root *Obj // black object, already scanned

func f(w *Obj) { // w may point to a white object: stack unscanned, refs unprocessed
    root.field = w // at write time: grey w and push it to the grey queue
    // afterwards w goes out of scope; the stack no longer holds it
    // if it wasn't greyed at write time, w would only be referenced
    // by root.field, and root is never scanned again → missed
}

Why can it be skipped once the stack is black? The key question is where the new value comes from.

A pointer written to the heap can’t materialize out of thin air — it has to be read from somewhere that already exists, most commonly a field of a heap object:

w := obj.field // w's value is read from obj.field

obj has exactly two possible states:

  • obj already scanned (black): when obj was scanned, every object its fields point to was greyed. Whatever w points to is already grey — the write needs no action.
  • obj not yet scanned (grey): obj is still queued in the grey queue, and scanning it will process its fields. What w points to may still be white, but it’s referenced by obj in the queue — it gets greyed naturally when obj is scanned. Nothing is missed.
Diagram Code
flowchart LR
    subgraph caseA[Case A: obj already scanned]
        A1["obj (black)"] -->|"field points to"| A2["target (already grey)"]
        A3["w = obj.field"] -.->|"writes to heap"| A2
    end
    subgraph caseB[Case B: obj not scanned]
        B1["obj (grey, in queue)"] -->|"field points to"| B2["target (white)"]
        B3["w = obj.field"] -.->|"writes to heap"| B2
        B2 -.->|"greyed when obj is scanned"| B4["not missed"]
    end
flowchart LR
    subgraph caseA[Case A: obj already scanned]
        A1["obj (black)"] -->|"field points to"| A2["target (already grey)"]
        A3["w = obj.field"] -.->|"writes to heap"| A2
    end
    subgraph caseB[Case B: obj not scanned]
        B1["obj (grey, in queue)"] -->|"field points to"| B2["target (white)"]
        B3["w = obj.field"] -.->|"writes to heap"| B2
        B2 -.->|"greyed when obj is scanned"| B4["not missed"]
    end
flowchart LR
    subgraph caseA[Case A: obj already scanned]
        A1["obj (black)"] -->|"field points to"| A2["target (already grey)"]
        A3["w = obj.field"] -.->|"writes to heap"| A2
    end
    subgraph caseB[Case B: obj not scanned]
        B1["obj (grey, in queue)"] -->|"field points to"| B2["target (white)"]
        B3["w = obj.field"] -.->|"writes to heap"| B2
        B2 -.->|"greyed when obj is scanned"| B4["not missed"]
    end
flowchart LR
    subgraph caseA[Case A: obj already scanned]
        A1["obj (black)"] -->|"field points to"| A2["target (already grey)"]
        A3["w = obj.field"] -.->|"writes to heap"| A2
    end
    subgraph caseB[Case B: obj not scanned]
        B1["obj (grey, in queue)"] -->|"field points to"| B2["target (white)"]
        B3["w = obj.field"] -.->|"writes to heap"| B2
        B2 -.->|"greyed when obj is scanned"| B4["not missed"]
    end

So a new value written from a black stack is either already grey or still hanging off a grey object waiting to be scanned — it can never be a “completely unprotected white”. The insertion part can be skipped.

Result: stacks are scanned once

No more pre-scan at mark start, no more re-scan. During concurrent marking, stacks are scanned one goroutine at a time (suspend the goroutine, run scanstack, turn the stack black); the short mark-termination STW only finishes whatever stacks are left.

During concurrent marking, stack scans and heap scans are interleaved — there’s no “scan all stacks first, then the heap” order. The grey queue holds both “scan a heap object” and “scan a goroutine stack” work items, and GC workers just take whichever comes next:

Diagram Code
flowchart LR
    S1["scan stack A"] --> S2["scan heap object X"] --> S3["scan stack B"] --> S4["scan heap object Y"] --> S5["scan stack C"]
flowchart LR
    S1["scan stack A"] --> S2["scan heap object X"] --> S3["scan stack B"] --> S4["scan heap object Y"] --> S5["scan stack C"]
flowchart LR
    S1["scan stack A"] --> S2["scan heap object X"] --> S3["scan stack B"] --> S4["scan heap object Y"] --> S5["scan stack C"]
flowchart LR
    S1["scan stack A"] --> S2["scan heap object X"] --> S3["scan stack B"] --> S4["scan heap object Y"] --> S5["scan stack C"]

Scanned stacks and objects turn black and never need re-scanning — once a stack is black, writes made while its goroutine keeps running are covered by the hybrid barrier (previous section). That’s how Go 1.8 could push stack scanning fully into the concurrent phase.

Objects allocated during GC are black from the start (allocate black). Even if they become garbage immediately, they’re not collected this round — they’re cleaned up in the next one. Floating garbage again.

Two concrete scenarios:

  • The program writes a freshly obtained pointer into a black object, pointing at a white object: the writer’s stack hasn’t been scanned, so the insertion part greys the white object and it enters the grey queue. Not missed.
Diagram Code
flowchart LR
    S["stack (grey, unscanned)"] -.->|"holds the pointer"| W["W (white object)"]
    B["B (black object)"] -->|"writes B.slot = W"| W
    W -.->|"insertion part fires: grey"| GQ["grey queue"]
flowchart LR
    S["stack (grey, unscanned)"] -.->|"holds the pointer"| W["W (white object)"]
    B["B (black object)"] -->|"writes B.slot = W"| W
    W -.->|"insertion part fires: grey"| GQ["grey queue"]
flowchart LR
    S["stack (grey, unscanned)"] -.->|"holds the pointer"| W["W (white object)"]
    B["B (black object)"] -->|"writes B.slot = W"| W
    W -.->|"insertion part fires: grey"| GQ["grey queue"]
flowchart LR
    S["stack (grey, unscanned)"] -.->|"holds the pointer"| W["W (white object)"]
    B["B (black object)"] -->|"writes B.slot = W"| W
    W -.->|"insertion part fires: grey"| GQ["grey queue"]
  • The program overwrites a heap reference; the old object is greyed unconditionally: if it’s still referenced elsewhere, safe; if nothing references it anymore, it’s collected next round. This happens regardless of the writer’s stack color — the deletion part always fires.
Diagram Code
flowchart LR
    B["B (black object)"] -->|"overwrites B.slot with a new object"| NEW["new object"]
    B -.->|"old reference detached"| W["W (white object)"]
    W -->|"deletion part: grey unconditionally"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"overwrites B.slot with a new object"| NEW["new object"]
    B -.->|"old reference detached"| W["W (white object)"]
    W -->|"deletion part: grey unconditionally"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"overwrites B.slot with a new object"| NEW["new object"]
    B -.->|"old reference detached"| W["W (white object)"]
    W -->|"deletion part: grey unconditionally"| GQ["grey queue"]
flowchart LR
    B["B (black object)"] -->|"overwrites B.slot with a new object"| NEW["new object"]
    B -.->|"old reference detached"| W["W (white object)"]
    W -->|"deletion part: grey unconditionally"| GQ["grey queue"]

Trigger conditions

GC fires on four kinds of triggers (gcTrigger):

  • gcTriggerHeap (heap threshold): the main one. Fires when live heap objects grow past a threshold. The threshold is controlled by GOGC, default 100, meaning “trigger when the heap has grown ~100% since the last GC”. For example, if 4MB survived the last GC, the next one fires when the heap reaches 8MB. GOGC is an environment variable; it can also be changed in code with debug.SetGCPercent.
  • gcTriggerTime (timer): forcegcperiod is 2 minutes; sysmon checks it and forces a GC when none has run for that long. Even a fully idle program gets periodic GCs.
  • gcTriggerCycle (manual): runtime.GC() in code.
  • GOMEMLIMIT (soft memory limit): Go 1.19+. When the heap approaches this limit, GC frequency is raised to keep it under control. It’s a soft limit: it may be briefly exceeded, but GC works to pull it back. Off by default.

The full cycle

One GC runs in four phases (gcStart → gcMarkDone → gcSweep):

  1. Sweep (finish the previous round): garbage from the last cycle that hasn’t been swept yet is cleaned up before this cycle starts. Sweeping is lazy — sweepone sweeps one span at a time, spread across normal execution instead of one concentrated pause.
  2. Mark Setup (short STW): stopTheWorld; all Ms reach safe points, Ps transition to _Pgcstop, gcphase flips from _GCoff to _GCmark, and the write barrier turns on. This STW is very short (microseconds to sub-millisecond).
  3. Marking (concurrent, the bulk): the world resumes; marking runs on background GC workers (gcBgMarkWorker, using P resources) while the program keeps executing. The write barrier intercepts heap pointer writes and greys the relevant objects. When the grey queue is empty, marking is done.
  4. Mark Termination (short STW): the world stops again; remaining unscanned stacks are finished, gcphase flips to _GCmarktermination, and the write barrier turns off. Then the world resumes into the next round’s Sweep phase.

Overall each GC has only two short STWs; the bulk is concurrent marking. That’s why Go has kept GC pauses in the millisecond range since 1.5.

Tuning

  • GOGC: default 100. Lower it: GC runs more often, memory usage drops, at the cost of more CPU. Raise it: the reverse. When memory is plentiful and throughput matters, raise it.
  • GOMEMLIMIT: Go 1.19+, GOMEMLIMIT=500MiB or debug.SetMemoryLimit. Sets a soft memory ceiling for containers; GC raises its frequency to hold memory under it.
  • GODEBUG=gctrace=1: prints each GC’s duration, STW time, and memory usage. This is the tool for observing GC behavior.

Start debugging from gctrace output, then decide whether to move GOGC or GOMEMLIMIT — don’t tune blind.

Interview Questions

What were the main stages in the evolution of Go’s GC?

Go 1.3 and earlier mainly used fully stop-the-world mark-and-sweep. The program was paused for marking and sweeping, so pause time grew with the heap and object count.

Go 1.5 introduced tri-color marking and concurrent marking, with an insertion barrier protecting heap pointer writes. Go 1.8 switched to the hybrid write barrier, moved stack scanning into the concurrent phase, and reduced each stack to one scan without a full stack rescan at mark termination.

How does tri-color marking work?

  • A white object has not been scanned. If it is still white when marking ends, it is reclaimed.
  • A grey object has been discovered, but its outgoing references have not all been processed.
  • A black object has been fully scanned, and all objects it references have entered the marking process.

GC starts from roots such as globals and stacks, then places reachable objects in the grey queue. Once the grey queue is empty, the remaining white objects are garbage for this cycle.

What is the purpose of a write barrier, and what does the hybrid write barrier do?

A write barrier is extra logic executed during pointer writes to protect concurrent marking. Without it, the program could make an already-scanned black object point to an unmarked white object, causing GC to miss a live object.

The hybrid write barrier used since Go 1.8 has two parts: it unconditionally shades the old value when overwriting a heap pointer; it also shades the new value if the writer’s stack has not been scanned. This prevents objects from being hidden behind unprotected references and removes the full stack rescan at mark termination.

Which phases of a GC cycle stop the world?

A GC cycle has two stop-the-world pauses:

  1. Mark setup: switch to the marking state, enable the write barrier, and prepare root scanning and background marking work.
  2. Mark termination: finish the remaining marking work, change the GC phase, then disable the write barrier.

Concurrent marking is the main body of the work, and the program continues running during it. Sweeping is normally lazy, so the whole sweep phase is not concentrated in one stop-the-world pause.

What can trigger Go’s GC?

  • gcTriggerHeap fires when the heap reaches the trigger threshold calculated by the GC controller.
  • gcTriggerTime is requested by the background monitor when more than two minutes have passed since the previous GC. This timer trigger is disabled when GOGC=off.
  • Calling runtime.GC() forces a new cycle through gcTriggerCycle.
  • When GOMEMLIMIT is set, the runtime may lower the GOGC-based heap goal and run GC earlier.

What exactly does GOGC=100 mean?

GOGC controls the heap growth goal for the next GC cycle, and its default is 100. It means the next GC should finish when newly allocated data is approximately 100% of the live data left after the previous GC. If the previous live heap was 4 MiB, the goal is roughly 8 MiB.

The source also includes the previous cycle’s stack and global scan work when calculating the heap goal:

heap goal = live heap + (live heap + stack scan + globals scan) × GOGC / 100

The GC controller calculates the actual start threshold below the heap goal to leave enough runway for concurrent marking. Therefore, 8 MiB is an approximate completion goal, not a fixed start point. A negative value or GOGC=off disables GOGC-based triggering, but the soft memory limit can still trigger GC.

How should Go GC be tuned?

Start with GODEBUG=gctrace=1 or runtime metrics to inspect GC frequency, heap size, and pause time, then locate allocation hotspots. If the program creates many short-lived objects, reduce allocations and reuse buffers before changing GOGC to hide the problem.

When memory is plentiful and throughput matters more, raising GOGC reduces GC frequency. Containers and memory-constrained services should set GOMEMLIMIT while leaving room for non-Go heap memory and system overhead. Lowering GOGC reduces heap usage but increases GC CPU cost.

Escape analysis is the compiler process that decides whether some values can safely remain on the stack or must be allocated on the heap. Stack values disappear automatically when their function returns and do not need separate GC reclamation; values that escape to the heap increase allocation and scanning work.

Reducing unnecessary escapes often lowers GC pressure, but correctness should not be sacrificed to avoid an escape. Use go build -gcflags='-m=2' to inspect the compiler’s decisions, then use performance data to decide whether the code should change.

Edit this page

Contents