Lesson 23 / 25
Goroutine Leaks and Deadlocks
Goroutines that never finish, and how to find them.
Blocked forever
A goroutine leak is a goroutine blocked forever, typically on a send nobody receives, a receive from a channel nobody closes, or a wait with no cancellation path. Each leak holds its stack and everything it references, so a service that leaks a few per request slowly runs out of memory. A deadlock is a cycle of goroutines waiting for each other (for example, two mutexes taken in opposite orders). The runtime only reports all goroutines are asleep - deadlock! when every goroutine is blocked; a partial deadlock in a server goes silent. Tools: watch runtime.NumGoroutine() as a metric; import net/http/pprof and read /debug/pprof/goroutine?debug=2 to see every goroutine's stack grouped by where it is blocked; in tests, use go.uber.org/goleak to fail if goroutines are still running at the end. Prevention: give every goroutine an exit path, select on ctx.Done() at every blocking point, and acquire locks in a consistent order.
A classic leak, its fix, and a leak check in tests
Returning early leaves the sender stuck unless the channel has room.
package fetch
import (
"context"
"testing"
"time"
"go.uber.org/goleak"
)
// LEAKS: on timeout nobody receives, so the goroutine blocks on send forever.
func leaky(ctx context.Context, work func() string) (string, error) {
ch := make(chan string)
go func() { ch <- work() }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// FIXED: a buffer of 1 lets the send complete even if nobody receives.
func fixed(ctx context.Context, work func() string) (string, error) {
ch := make(chan string, 1)
go func() { ch <- work() }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func TestFixedDoesNotLeak(t *testing.T) {
defer goleak.VerifyNone(t) // fails if unexpected goroutines remain
release := make(chan struct{})
work := func() string { <-release; return "data" }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if _, err := fixed(ctx, work); err == nil {
t.Fatal("expected a timeout")
}
close(release) // work finishes; its send succeeds into the buffer
// With leaky instead of fixed, the worker would stay blocked and
// goleak.VerifyNone would report it.
}
// To inspect a running server, import _ "net/http/pprof" and fetch:
// curl 'http://localhost:6060/debug/pprof/goroutine?debug=2'
Graph the goroutine count
Export runtime.NumGoroutine() (most metrics libraries do it for you). A count that rises with traffic and never falls back is the clearest early sign of a leak.
Quick check: When does the Go runtime itself report "all goroutines are asleep - deadlock!"?
- Only when every goroutine in the program is blocked
- Whenever any two goroutines wait on each other
- Whenever a goroutine blocks for more than a second
- Whenever a channel is left open at exit
Answer
Only when every goroutine in the program is blocked — Partial deadlocks and leaks go unreported, which is why profiles and leak tests matter.