Go Garbage Collection Internals: Tri-Color Mark-and-Sweep, Write Barriers, and Latency Trade-offs Explained

Go’s garbage collector gets dismissed in both directions. Critics from the Java or C++ world call it immature. Go developers, relieved to not write free(), often treat it as a black box they would rather not think about. Both positions miss something important.

Go’s GC is a concurrent, tri-color, mark-and-sweep collector with a write barrier that allows the garbage collector and application goroutines to run simultaneously, achieving pause times that are consistently under one millisecond even on heaps measured in gigabytes. That is a non-trivial engineering achievement, and it is built on a small set of ideas that are entirely understandable.

This post explains those ideas precisely: what tri-color means, why a write barrier is necessary, what triggers a cycle, how the GC is tuned, and what you can do to reduce its cost in performance-critical code.


What the GC Must Achieve

Before the mechanism, the goal. A garbage collector must satisfy two properties simultaneously:

Safety: No live object is ever collected. An object is live if it is reachable from any root — a goroutine stack, a global variable, or a register value.

Completeness: Every unreachable object is eventually collected. Memory is not leaked indefinitely.

A stop-the-world collector satisfies both trivially: freeze the program, walk all references, collect everything unreachable, resume. The cost is pause latency — the entire program stalls for the duration of the GC cycle, which scales with heap size.

Go’s design goal is to make GC pauses predictably short — under 1ms in most cases — regardless of heap size. Achieving this while maintaining safety requires running the collector concurrently with the application. Concurrent collection is what makes correctness hard.


The Tri-Color Abstraction

The tri-color algorithm, due to Dijkstra, provides a framework for thinking about concurrent mark-and-sweep. Every object on the heap is conceptually one of three colors:

  • White: Not yet reached. At the start of a GC cycle, everything is white. At the end of a complete cycle, white objects are unreachable — they are garbage.
  • Grey: Discovered but not yet fully scanned. The GC knows the object is reachable, but has not yet followed all the pointers it contains.
  • Black: Fully scanned. The object is reachable, and all objects it points to have been added to the grey set. Black objects are known-live.
┌─────────────────────────────────────────────────────────────┐
│  GC Cycle Start: all objects WHITE                          │
│                                                             │
│   ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○                         │
└─────────────────────────────────────────────────────────────┘
                        │
                        │ Mark roots (stacks, globals)
                        ▼
┌─────────────────────────────────────────────────────────────┐
│  Roots are GREY; everything else still WHITE                │
│                                                             │
│   ● ○ ○ ● ○ ○ ● ○ ○ ○ ○ ○ ○ ● ○ ○                         │
│   (grey)   (grey)  (grey)       (grey)                     │
└─────────────────────────────────────────────────────────────┘
                        │
                        │ Drain grey set: scan each grey object,
                        │ colour it BLACK, colour its referents GREY
                        ▼
┌─────────────────────────────────────────────────────────────┐
│  Reachable objects BLACK; unreachable remain WHITE          │
│                                                             │
│   ■ ■ ■ ■ ○ ○ ■ ○ ○ ■ ■ ○ ○ ■ ■ ■                         │
│   (black/live)   (white/garbage)                           │
└─────────────────────────────────────────────────────────────┘
                        │
                        │ Sweep: reclaim all WHITE objects
                        ▼

The invariant that makes this correct in a stop-the-world setting is simple: a black object never points directly to a white object. If it did, following that pointer would lead to a white (presumably garbage) object that is actually live — a safety violation.

In a stop-the-world collector, the invariant holds trivially because the program cannot modify any pointers while the GC is running. Concurrency breaks this guarantee: a mutator goroutine can write a pointer from a black object to a white object at any moment, violating the invariant and risking premature collection.


The Problem with Concurrency: The Tricolor Invariant

Consider this scenario with concurrent mutator and GC:

Initial state (mid-GC):
  A (black) → B (grey) → C (white)

Mutator executes two operations:
  1. A.ref = C   (A now points to C)
  2. B.ref = nil (B drops its reference to C)

Resulting state:
  A (black) → C (white)   ← C is live but white!
  B (grey)  → nil

