How do I cut GC pressure in Go with sync.Pool and escape analysis under load?
Question
We have a Go data ingest service that takes 50,000 small JSON packets per second, processes them, and writes to a database. Under heavy load, when GC kicks in the CPU maxes out and the "Stop-the-World" pauses spike our API response times. How do I apply `sync.Pool` and escape analysis in this scenario to reduce object allocations in memory?
Answer
Short answer: GC pauses are a symptom; the real enemy is allocation rate. Cut allocations first and GC relaxes on its own.
Your spikes aren’t because GC is “bad” — 50k packets per second means hundreds of thousands of short-lived objects per second, so GC runs constantly and aggressively to collect that garbage, and the Stop-the-World pauses bleed into your response times. The fix isn’t to disable GC, it’s to give it less work.
- Reuse on the hot path with
sync.Pool. Instead ofnew-ing short-lived buffers, structs, and decoders for every packet, take them from a pool and return them. Critical detail: reset the object before youPutit so stale data doesn’t leak. This dramatically lowers allocation pressure on the hottest path. - Cut allocations at the root. Pre-size slices/maps with
make([]T, 0, n)when you know the count; reusebytes.Bufferand the JSON decoder; avoid needless[]byte↔stringcopies. A single copy multiplied 50k times becomes a mountain. - Run escape analysis, keep it on the stack. The
go build -gcflags=-moutput tells you which values “escape” to the heap. Returning a pointer out of a function or boxing a value into an interface causes most escapes; keep those on the stack and GC never sees the object — the cheapest allocation is the one you never make. - Don’t fly blind: measure before/after with
pprof. Thealloc_spaceprofile frompproftells you the line allocating the most;GODEBUG=gctrace=1shows GC frequency and pause duration. Measure under the same load before and after the change — what you “think” you improved is often flat once you actually measure it. - Mind the pooling trap. Pooling objects that hold pointers to other heap data can backfire: the pool keeps the object alive, so the large graph it points to never gets freed and memory swells more than you expect. Keep only flat, self-contained buffers/structs in the pool.
Bottom line: the order is clear — profile → kill the top allocator → pool the survivors → then tune GOGC/GOMEMLIMIT. GOGC and the soft memory limit (GOMEMLIMIT) make GC less frequent but won’t fix a path allocating 50k times a second; they’re the final tuning, not the first move. The real win comes from producing no garbage on the hottest path at all.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.