Part III — Advanced: systems Go
You've used goroutines and channels since Part II; this part opens up what's actually happening underneath them — the scheduler, the memory model, garbage collection — plus the concurrency patterns, advanced generics, and profiling tools you reach for once a program has to perform under load, not just work correctly. It ends with a TCP chat server and a mini API gateway, both built on the internals covered earlier in the part.
27. The Go memory model and the garbage collector
27.1 Stack vs. heap, and escape analysis
The compiler decides, per variable, whether it can live on the stack (fast, freed automatically when the function returns, no GC involvement) or must "escape" to the heap (because a pointer to it outlives the function, e.g. it's returned, or captured by a goroutine/closure that outlives the call).
go build -gcflags="-m" main.go
# main.go:12:6: moved to heap: x
func onStack() int {
x := 42
return x // value copied out — x itself never escapes
}
func onHeap() *int {
x := 42
return &x // address escapes — x must live on the heap
}
Fewer heap allocations means less GC work — this is the lever behind most Go performance tuning, more so than algorithmic micro-optimization.
27.2 The GC: concurrent, tri-color mark-and-sweep
Go's GC runs concurrently with your program (short "stop-the-world" pauses measured in microseconds, not the multi-millisecond pauses of older collectors), using a tri-color mark-and-sweep algorithm with a write barrier to track pointer mutations during marking. You almost never need to know the algorithm's internals day to day — you need to know its knobs:
GOGC=100 # default: GC triggers when heap doubles since the last collection
GOGC=off # disable GC entirely (only for short-lived batch tools!)
GOMEMLIMIT=512MiB # soft memory cap; GC works harder as usage approaches it
27.3 Reducing GC pressure in practice
- Pre-allocate slices with
make([]T, 0, n)when the size is known (Chapter 6). - Reuse buffers with
sync.Poolfor high-churn, short-lived allocations. - Pass large structs by pointer instead of copying them repeatedly.
- Avoid unnecessary
interface{}/anyboxing of small values in hot loops — boxing anintinto an interface allocates.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func handle(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// ... use buf ...
}
27.4 War story
A JSON API service's p99 latency had a periodic 40ms spike every few
seconds under load — invisible in average latency, brutal for a
low-latency SLA. A CPU profile during the spike showed runtime.gcBgMarkWorker
dominating. The root cause: a hot path allocated a fresh []byte buffer
per request to build a response, and under load the heap grew fast
enough to trigger GC cycles every couple of seconds. Switching that one
buffer to a sync.Pool cut allocations in the hot path by ~90% and the
p99 spikes disappeared. Lesson: GC pauses are a downstream symptom —
the actual bug is almost always "too many allocations," found by
profiling the allocator (pprof's heap profile), not by tuning GOGC.
Applied in the series: Linux guide, Chapter 76: the GC's pacing meets a container memory limit. OOM-killed at 300 MB without GOMEMLIMIT, survived with it, at the cost of about 1,500 GCs per second.