GC scans B: finds no references, colours B black.
GC sweep:   C is white → collected.
Result:     A holds a dangling pointer to freed memory. 💥

The GC has collected a live object. This is a safety violation. The two mutator writes, taken together, created a black-to-white reference while destroying the only grey-to-white reference that would have protected C.

This is called the lost object problem or a violation of the strong tri-color invariant: no black object may point to a white object.

There are two ways to prevent it:

  1. Snapshot-at-the-beginning (SATB): Preserve the graph as it was at the start of the cycle. Any reference that is overwritten is treated as still existing for the purposes of this GC cycle.
  2. Incremental update: When a black object acquires a pointer to a white object, re-grey the white object (or the black object) so the GC will revisit it.

Go uses a hybrid of both approaches, enforced by a write barrier.


The Write Barrier

A write barrier is a small piece of code injected by the compiler at every pointer write. It runs in the mutator goroutine, synchronously with the write, and informs the GC of the modification so the invariant can be maintained.

Go’s current write barrier (introduced in Go 1.17 as a simplification of the earlier Dijkstra+Yuasa hybrid) is called the hybrid write barrier. It implements both snapshot-at-the-beginning and incremental-update semantics:

On every pointer write:  *slot = new

  1. Shade(old)   // old value being overwritten → grey if white (SATB)
  2. Shade(new)   // new value being written → grey if white (incremental)
  3. *slot = new  // perform the actual write

Where Shade(p) means: if object p is white, colour it grey and add it to the work queue.

This ensures:

  • The old referent (old) is not silently dropped from the grey set when a reference is overwritten.
  • The new referent (new) is not silently made reachable through a black object without the GC knowing.

Either condition alone would be sufficient to prevent the lost object problem. Go uses both because together they allow a weaker invariant that eliminates the need to rescan goroutine stacks at the end of the cycle — an important latency optimisation.

The Cost of Write Barriers

Write barriers are not free. Every pointer write during a GC cycle executes additional instructions: a barrier check, potentially two Shade operations (each involving a CAS on the mark bitmap), and work queue operations if the object was white.

In pointer-heavy workloads — linked lists, trees, maps with pointer values — the write barrier overhead can be 10–30% of total CPU time during a GC cycle. This is the fundamental cost of concurrent collection. A stop-the-world collector pays no write barrier cost but pays latency. Go trades some throughput for latency.

Write barriers are only active during the marking phase of a GC cycle. Outside of a cycle, pointer writes are bare stores — no overhead.


The Full GC Cycle: Four Phases

Phase 1: Mark Setup (STW — Stop the World)

Duration: typically < 0.5 ms.

The runtime briefly stops all goroutines. During this pause it:

  1. Enables the write barrier on all goroutines.
  2. Scans goroutine stacks for root pointers and adds them to the grey set.
  3. Scans global variables for root pointers.
  4. Resumes all goroutines.

This is the first stop-the-world pause in a GC cycle. Its brevity is possible because it only enables infrastructure — it does not scan the heap.

Timeline:
  ────────────┬─────────┬──────────────────────────────────────
  Application │  STW 1  │  Application runs concurrently       
  ────────────┴─────────┴──────────────────────────────────────
                         ↑
                    Write barrier ON
                    Roots → grey set

Phase 2: Concurrent Marking

Duration: proportional to live heap size — this is the longest phase, but it runs concurrently.

GC goroutines (called mark workers) drain the grey work queue: pop a grey object, follow its pointers, colour its referents grey, colour itself black. This runs in parallel with mutator goroutines.

The write barrier keeps the grey set consistent as mutators modify the heap. Mutators do their normal work; they pay the write barrier cost on pointer writes.

Mark workers are scheduled by the Go scheduler alongside regular goroutines. They are throttled to use approximately 25% of available CPU time (configurable via GOGC indirectly, and directly via debug.SetGCPercent or runtime/debug.SetMemoryLimit).

Timeline:
  ──────────────────────────────────────────────────────────
  Application goroutines run (with write barrier overhead)
  ──────────────────────────────────────────────────────────
  GC mark workers run (~25% CPU)
  ──────────────────────────────────────────────────────────

