Skip to content

go.goroutine_leak

Stability High Causes Production Outages

Detects goroutines that may never terminate (blocking on channels without cancellation).

Leaked goroutines accumulate over time:

  • Memory growth - Each goroutine uses ~2KB stack minimum
  • Resource exhaustion - Eventually crashes the application
  • Hard to detect - No obvious symptoms until it’s too late
  • Slow degradation - Performance degrades gradually

A goroutine leak is like a memory leak, but worse because goroutines also hold references to other resources.

// ❌ Before
func startWorker() {
go func() {
for {
msg := <-messages // Blocks forever if channel closes
process(msg)
}
}()
}

If messages is never closed and nothing sends, this goroutine lives forever.

// ✅ After
func startWorker(ctx context.Context) {
go func() {
for {
select {
case msg := <-messages:
process(msg)
case <-ctx.Done():
return
}
}
}()
}

The goroutine exits when context is cancelled.

  • Goroutines blocking on channel receive without select
  • Goroutines without cancellation mechanism
  • Channel sends without corresponding receives
  • Forever loops without exit conditions

Unfault can add context-based cancellation to goroutine patterns when the blocking pattern is clearly identified.

// LEAK: Unbuffered channel with no receiver
ch := make(chan int)
go func() {
ch <- 1 // Blocks forever
}()
// LEAK: Range over channel never closed
go func() {
for v := range ch { // Blocks forever at end
process(v)
}
}()
// LEAK: Select without done case
go func() {
select {
case v := <-ch:
process(v)
} // Only processes once, then exits - but what if ch never sends?
}()
// Timeout
select {
case v := <-ch:
process(v)
case <-time.After(30 * time.Second):
return // Give up after timeout
}
// Context cancellation
select {
case v := <-ch:
process(v)
case <-ctx.Done():
return // Cancelled by parent
}
// Buffered channels for fire-and-forget
ch := make(chan int, 1)
ch <- 1 // Doesn't block