Back to Notes

Go Goroutine Leaks

Goroutine Leaks

What: A goroutine blocked forever (channel op, lock, or infinite loop) with no termination path. Each leak pins its stack and everything reachable from it; leaks accumulate in servers until OOM.

Syntax/Signature: Detection: runtime.NumGoroutine(), pprof goroutine profile (/debug/pprof/goroutine?debug=2), goleak.VerifyTestMain(m) (uber-go/goleak) in tests.

// LEAK: caller times out; worker blocks forever on send.
func leaky(ctx context.Context) (int, error) {
	ch := make(chan int) // unbuffered = the bug
	go func() { ch <- slowWork() }()
	select {
	case v := <-ch:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err() // goroutine above is now stuck forever
	}
}
// FIX: make(chan int, 1) — worker's send always succeeds, goroutine exits,
// the orphaned value is GC'd with the channel.

How it works under the hood: The GC cannot collect a runnable-or-parked goroutine: it's rooted by the scheduler, and its stack roots everything it references — including the channel it's parked on (see [[Go Channels]]). A channel is collectable only when unreachable; a goroutine parked on it keeps it reachable. Hence "blocked forever" = permanent memory, not eventual cleanup. There is no way to kill a goroutine from outside — prevention is the only cure.

Interview angle: "Show me the classic timeout leak and fix it" (above — buffered cap 1). "How do you find leaks in prod?" — goroutine profile diff over time; thousands of goroutines with identical stacks parked at the same line is the smoking gun.

Gotchas / common wrong answers:

  • The leak checklist interviewers expect: (1) send with no receiver after caller abandons, (2) receive on a never-closed channel (range included), (3) missing ctx.Done() case in select sends (see [[Go Context & Cancellation]]), (4) forgotten cancel(), (5) time.After in hot loops (pre-1.23), (6) blocked on mu.Lock held by a leaked goroutine — leaks cascade.
  • "GC will clean it up" — it won't, ever.
  • Fixing by adding buffer without reasoning — cap 1 fixes this shape because there's exactly one send; it's not a universal remedy.

Trade-offs / when NOT to use: Rule of thumb worth quoting: never start a goroutine without knowing how it stops. Every go in review should have an obvious exit path (input close, ctx, or bounded work).

Standard Go tooling for this class of bug — pprof (profiling, ships in stdlib) and goleak (leak detection in tests, uber-go/goleak) — not covered as separate KB pages, but worth naming by name in an interview as evidence of real production experience with this failure mode.

Interview Drills

  1. A caller times out after 2 seconds, but the worker goroutine it spawned is still blocked trying to send its result. Fix it. — The channel is unbuffered, so the worker's send blocks forever once the caller has already returned via the timeout path (nobody is left to receive). Fix: make the channel buffered with capacity 1 — the worker's send then always succeeds immediately regardless of whether anyone is still listening, the goroutine exits normally, and the orphaned buffered value is garbage collected along with the channel once both become unreachable.
  2. How would you detect goroutine leaks that have already happened in a running production service, without adding new instrumentation code? — Take a pprof goroutine profile (/debug/pprof/goroutine?debug=2) at two points in time and diff them — a genuine leak shows up as a growing count of goroutines with identical stack traces parked at the same blocking line, distinct from normal request-scoped goroutines that come and go.
  3. Why is "the GC will eventually clean up a blocked goroutine" a wrong mental model? — The garbage collector only reclaims memory that's unreachable; a goroutine parked on a channel operation is rooted by the scheduler (it's still "running," just blocked), and its stack keeps everything it references — including the channel itself — reachable indefinitely. There is no timeout or eventual collection for a goroutine that never returns; it is permanent memory until the process exits.

Cross-links

[[Go Goroutines & Scheduling]] · [[Go Channels]] · [[Go Context & Cancellation]] · [[Go Concurrency Patterns]]