Frontend Labs
⋯

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.Pool for high-churn, short-lived allocations.
  • Pass large structs by pointer instead of copying them repeatedly.
  • Avoid unnecessary interface{}/any boxing of small values in hot loops — boxing an int into 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.


Generated from real-life-golang-guide.md · 8 Oct 2026 16:36