Go 1.27 makes the goroutineleak pprof profile generally available for finding goroutines permanently blocked on channels and sync primitives. Here's how its GC-based detection works and where it stops.
· 7 min read

Goroutine Leak Profiles in Go 1.27: Finding Permanently Blocked Goroutines


Your service has 4,000 goroutines parked on a channel send. Is that a leak, or is it Tuesday afternoon traffic? A standard goroutine profile cannot tell you. You stare at the stacks, guess, and move on.

Go 1.27 makes a pprof profile type called goroutineleak generally available after its experimental debut in Go 1.26. It reports goroutines that the runtime can determine are permanently blocked on a channel or supported sync primitive. The analysis is deliberately conservative: it avoids false positives by omitting cases it cannot prove. If you already import net/http/pprof, /debug/pprof/goroutineleak is available alongside the other profiles.

The official Go blog post announcing it covers the API and the runtime changes behind it.

Why goroutine profiles weren’t enough

A standard goroutine profile dumps every goroutine and its stack. It cannot distinguish a goroutine that will drain in 200ms from one that will still be there when the process gets OOM-killed next week. The information you need — will anything ever wake this thing — isn’t in the dump.

Two existing tools handle the test-time case well. goleak fails a test if goroutines outlive it. synctest, added to the standard library in Go 1.25, gives you deterministic control over concurrent event ordering in tests. Neither helps you in a running service.

The leak profiler fills that gap, and it pays for the precision with scope. It only classifies goroutines blocked on Go’s first-class concurrency primitives. Goroutines blocked on network reads, file IO, or raw syscalls are never reported as leaked.

A leak you have probably written

Here is the pattern from the Go blog, which the authors say shows up in real production code, including at Uber:

type result struct {
	res workResult
	err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
	// Unbuffered: every sender must rendezvous with a receiver.
	ch := make(chan result)
	for _, w := range ws {
		go func() {
			res, err := processWorkItem(w)
			ch <- result{res, err}
		}()
	}

	var results []workResult
	for range len(ws) {
		r := <-ch
		if r.err != nil {
			// Early return: remaining senders never get a receiver.
			return nil, r.err
		}
		results = append(results, r.res)
	}
	return results, nil
}

One error return abandons the remaining results. Workers that have not already completed a send eventually block on ch <- result{...} forever. Each leaked goroutine retains its stack and anything reachable only through it, adding memory and garbage-collection work as leaks accumulate. With GOMEMLIMIT in use, that extra live memory can also drive more frequent GC cycles and higher CPU use.

The fix is one argument:

ch := make(chan result, len(ws))

Senders never block. Abandoned goroutines finish their send and exit. More variants of this in Common Goroutine Leaks in Go and How to Avoid Them.

Collecting the profile

Wire up net/http/pprof the usual way:

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof" // registers handlers on http.DefaultServeMux
)

