How do I detect and prevent goroutine leaks in a long-running Go service?
Question
A data ingestion service I wrote in Go slowly climbs to tens of thousands of goroutines over a few days. It spawns work per event with `go func()`, has channels, and makes a few outbound HTTP calls. The problem is I can't figure out which spawn site leaks. How do I detect this leak in production, find the root cause, and prevent it from happening again?
Answer
Short answer: a steadily climbing count means goroutines blocked forever on a channel/lock/IO with no cancellation path.
Short answer
Measure and dump first, then fix the spawn site’s contract. The instinct is to blame the growing count on load, but a leak has a signature that a spike doesn’t: it never comes back down. Separate those two before you touch any code, because they need completely different fixes. I laid out the channel and select contract underneath all of this in the Go concurrency post; a leak is what shows up where that contract isn’t held.
Why
- The root cause is almost always missing cancellation. A goroutine writing to a channel whose reader is gone, a
for range chthat never closes, or an HTTP call with no context/timeout. Every goroutine needs an exit path:ctx.Done(), a closed channel, or bounded work. - A stuck network read holds a goroutine until the process dies. When context isn’t propagated down and network calls carry no timeout, the
selectnever returns; the goroutine parks and stays there. - An unbounded spawn model gives the leak no ceiling. A service that fires an unbounded
go func()per event hits no upper limit once a leak starts — the count just keeps climbing with load.
What to do
-
Confirm the count first. Export
runtime.NumGoroutine()as a metric. If it grows monotonically with load and never recedes even at idle, it’s a leak, not a spike. That distinction is the foundation of the whole diagnosis. -
The pprof goroutine dump is the smoking gun. Wire up
net/http/pprofand grab/debug/pprof/goroutine?debug=2(raw stacks) ordebug=1(aggregated). The stack with thousands of copies, all parked on the same channel recv/send orselect, is exactly the leak site.# Find the parked stack at peak in production: curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | less -
Propagate context everywhere. Pass
context.Contextdown, honor it inselect, and put timeouts on every network call (http.Client.Timeout, a query context for the DB). A goroutine parked on a stuck network read leaks until the process dies.func worker(ctx context.Context, jobs <-chan Job) { for { select { case <-ctx.Done(): // an exit path for every goroutine return case j, ok := <-jobs: if !ok { return } process(ctx, j) } } } -
Test for leaks. Use
go.uber.org/goleakinTestMainso a test fails if goroutines outlive it. That’s the cheapest way to catch new leaks per spawn site, before they reach production. -
Bound your concurrency. Instead of an unbounded
go func()per event, use a worker pool / semaphore. A leak in an unbounded spawn model has no ceiling; a pool caps the damage from the start. It also makes the pprof dump far easier to read, because the parked goroutines cluster at a handful of named workers instead of thousands of anonymous closures.
Bottom line: personally I’d wire NumGoroutine() onto a dashboard and attach the pprof endpoint right away; take a goroutine?debug=2 dump at peak, find the parked stack with thousands of copies, and make that spawn site honor ctx.Done(). Then I’d add goleak so it never regresses. A leak isn’t a “mystery” — the dump points you at the exact line.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.