Problem Statement
Your high-frequency trading system written in Go has latency spikes every few seconds. Using GODEBUG, you discover GC pauses are the culprit. Explain Go's garbage collector and how to tune it for low-latency applications.
Tri-Color Mark and Sweep Algorithm
Go uses a concurrent, tri-color, mark-and-sweep garbage collector:
- White: Not yet scanned (candidates for collection)
- Grey: Scanned, but children not scanned
- Black: Scanned, and all children scanned (reachable)
GC Phases
┌────────────────────────────────────────────────────────┐
│ 1. Mark Setup (STW ~10-30μs) │
│ - Enable write barrier │
│ - Scan stacks │
├────────────────────────────────────────────────────────┤
│ 2. Concurrent Mark (runs with app) │
│ - Traverse heap │
│ - Mark reachable objects │
├────────────────────────────────────────────────────────┤
│ 3. Mark Termination (STW ~10-30μs) │
│ - Drain remaining work │
│ - Disable write barrier │
├────────────────────────────────────────────────────────┤
│ 4. Sweep (concurrent) │
│ - Reclaim white objects │
│ - Return memory to allocator │
└────────────────────────────────────────────────────────┘Debugging GC with GODEBUG
GODEBUG=gctrace=1 ./myapp
# Output:
# gc 1 @0.012s 2%: 0.018+1.2+0.019 ms clock, 0.14+0.20/0.80/0+0.15 ms cpu,
# 4->4->1 MB, 5 MB goal, 8 P
#
# Meaning:
# gc 1 - GC number
# @0.012s - Time since start
# 2% - % of CPU used for GC
# 0.018+1.2+0.019 - STW mark setup, concurrent mark, STW mark term
# 4->4->1 MB - Heap before GC, after GC, live dataTuning GC for Low Latency
import "runtime/debug"
func init() {
// GOGC: Target heap growth % before next GC
// Default: 100 (double heap size triggers GC)
// Lower = more frequent GC, less memory, more CPU
debug.SetGCPercent(50)
// GOMEMLIMIT: Soft memory limit (Go 1.19+)
// GC triggers more aggressively as limit approaches
debug.SetMemoryLimit(1 << 30) // 1GB
}Reducing GC Pressure
// 1. Use sync.Pool for frequently allocated objects
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 4096)
},
}
func process(data []byte) {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf)
// Use buf...
}
// 2. Pre-allocate slices
// Bad: grows and reallocates
items := []Item{}
for i := 0; i < 1000; i++ {
items = append(items, Item{})
}
// Good: single allocation
items := make([]Item, 0, 1000)
for i := 0; i < 1000; i++ {
items = append(items, Item{})
}
// 3. Use value types instead of pointers when possible
// (keeps data on stack, not heap)The Ballast Technique (Legacy)
// Before GOMEMLIMIT: Allocate a large unused byte slice
// to trick GC into running less frequently
var ballast = make([]byte, 10<<30) // 10GB
// This is now obsolete with GOMEMLIMIT in Go 1.19+Follow-up Questions
- Is Go's GC generational? Compacting?
- What is a write barrier and why is it needed?
- How does sync.Pool interact with GC cycles?