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.