func main() {
	go func() {
		// Bind to localhost so the endpoints are not publicly exposed.
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	// ... your program ...
	select {}
}

Then grab it and open it in pprof:

$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
(pprof) list processWorkItems

The list output annotates the exact line where the leaked goroutines are parked. For the worker example above, every leaked sample points at the ch <- result{res, err} line. The profile has already distinguished these from temporarily blocked goroutines; comparing profiles over time tells you whether the leak is accumulating.

If you’d rather not run an HTTP server, the profile is reachable programmatically through runtime/pprof:

package main

import (
	"os"
	"runtime/pprof"
)

func dumpLeaks(path string) error {
	p := pprof.Lookup("goroutineleak")
	if p == nil {
		// Profile type not available on this Go version.
		return nil
	}

	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()

	// debug=0 writes the binary protobuf format that go tool pprof reads.
	return p.WriteTo(f, 0)
}

Keep the pprof.Lookup nil check if the code may be built with a Go version where the profile is unavailable, including Go 1.26 without its experiment enabled.

How the runtime decides a goroutine is leaked

The detection algorithm is the interesting part, and it reuses machinery you already know: the garbage collector.

Start with a definition of liveness for goroutines. A goroutine is live if it is not blocked, or if at least one concurrency primitive blocking it is referenced by another live goroutine.

The second half is the key. If some live goroutine still holds a reference to the channel or mutex you’re blocked on, it might eventually send, close, or unlock. If no live goroutine can reach that primitive, none can use it to wake you. This conservative reachability rule is what keeps false positives low.

Reachability from a set of roots is exactly what a tri-color mark-and-sweep collector computes. So the leak detector modifies the GC cycle instead of building a new traversal:

  1. Normally every goroutine is a mark root. The leak-detecting cycle uses only unblocked goroutines (and globals) as roots.
  2. Marking proceeds as usual, so only memory reachable from live goroutines gets marked.
  3. At the end of a marking round, the runtime inspects the blocked goroutines that weren’t roots. Any goroutine blocked on a primitive that got marked is promoted to a root, and marking resumes. This is the inductive step.
  4. When no new live goroutines appear, everything still unmarked as a root is flagged leaked. Marking then runs once more with leaked goroutines added as roots, so the GC ends up marking the same memory a normal cycle would.

The profiler then behaves like a regular goroutine profile, filtered to goroutines with the leaked status.

What it will and won’t catch

It catches channel sends and receives (including on nil channels), blocking select statements with no default case (including select {}), and sync.Mutex, sync.RWMutex, sync.WaitGroup, and sync.Cond.

There are three important limits.

Memory overreach. If a channel is reachable from a global variable or from any runnable goroutine, goroutines blocked on it are not reported — even if that channel will never be touched again. Package-level channels and long-lived registries can therefore hide leaks involving those primitives. Keeping primitive lifecycles tightly scoped makes both the code and the profiler’s result easier to reason about.

Non-standard blocking. Network reads, file IO, syscalls, and hand-rolled spin locks are invisible unless they bottom out in the primitives above.

Non-determinism. A leak has to actually happen before it shows up. Flaky, timing-dependent leaks still need reproduction.

That last point is why the Go team recommends layering: goleak and synctest in tests, leak profiles in staging and production.

Cost and how often to sample

Memory overhead is small, just bookkeeping. The CPU cost lands in the marking phase.

Consider a daisy chain: runnable goroutine G₀ references primitive P₁ which blocks G₁, which references P₂ which blocks G₂, and so on. Proving G₂ live requires first proving G₁ live. Marking serializes along the chain. That’s intrinsic to the algorithm and isn’t going away. On top of that, the current implementation re-inspects all blocked goroutines at the end of each marking round, giving a worst case of O(n²) steps per cycle for n goroutines. The re-inspection is an implementation detail someone can optimize; the serialization is the algorithm.

Sampling frequency is the main way to manage that cost. Once the profiler can observe a goroutine as leaked, it remains observable for the rest of that process execution. Continuous collection is therefore usually unnecessary; periodic collection — the Go team gives every four hours as an example — retains most of the diagnostic value at much lower cost. The GC still runs concurrently with your code unless you’ve configured it otherwise.

Patterns worth reviewing in your own code

The Go blog catalogs leak patterns found in real codebases. A few worth grepping for:

Double send — a goroutine sends on an error path, forgets to return, then sends again on the happy path. The receiver only reads once.

Timeout races — a worker sends on an unbuffered channel while the parent does select on that channel and ctx.Done(). If the context fires first, the sender leaks. Buffer the channel with size 1.

range over a channel that never closes — worker goroutines using for item := range ch block forever if the producer never calls close(ch). This is why bounded worker pools need an explicit close after the last send.

Missing unlock before break — a CockroachDB bug took a mutex in a loop and broke out without unlocking. Every later caller blocks on Lock().

WaitGroup.Wait inside the loop — a Moby bug called group.Wait() in the loop body instead of after it, deadlocking whenever there was more than one item.

Method contract violations — a type with Start() and Stop() where Start spawns a goroutine parked on a select over a done channel. Callers who never call Stop leak that goroutine. When the type is exported only as an interface, callers may not even know the contract exists. Here the profile earns its keep: it points straight at the select inside Start, and from there the caller is usually one grep away.

The detection work came out of a collaboration between Aarhus University, Washington University in St. Louis, and Uber, published as “Dynamic Partial Deadlock Detection and Recovery via Garbage Collection” at ASPLOS 2025. The title mentions recovery, which the Go implementation does not attempt: the profile reports the blocked goroutines and leaves remediation to the application. The research therefore goes beyond the diagnostic feature that shipped in Go.