Mark assist: If the allocation rate is so high that new grey objects are created faster than mark workers can drain them, mutator goroutines are asked to assist — they do GC marking work in proportion to the memory they are allocating. This is the mechanism that prevents the heap from growing without bound during a fast-allocating concurrent marking phase. If your application shows unexpected latency spikes during GC, mark assist is often the cause: goroutines that expected to do application work are spending cycles scanning the heap.

Phase 3: Mark Termination (STW — Stop the World)

Duration: typically < 0.5 ms.

When the grey work queue is empty, the GC cannot be certain marking is complete — mutators may have created new grey objects since the last drain. The runtime briefly stops all goroutines again to:

  1. Flush any remaining write barrier buffers.
  2. Rescan a small number of recently-modified stacks (stacks that were active during marking).
  3. Verify the grey set is truly empty.
  4. Disable the write barrier.
  5. Resume all goroutines.

This is the second and final stop-the-world pause. After it, the heap is fully marked: black objects are live, white objects are garbage.

Phase 4: Concurrent Sweep

Duration: proportional to total heap size — runs concurrently, incrementally.

The sweep phase reclaims white (garbage) objects. It runs concurrently with the application and is structured as a lazy sweep: memory is reclaimed in spans (large blocks of pages) as the allocator needs them for new allocations, rather than all at once.

This means the sweep phase has no distinct end point from the application’s perspective — it simply completes before the next GC cycle needs to begin. The allocator checks whether the span it is about to reuse has been swept, and sweeps it inline if not.

Complete GC Timeline:

  ──┬──────┬──────────────────────────────────┬──────┬──────────────────
    │STW 1 │    Concurrent Mark               │STW 2 │  Concurrent Sweep
    │<0.5ms│    (seconds, at 25% CPU)         │<0.5ms│  (lazy, concurrent)
  ──┴──────┴──────────────────────────────────┴──────┴──────────────────

What Triggers a GC Cycle

Go’s GC is pacing-based, not scheduled at fixed intervals. The primary trigger is the GOGC parameter (default: 100), which controls the heap growth ratio at which a new GC cycle begins.

Next GC target = live heap after last GC × (1 + GOGC/100)

With GOGC=100 (the default):

  • If 10 MB of live data survived the last GC, the next cycle begins when the heap reaches 20 MB.
  • If 500 MB survived, the next cycle begins at 1 GB.

This means the GC frequency scales with allocation rate, not with time. A program that allocates aggressively triggers GC more often. A program that allocates little can run for a long time between cycles.

import "runtime/debug"

// Reduce GC frequency: cycle when heap is 3× the live set
debug.SetGCPercent(200)

// Disable GC entirely (use with extreme care)
debug.SetGCPercent(-1)

// Force an immediate GC cycle (testing/benchmarking only)
runtime.GC()

SetMemoryLimit (Go 1.19+)

GOGC alone has a problem: if the live heap is small but the program is given a large memory quota, the GC triggers infrequently and peak memory usage can spike. runtime/debug.SetMemoryLimit provides a hard ceiling on the Go runtime’s total memory usage:

import "runtime/debug"

// Tell the GC: keep total memory use under 512 MB
debug.SetMemoryLimit(512 << 20)

When total memory approaches the limit, the GC is triggered more aggressively, even if the heap hasn’t grown to GOGC% of the previous live set. This is the recommended way to run Go programs in memory-constrained containers.

// Common pattern: read limit from environment (set by container runtime)
import (
    "runtime/debug"
    _ "go.uber.org/automaxprocs" // sets GOMAXPROCS from CPU quota
)

func init() {
    // Use 90% of the container memory limit as the GC ceiling
    // (the GOMEMLIMIT env var is read automatically in Go 1.19+)
    // Or set programmatically:
    debug.SetMemoryLimit(450 << 20) // 450 MB of a 512 MB container
}

The Pacer: Keeping GC Concurrent

For concurrent GC to work, the marking phase must complete before the heap reaches its growth target. If marking falls behind allocation, the heap would grow beyond the target before the cycle finishes — and if that happens consistently, the heap would grow without bound.

