2.9 KiB
Concurrency Debugging
Goroutine Leaks
Symptoms: Memory slowly increasing, goroutine count growing, no obvious CPU spike.
Diagnosis:
Use pprof goroutine profile (see pprof.md) with ?debug=2 for human-readable output, then look for goroutines stuck in chan receive.
For Go 1.26 diagnostics, there is also an experimental goroutine leak profile. It is useful for production-oriented leak investigation, but is gated by GOEXPERIMENT=goroutineleakprofile; do not rely on it as default stable behavior.
curl http://localhost:6060/debug/pprof/goroutineleak?debug=2
go tool pprof http://localhost:6060/debug/pprof/goroutineleak
Keep existing tools: go.uber.org/goleak in tests, runtime.NumGoroutine() for coarse monitoring, /debug/pprof/goroutine?debug=2 for stack dumps, and go test -race ./... for race checks.
// Programmatic monitoring — log goroutine count to detect leaks
go func() {
for {
log.Printf("goroutines: %d", runtime.NumGoroutine())
time.Sleep(3 * time.Second)
}
}()
// In tests, use goleak to detect goroutine leaks
// import "go.uber.org/goleak"
// func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
Common causes:
// 1. Unclosed channel — goroutine blocks forever
// BAD
for {
job := <-jobs
process(job)
}
// GOOD
for {
select {
case job, ok := <-jobs:
if !ok { return }
process(job)
case <-ctx.Done():
return
}
}
// 2. Forgotten response body close — leaks HTTP connection
// Always defer resp.Body.Close() after HTTP calls.
// See production-debug.md for the correct pattern.
// 3. time.After in hot loop — allocates a new timer each iteration
// BAD
for {
select {
case <-time.After(time.Second):
do()
}
}
// GOOD — reuse a ticker for repeated intervals
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
do()
case <-ctx.Done():
return
}
}
Race Conditions
Symptoms: Intermittent failures, "sometimes works sometimes doesn't", different results on different machines.
Diagnosis: Race conditions MUST be tested with the -race flag:
go test -race ./...
go run -race main.go
# Race detector slows code ~10x but finds data races reliably
Common patterns:
- Shared map without mutex
- Shared variable without atomic
- Publishing reference before initialization
- go func() accessing outer variables without synchronization
Deadlocks
Symptoms: Program hangs, goroutines stuck in "chan receive" or "mutex lock".
Diagnosis:
curl http://localhost:6060/debug/pprof/goroutine?debug=2
# Or programmatic
runtime.Stack(buf, true)
Common patterns:
- Circular wait — A waits for B, B waits for A
- Forgotten channel send — sender goroutine exited
- Wrong lock order — always acquire locks in the same order