TL;DR — Go’s concurrent, non‑generational garbage collector (GC) can be tuned for sub‑millisecond pause times even under heavy parallel load. By understanding GC phases, adjusting GOGC, employing object‑reuse patterns, and leveraging built‑in profiling, you can keep latency low while maintaining throughput.

Modern Go services rarely run in isolation; they handle thousands of requests per second, spawn goroutines at scale, and share memory across many cores. In such an environment the garbage collector becomes a performance hinge point. This article walks through how Go’s GC works, why concurrency matters, what knobs you can turn, and how to verify that your changes actually improve latency and throughput.

Overview of Go’s Garbage Collector

Go’s GC, introduced in Go 1.5 and refined through every release, is a concurrent, tri‑color, non‑generational collector. The key ideas are:

  1. Concurrent Mark – the runtime walks the heap while the program continues to run, marking live objects.
  2. Write Barrier – every pointer write goes through a barrier that records “dirty” objects, ensuring the concurrent mark sees updates.
  3. Sweep – after the mark phase, the runtime sweeps unmarked memory, returning it to the heap.

Because the GC runs concurrently, most of the work happens without stopping the world (STW). The only STW phases are brief GC start and GC end pauses, which are where latency spikes originate.

The collector is non‑generational, meaning it treats all objects the same regardless of age. This simplifies the implementation but also means short‑lived objects don’t get a special fast‑track. Go 1.22 introduced a modest “generational‑like” optimization for stack objects, but the heap collector remains largely uniform.

The GC’s aggressiveness is controlled by the GOGC environment variable (or runtime/debug.SetGCPercent). The default value of 100 means the heap may grow by 100 % before a collection is triggered. Lowering GOGC reduces heap size and pause frequency at the cost of higher CPU usage.

The GC Cycle in Detail

PhaseWhat HappensApprox. Time
Mark Start (STW)Runtime stops the world, sets up data structures, records the current heap size.< 0.1 ms
Concurrent MarkGoroutine execution continues; the write barrier records pointers written after the mark started.Majority of total GC time
Mark Finalization (STW)Finish marking, process the dirty set, ensure no live objects are missed.0.2–0.5 ms
Sweep (concurrent)Unmarked objects are reclaimed; the sweep can be parallelized across P’s.Overlaps with program execution
Sweep End (STW)Final cleanup, update heap statistics.< 0.1 ms

These numbers are typical on a modern 8‑core server running a modest workload; they can increase under heavy allocation pressure or when the GC is forced to work harder (e.g., low GOGC).

How Concurrency Affects GC

Goroutine Scheduling and P‑M Model

Go’s runtime uses the P‑M model: a set of P (processor) structures each bound to an OS thread (M) that executes goroutine work. The GC interacts with this model in two ways:

  • Preemptive Scheduling – The runtime can preempt a long‑running goroutine at safe points, giving the GC a chance to run its write barrier and mark work.
  • Work Stealing – During the concurrent mark, each P performs a portion of the scan. If one P finishes early, it can steal work from another, keeping all cores busy.

When you increase concurrency (e.g., by launching thousands of goroutines), you also increase the number of write barrier invocations, which adds overhead to each pointer write. The cost is small per operation but can add up in tight loops.

Allocation Patterns

Concurrent programs often allocate short‑lived objects (JSON structs, request‑scoped buffers). If these allocations are burst‑y and concentrated in a few goroutines, the GC may see a large amount of work at the end of the burst, causing a pause spike. Conversely, spreading allocations evenly across goroutines smooths the workload.

A useful heuristic: If the allocation rate exceeds roughly 10 MiB/s per GOMAXPROCS, expect GC CPU usage > 30 %. This is not a hard rule but a practical guideline observed in production services.

Example: High‑Concurrency Echo Server

package main

import (
	"net"
	"sync"
)

func handle(conn net.Conn, wg *sync.WaitGroup) {
	defer wg.Done()
	buf := make([]byte, 4096) // allocate per connection
	for {
		n, err := conn.Read(buf)
		if err != nil {
			return
		}
		_, _ = conn.Write(buf[:n])
	}
}

func main() {
	ln, _ := net.Listen("tcp", ":8080")
	var wg sync.WaitGroup
	for {
		c, _ := ln.Accept()
		wg.Add(1)
		go handle(c, &wg)
	}
}

In this naive server each connection allocates a 4 KiB buffer that lives for the lifetime of the connection. Under a load of 10 k concurrent connections the heap quickly balloons, prompting frequent GC cycles. The fix is to reuse buffers from a pool, dramatically reducing allocation churn.

Tuning GC for Low Latency

1. Adjust GOGC Dynamically

Instead of a static environment variable, you can adjust the GC target at runtime based on observed latency.

import "runtime/debug"

func setTargetHeapGrowth(percent int) {
	debug.SetGCPercent(percent)
}