The GC pacer is the runtime component that prevents this. It:

  1. Estimates how long marking will take based on the previous cycle’s marking rate.
  2. Starts the GC cycle early enough (with a runway of headroom) that marking will finish before the heap limit is hit.
  3. Throttles mark workers and triggers mark assist if the allocation rate is faster than expected.

The pacer’s goal is to keep the GC “just ahead” of the allocator — marking fast enough to keep up with allocation without spending excessive CPU on GC work.

This is why Go’s GC does not guarantee that heap size will never exceed GOGC% above the live set — it is a target, not a hard limit. Under extreme allocation pressure, the heap may temporarily overshoot while the pacer catches up. SetMemoryLimit provides the hard cap if you need one.


Finalizers and Weak References

Finalizers

Go supports finalizers — functions that run when an object is about to be collected:

type Resource struct {
    fd int
}

func NewResource(fd int) *Resource {
    r := &Resource{fd: fd}
    runtime.SetFinalizer(r, func(r *Resource) {
        syscall.Close(r.fd) // cleanup when r is collected
    })
    return r
}

Finalizers interact with the GC in non-obvious ways:

  • An object with a finalizer is not collected in the cycle that first finds it unreachable. It is moved to a finalizer queue and the finalizer is run by a dedicated goroutine. The object is collected in the next cycle after the finalizer has run.
  • This means finalizers add at least one full GC cycle of delay to object reclamation.
  • Finalizers are not guaranteed to run before program exit.
  • Cycles of finalizable objects (A finalizes → points to B finalizes → points to A) are leaked permanently.

Use defer or explicit Close() methods for resource cleanup. Treat finalizers as a safety net, not a primary resource management strategy.

Weak References (Go 1.24+)

Go 1.24 introduced weak.Pointer[T] in the standard library — a pointer that does not prevent the pointed-to object from being collected:

import "weak"

type Cache struct {
    entries map[string]weak.Pointer[Entry]
}

func (c *Cache) Get(key string) (*Entry, bool) {
    wp, ok := c.entries[key]
    if !ok {
        return nil, false
    }
    // Value returns nil if the Entry has been collected
    entry := wp.Value()
    return entry, entry != nil
}

Weak references allow caches and interning structures to hold references without preventing collection — the GC can collect the entry if no strong references remain, and the cache naturally evicts it.


Observing GC Behaviour

GODEBUG=gctrace=1

GODEBUG=gctrace=1 ./myapp

Prints a line for every GC cycle:

gc 14 @4.932s 1%: 0.19+3.1+0.15 ms clock, 1.5+1.2/5.8/0+1.2 ms cpu,
     48->51->24 MB, 50 MB goal, 0 MB stacks, 0 MB globals, 8 P

Decoded:

Field Meaning
gc 14 14th GC cycle since program start
@4.932s Wall time since program start
1% Percentage of program time spent in GC
0.19+3.1+0.15 ms clock Wall time: STW mark setup + concurrent mark + STW mark termination
48->51->24 MB Heap size: before GC → peak during GC → after GC (live set)
50 MB goal The heap target that triggered this cycle
8 P GOMAXPROCS at the time

The most important fields for latency analysis are the STW pause times (first and third numbers in the clock field) and the GC CPU percentage. If STW pauses are consistently above 1ms, something unusual is happening — often a finalizer goroutine holding a lock, or an unusually large number of goroutine stacks to scan.

runtime.ReadMemStats

var stats runtime.MemStats
runtime.ReadMemStats(&stats)

fmt.Printf("Heap in use:      %d MB\n", stats.HeapInuse/1<<20)
fmt.Printf("Heap allocated:   %d MB\n", stats.HeapAlloc/1<<20)
fmt.Printf("Heap released:    %d MB\n", stats.HeapReleased/1<<20)
fmt.Printf("GC cycles:        %d\n",    stats.NumGC)
fmt.Printf("Total GC pause:   %v\n",    time.Duration(stats.PauseTotalNs))
fmt.Printf("Last GC pause:    %v\n",    time.Duration(stats.PauseNs[(stats.NumGC+255)%256]))
fmt.Printf("Next GC target:   %d MB\n", stats.NextGC/1<<20)
fmt.Printf("GC CPU fraction:  %.2f%%\n", stats.GCCPUFraction*100)

