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. 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.
- 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. - 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. - 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. - 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.
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)
}
}
}
# Find the parked stack at peak in production:
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | less
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.