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:

  1. The handler's workers (main.handler.func1, 300 of them, still in slowWork; once it returns they will sit in chan send forever).
  2. The client's unclosed bodies: each http.Get whose body is not closed keeps one connection with a readLoop and writeLoop goroutine on the client and a conn.serve goroutine 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

  1. Every goroutine has an owner and an exit condition. Before writing go, answer: what makes this goroutine return, and who waits for it?
  2. Pass context.Context to anything that blocks, and select on ctx.Done() in every loop and every blocking send/receive that could outlive the caller.
  3. defer cancel() immediately after context.WithTimeout / WithCancel.
  4. Set deadlines on network I/O (conn.SetDeadline, http.Client{Timeout: ...}, server ReadTimeout/WriteTimeout/IdleTimeout).
  5. Close response bodies and drain them for connection reuse.
  6. Use structured concurrency — errgroup or WaitGroup scoped to a function, so goroutines cannot outlive the function that started them.
  7. 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

  1. Why doesn't the garbage collector free a goroutine that is blocked forever on a channel send?
  2. In a goroutine?debug=1 profile, what signal tells you that you are looking at a leak rather than normal load?
  3. Why does not closing resp.Body leak goroutines on both the client and the server?
  4. Why is make(chan T, 1) the right fix for the timeout pattern, and why is a select with default wrong?
  5. What can the experimental goroutineleak profile prove that the ordinary goroutine profile cannot?