Note: runtime.ReadMemStats triggers a brief STW pause itself to get a consistent snapshot. Do not call it in tight loops.

Execution Tracer

go test -bench=. -trace=trace.out ./...
go tool trace trace.out

The trace viewer shows GC events on a timeline: mark start, mark end, sweep, assist intervals, and individual goroutine blocking events caused by GC. It is the highest-resolution view of GC behaviour — use it when gctrace shows anomalies you cannot explain from aggregate numbers alone.

Prometheus / expvar

For production, expose GC metrics continuously:

import (
    "expvar"
    "runtime"
    "time"
)

func init() {
    expvar.Publish("gc_pause_ns", expvar.Func(func() any {
        var s runtime.MemStats
        runtime.ReadMemStats(&s)
        if s.NumGC == 0 {
            return int64(0)
        }
        return int64(s.PauseNs[(s.NumGC+255)%256])
    }))
}

The prometheus/client_golang library’s collectors.NewGoCollector() exports all standard Go runtime metrics including GC pause histograms automatically.


Reducing GC Pressure: Practical Patterns

The best way to reduce GC cost is to reduce the number of heap objects the GC must mark. Fewer live objects → shorter marking phase → lower CPU cost.

Reduce Allocation Rate

Every heap allocation is a future GC cost. The strategies from escape analysis apply directly here:

  • Reuse objects with sync.Pool.
  • Pre-allocate slices with make([]T, 0, n).
  • Prefer values over pointers for small structs.
  • Avoid interface{}/any boxing in hot paths.

Reduce Pointer Density

The marking phase traverses pointers. An object with no pointer fields requires no pointer scanning — the GC marks it in constant time regardless of its size. An object with many pointer fields requires following each one.

// High pointer density — each node requires scanning 2 pointers
type LinkedNode struct {
    Value int
    Next  *LinkedNode
    Prev  *LinkedNode
}

// Zero pointer density — the GC marks this in O(1) regardless of length
type DenseData struct {
    Values [1024]int64 // no pointers — GC scans this as a unit
}

For lookup tables, caches, and other large collections of numeric data, []byte, []int64, or similar pointer-free slice types are dramatically cheaper for the GC than []*T or map[string]*T.

Use Pointer-Free Maps and Slices Where Possible

// Expensive for GC: map contains pointer values
type UserCache map[string]*User

// Cheaper for GC: map contains value types (if User has no pointer fields)
type UserID uint64
type UserCache map[UserID]User // no pointers in key or value → minimal GC scan

If User itself contains no pointer fields (no strings, slices, maps, or pointer fields), the map values require no pointer scanning. The GC only needs to scan the map’s internal bucket structures.

Slab Allocation: Group Objects to Reduce Pointer Count

// Naive: each Node is a separate heap object → N pointers for GC to track
nodes := make([]*Node, 1000)
for i := range nodes {
    nodes[i] = &Node{Value: i}
}

// Slab: all Nodes in one allocation → 1 pointer for GC to track
slab := make([]Node, 1000)
nodes := make([]*Node, len(slab))
for i := range slab {
    slab[i].Value = i
    nodes[i] = &slab[i] // points into the slab
}

The GC sees one heap object (the slab slice backing array) instead of 1,000. Marking the slab is a single operation.

arena Package (Experimental, Go 1.20+)

The arena package (currently in golang.org/x/exp/arena) allows allocating a group of objects into a manually-managed arena that can be freed all at once, bypassing the GC entirely for those objects:

import "arena"

a := arena.NewArena()
defer a.Free() // frees all objects allocated in this arena at once

req := arena.New[Request](a)     // allocated in arena, not GC heap
resp := arena.New[Response](a)   // same

// When a.Free() is called, all arena memory is reclaimed instantly.
// The GC never sees these objects.