A common pattern is to start with GOGC=100 and, when latency spikes are detected, lower it to 70 or 50 for a brief period. Remember that a lower GOGC increases CPU usage; monitor both latency and CPU to avoid saturation.

2. Use runtime/pprof to Identify Allocation Hotspots

go test -run=BenchmarkMyService -benchmem -memprofile=mem.out
go tool pprof -http=:8080 mem.out

The pprof UI will highlight functions with high allocation rates. Refactor those functions to reuse objects or to allocate on the stack (e.g., by avoiding escape to heap).

3. Leverage sync.Pool for Reusable Buffers

sync.Pool provides per‑P object caches, reducing contention and avoiding global locks.

var bufPool = sync.Pool{
	New: func() interface{} { return make([]byte, 4096) },
}

func handle(conn net.Conn, wg *sync.WaitGroup) {
	defer wg.Done()
	buf := bufPool.Get().([]byte)
	defer bufPool.Put(buf)
	// use buf as before
}

Because each P has its own sub‑pool, the GC sees the pooled objects as long‑lived, which reduces the amount of work needed during a collection.

4. Enable Heap Dump Analysis

Periodically dump the heap to understand object lifetimes:

go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap

Look for high‑frequency short‑lived objects (e.g., []byte slices) and consider pooling or using bytes.Buffer with a pre‑allocated capacity.

5. Pin Critical Goroutines to a Subset of P’s

If a latency‑sensitive request handler must not be paused, you can limit the number of P’s that the GC may preempt by setting GOMAXPROCS lower for that part of the program and using runtime.LockOSThread for the critical goroutine.

runtime.GOMAXPROCS(4) // reserve 4 cores for critical path
runtime.LockOSThread() // keep goroutine on its thread

Note: This is an advanced technique and should be benchmarked; it can starve other work if misused.

Profiling and Monitoring GC

Runtime Metrics

The Go runtime exposes a set of metrics via the expvar and net/http/pprof endpoints. Key metrics include:

  • gc_pause_total_ns – cumulative pause time.
  • gc_pause_ns – recent pause durations (as a histogram).
  • heap_alloc_bytes – live heap size.
  • heap_objects – number of allocated objects.

You can scrape these with Prometheus:

# prometheus.yml excerpt
scrape_configs:
  - job_name: 'go_app'
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: '/debug/vars'

Visualizing Pause Times

Grafana dashboards can plot gc_pause_ns percentiles. A healthy low‑latency service keeps the 99th percentile of pause times under 1 ms. If you see spikes beyond 5 ms, investigate allocation bursts or consider lowering GOGC.

Benchmarking GC Impact

Write a benchmark that runs under different GOGC settings and measures latency.

package gcbench

import (
	"testing"
	"runtime/debug"
)

func BenchmarkLatency(b *testing.B) {
	for _, gc := range []int{200, 100, 70, 50} {
		b.Run(fmt.Sprintf("GOGC=%d", gc), func(b *testing.B) {
			debug.SetGCPercent(gc)
			for i := 0; i < b.N; i++ {
				// simulate request handling
				handleFakeRequest()
			}
		})
	}
}

Running go test -bench=. -benchmem will output allocation statistics and elapsed time per configuration, letting you pick the sweet spot.

Common Pitfalls and Anti‑Patterns

PitfallWhy It HurtsRemedy
Allocating large slices inside hot loopsTriggers frequent large‑object collections, which are more expensive than small objects.Pre‑allocate once, reuse, or use bytes.Buffer with Grow.
Using interface{} heavilyCauses heap allocations for the underlying concrete value and the interface header.Prefer concrete types when possible; avoid generic containers for performance‑critical paths.
Neglecting defer in tight loopsdefer adds a small overhead per iteration and may keep objects alive longer.Inline cleanup or use explicit if blocks.
Relying on the GC to clean up large cachesThe GC may not reclaim cache entries quickly, leading to memory bloat.Implement explicit eviction (e.g., LRU) or use runtime.GC() sparingly.
Setting GOGC to 0Disables automatic GC, causing unbounded heap growth and eventual OOM.Use 0 only for short‑lived programs; never in long‑running services.

Key Takeaways

  • Go’s GC is concurrent and low‑pause by design, but allocation patterns and write‑barrier frequency heavily influence latency.
  • Tune GOGC dynamically, use sync.Pool, and keep object lifetimes short to minimise pause times.
  • Profile with pprof, runtime/metrics, and external monitoring (Prometheus/Grafana) to spot allocation hot‑spots and pause spikes.
  • Avoid common anti‑patterns such as per‑request large allocations, over‑use of interface{}, and unchecked caches.
  • Test different GC settings under realistic load; the “right” GOGC is workload‑specific and often lies between 50 and 150.

Further Reading