Goroutine Leaks¶
What a leak is in Go¶
A goroutine leak is a goroutine that will never finish: it is blocked forever (on a channel, a lock, a network read with no deadline) or loops forever with no exit condition. The runtime never kills or collects a blocked goroutine — from its point of view the goroutine might still be woken. So a leak keeps alive:
- its stack (at least 2 KB, often 4–8 KB after growth);
- every heap object reachable from that stack: buffers, request structs, captured variables;
- any file descriptors and connections it holds.
One leaked goroutine per request is invisible in a load test of five minutes and fatal after five days in production.
The common causes¶
| Cause | Shape |
|---|---|
| Send with no receiver | Worker sends its result on an unbuffered channel after the caller gave up (timeout, early return on error) |
| Receive with no sender | Consumer waits on a channel that no one will ever send on or close |
| Missing cancellation | for { select { case <-tick: ... } } with no case <-ctx.Done() |
| Network I/O without deadlines | conn.Read on a peer that silently disappeared waits for TCP keepalive (hours) |
| Unclosed HTTP response bodies | http.Get without resp.Body.Close() keeps the connection and its two transport goroutines alive |
| Lock never released | A path that returns early without Unlock (use defer mu.Unlock()) |
time.Ticker never stopped (before Go 1.23) |
The runtime kept the ticker alive; since 1.23 unreferenced timers and tickers are collected |
A real leak, reproduced¶
A handler runs slow work in a goroutine and enforces a timeout:
func handler(w http.ResponseWriter, r *http.Request) {
res := make(chan int) // unbuffered
go func() { res <- slowWork() }()
select {
case v := <-res:
fmt.Fprintln(w, v)
case <-time.After(10 * time.Millisecond):
http.Error(w, "timeout", http.StatusGatewayTimeout)
}
}
When the timeout fires, the handler returns and nobody will ever read
res. When slowWork finishes, its goroutine blocks on res <- ...
forever. A client loop called this endpoint 300 times with
http.Get — and never closed the response bodies. The goroutine
profile (/debug/pprof/goroutine?debug=1) six seconds later:
goroutine profile: total 1203
300 @ ...
# internal/poll.runtime_pollWait netpoll.go:351
# ...
# net/http.(*conn).serve server.go:2109 ← server side of 300 open connections
300 @ ...
# net/http.(*persistConn).readLoop transport.go:2450 ← client side: body never closed
300 @ ...
# net/http.(*persistConn).writeLoop transport.go:2652
300 @ ...
# time.Sleep time.go:363
# main.slowWork main.go:12
# main.handler.func1 main.go:16 ← our leaked workers
debug=1 groups identical stacks with a count, which is exactly what
makes leaks obvious: hundreds of goroutines with the same stack is
almost always a leak. This one profile shows two independent leaks:
- The handler's workers (
main.handler.func1, 300 of them, still inslowWork; once it returns they will sit inchan sendforever). - The client's unclosed bodies: each
http.Getwhose body is not closed keeps one connection with areadLoopandwriteLoopgoroutine on the client and aconn.servegoroutine on the server. The transport cannot reuse a connection whose body was not consumed, so every request opened a new one — 300 connections for 300 requests.
Fixes:
res := make(chan int, 1) // 1. the send can always complete; the
// goroutine exits even if no one reads
go func() { res <- slowWork(ctx) }() // and pass ctx so the work itself stops
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // 2. always, even if you don't read it
io.Copy(io.Discard, resp.Body) // drain it if you want the connection reused
A buffered channel of size 1 is the idiomatic fix for "one result that
may not be wanted". Passing ctx (and having slowWork honour it)
fixes the deeper problem: the abandoned work keeps consuming CPU,
database connections, and memory until it finishes.
Detecting leaks¶
In production — watch the count. Export runtime.NumGoroutine()
(Prometheus' Go collector does this as go_goroutines). A healthy
service's goroutine count tracks its load; a leaking one rises
monotonically, in steps that match traffic, and never comes back down
at night.
Find the stack — goroutine profile.
curl -s localhost:6060/debug/pprof/goroutine?debug=1 | head -50 # grouped by stack
curl -s localhost:6060/debug/pprof/goroutine?debug=2 > dump.txt # every goroutine, with wait reason and minutes blocked
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
debug=2 prints each goroutine like a panic trace, with how long it
has been blocked: goroutine 4242 [chan send, 87 minutes]:. Anything
blocked for many minutes on a channel operation deserves a look.
Experimental: the leak profile (Go 1.26). Built with
GOEXPERIMENT=goroutineleakprofile, the runtime adds a goroutineleak
profile that uses the garbage collector to find goroutines blocked on
a channel, mutex, or sync.Cond that no runnable goroutine can
reach any more — which proves they can never wake up. Running the
timeout example 100 times:
goroutineleak profile: total 100
100 @ 0x1049e55cc 0x10497e2b8 0x10497dec8 0x104a354f0 0x1049ebe04
# 0x104a354ef main.withTimeout.func1+0x3f main.go:14
Unlike the goroutine profile, it has no false positives from legitimately idle goroutines (a worker waiting on a queue that is still referenced is not reported). It is an experiment in 1.26: use it in tests and staging, and expect the API to change.
In tests — goleak. go.uber.org/goleak fails a test (or a whole
package via TestMain) if goroutines started during it are still
running at the end:
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
This catches leaks at code review time, where they are cheapest. For
concurrency code with timers, testing/synctest (Go 1.25) runs the
test in a bubble with a fake clock, so a "wait 30 s then time out" path
runs instantly and deterministically — see
Concurrency Bugs.
Rules that prevent leaks¶
- Every goroutine has an owner and an exit condition. Before
writing
go, answer: what makes this goroutine return, and who waits for it? - Pass
context.Contextto anything that blocks, andselectonctx.Done()in every loop and every blocking send/receive that could outlive the caller. defer cancel()immediately aftercontext.WithTimeout/WithCancel.- Set deadlines on network I/O (
conn.SetDeadline,http.Client{Timeout: ...}, serverReadTimeout/WriteTimeout/IdleTimeout). - Close response bodies and drain them for connection reuse.
- Use structured concurrency —
errgrouporWaitGroupscoped to a function, so goroutines cannot outlive the function that started them. - Bound concurrency so a leak (or a slowdown) cannot grow without limit: a worker pool leaks at most N goroutines.
Common pitfall¶
"Fixing" a leak by adding a default: case to the send:
go func() {
v := slowWork()
select {
case res <- v:
default: // nobody listening, drop it
}
}()
This looks like it solves the problem but introduces a race: if the
caller is still waiting but has not yet reached its select at the
moment the worker finishes, the non-blocking send fails and the
result is dropped even though someone wanted it — the caller then
waits until its timeout despite the work having succeeded. The buffered
channel of size 1 has neither problem: the send always succeeds
immediately, and the value is there whenever the caller looks. Use a
default case only when dropping is genuinely the intended semantics
(lossy metrics, best-effort notifications).
Review questions¶
- Why doesn't the garbage collector free a goroutine that is blocked forever on a channel send?
- In a
goroutine?debug=1profile, what signal tells you that you are looking at a leak rather than normal load? - Why does not closing
resp.Bodyleak goroutines on both the client and the server? - Why is
make(chan T, 1)the right fix for the timeout pattern, and why is aselectwithdefaultwrong? - What can the experimental
goroutineleakprofile prove that the ordinary goroutine profile cannot?