Arenas are appropriate for request-scoped allocations in latency-sensitive servers where predictable GC pauses matter more than memory safety convenience. Use with care — accessing an arena-allocated object after a.Free() is undefined behaviour.


The Latency vs. Throughput Trade-off

Go’s GC makes a deliberate choice: sacrifice throughput to minimise latency. Understanding this trade-off helps set expectations.

Throughput cost:

  • Write barrier overhead on every pointer write during marking (~10–30% CPU in pointer-heavy code).
  • Mark assist work done by application goroutines under heap pressure.
  • ~25% of CPU reserved for mark workers during a cycle.

Latency benefit:

  • STW pauses consistently under 1ms.
  • Pause time does not scale with heap size (unlike stop-the-world collectors).
  • Predictable latency even on multi-gigabyte heaps.

Compare to alternative GC strategies:

Strategy Pause Throughput Memory overhead Go uses?
Stop-the-world mark-sweep O(live heap) — seconds on large heaps High (no write barrier) Low No (pre-1.5)
Concurrent mark, STW sweep Two short pauses + concurrent mark Medium Medium No
Concurrent mark-and-sweep Two STW < 1ms Medium (write barrier) Medium Yes
Generational GC Very short (minor); occasional longer (major) High for short-lived objects Higher (two heaps) Planned
Reference counting Near-zero (amortised) Low (counter updates) Low No

Why No Generational GC (Yet)?

Generational collectors exploit the generational hypothesis: most objects die young. By focusing collection effort on recently-allocated “nursery” objects, they achieve high throughput without full-heap scans.

Go has historically avoided generational collection for two reasons:

  1. Write barrier cost. A generational collector needs to track old-to-young pointers. Go’s write barrier would need to be more complex, adding overhead even outside GC cycles.
  2. Goroutine stacks. In Go, goroutine stacks contain many short-lived objects. Moving objects between generations would require either pinning stack objects (limiting the nursery’s effectiveness) or copying stack frames (expensive).

As of Go 1.24, the Go team has been working on a generational collector. The GOEXPERIMENT=greenteagc flag in development builds enables an experimental “Green Tea” GC that uses a generational approach. It is not yet production-ready, but the direction is clear.


Key Takeaways

Go’s tri-color concurrent mark-and-sweep GC is built on a small number of ideas working together:

  1. Tri-color invariant: White = unseen, grey = discovered, black = fully scanned. At cycle end, white = garbage. The invariant no black object points directly to a white object must hold throughout.

  2. Write barrier enforces the invariant concurrently. Every pointer write during marking shades the old and new values grey. This is the cost of concurrency: extra instructions on every pointer store during a GC cycle.

  3. Two brief STW pauses bookend a long concurrent phase. Setup (<0.5ms): enable write barrier, scan roots. Termination (<0.5ms): flush barriers, verify grey set empty. Between them: mark workers run concurrently at ~25% CPU.

  4. GOGC controls heap growth ratio, not GC frequency. GOGC=100 (default) triggers a cycle when the heap doubles. SetMemoryLimit provides a hard memory cap — essential in containers.

  5. Mark assist prevents heap runaway. If allocation outpaces marking, mutator goroutines assist the GC. This is the primary source of unexpected latency during GC in allocation-heavy programs.

  6. Reduce GC cost by reducing pointer count, not just allocation count. The marking phase traverses pointers. Pointer-free types ([]int64, value-type maps) are cheaper to mark than pointer-heavy types ([]*T, map[string]*T) regardless of size.

  7. gctrace and the execution tracer are your first diagnostic tools. Aggregate numbers from gctrace show the shape of GC behaviour. The trace viewer shows individual events when you need to understand a specific latency spike.

  8. Generational GC is coming. The current collector optimises for low latency at the cost of some throughput. A future generational collector will recover throughput for workloads with many short-lived objects, while preserving Go’s latency guarantees.

Go’s GC is not magic and it is not free. It is a carefully engineered system that makes a specific trade: predictable, sub-millisecond pauses in exchange for write barrier overhead and constrained throughput. Knowing that trade lets you design systems that work with the GC rather than against it.