Detect unreachable goroutine leaks with Go 1.27

John Burns

A goroutine count can show that a service is growing, but it does not explain whether the blocked work is expected. A busy HTTP server may have many goroutines waiting on sockets, timers, or a work queue and still be healthy. A goroutine that is waiting on a channel which no remaining code can ever send to is different: it will retain its stack and anything it references until the process exits.

Go 1.27 makes one useful subset of those failures easier to separate from ordinary blocking. The runtime now exposes a goroutineleak profile. It reports goroutines blocked on channels and synchronization primitives when the primitive can no longer be reached by any runnable goroutine or by a goroutine that could become runnable. That is a stronger signal than an unusually large goroutine profile, but it is not a general answer for every stalled request or deadlock.

This guide creates a small local failure, collects the profile without opening a network listener, and changes the handoff so the blocked sender has a receiver. The validation run used Go 1.27.1 on Linux AMD64. Five senders blocked on unreachable channels appeared as five entries at the same function in the goroutineleak profile. After the handoff was corrected, the profile contained no entries for that function. The test is deliberately disposable: it does not contact an application, an internal pprof endpoint, or a production service.

Know what the profile can prove

The leak profiler is based on reachability. When a goroutine is blocked on a concurrency primitive and that primitive is unreachable from runnable work or from work that could be unblocked, the runtime can conclude that the wait cannot finish. Channel sends, channel receives, mutexes, condition variables, and similar synchronization boundaries are in scope.

That definition deliberately leaves out many problems that an operator may still call a leak. A goroutine blocked on a reachable global channel can be waiting forever because a future sender is logically impossible, but the runtime cannot prove that from reachability alone. A goroutine blocked in network I/O, a request that is merely slow, and a worker held behind a live lock need different evidence. Keep the existing goroutine profile, metrics, request timeouts, and application-level cancellation checks; goroutineleak adds a precise diagnostic signal rather than replacing them.

The profile is available through runtime/pprof and, when an application already installs the standard net/http/pprof handlers, at /debug/pprof/goroutineleak. Do not expose that HTTP endpoint on an untrusted interface merely to collect this profile. Pprof data can reveal package paths, allocation behavior, and implementation details. Bind an existing diagnostic listener to loopback or an authenticated administration boundary, and collect it only through the organization-approved support procedure.

For a local regression test, using runtime/pprof directly avoids opening a port at all. The next example is a small boundary test that can run in a temporary directory or a package test.

Test an unreachable blocked sender

The following function creates an unbuffered channel, starts a sender, and returns without preserving any receiver or channel reference. The sender owns the last reference to the channel and blocks at the send. No runnable goroutine can obtain the channel and receive from it.

func leak() {
	ch := make(chan struct{})
	go func() { ch <- struct{}{} }()
}

That pattern is intentionally small. In a real service the equivalent error is often an early return from an aggregation loop, an abandoned worker result channel, or a shutdown path that no longer owns the signal it is waiting to deliver. The useful review question is not whether a channel is unbuffered by itself. It is whether every producer still has a consumer, cancellation path, or bounded buffer after an error path changes control flow.

Create five instances and force garbage collection before collecting the profile. The garbage-collection calls make the short standalone test decisive; they are not a recommendation to call runtime.GC repeatedly in a service.

package main

import (
	"bytes"
	"fmt"
	"runtime"
	"runtime/pprof"
	"strings"
)

func leak() {
	ch := make(chan struct{})
	go func() { ch <- struct{}{} }()
}

func main() {
	for range 5 {
		leak()
	}
	for range 3 {
		runtime.GC()
	}

	var report bytes.Buffer
	if err := pprof.Lookup("goroutineleak").WriteTo(&report, 1); err != nil {
		panic(err)
	}
	text := report.String()
	fmt.Print(text)
	if !strings.Contains(text, "main.leak.func1") {
		panic("goroutineleak profile did not identify the blocked sender")
	}
}

Run it with Go 1.27 or a later compatible release:

go version
go run leakcheck.go

The validation run returned zero and produced this relevant output:

go version go1.27.1 linux/amd64
goroutineleak profile: total 5
5 @ ...
#	...	main.leak.func1+0x1d	...:13

The exact program counter and source location will differ. The decisive parts are the profile type, the count of five, and the blocked sender function. A zero exit status from the example means the profile included that function; it does not mean a production service is leak-free.

Correct the ownership boundary

A buffer is sometimes the right fix for a fan-out operation with a known upper bound. It is not a universal cure. A buffered channel can postpone a leak when the number of sends can exceed the buffer capacity, and it does not communicate cancellation to a worker that is still doing expensive work.

For this single handoff, give the sender a receiver and wait for it to finish. The channel is now reachable from the caller until the transfer is complete:

func deliver() {
	ch := make(chan struct{})
	go func() { ch <- struct{}{} }()
	<-ch
}

For a worker group, prefer a design that makes completion and cancellation explicit. A sync.WaitGroup, a context passed to workers, and a result channel consumed until workers exit make the ownership visible. If an aggregator can return on the first error, arrange for remaining workers to observe cancellation or continue draining the result channel. Do not leave them attempting a send that no code will receive.

The correct choice depends on the service contract. A bounded buffer is appropriate when each worker must publish one result and the coordinator owns a known maximum. Context cancellation is appropriate when work should stop after the first error. A wait group is appropriate when the coordinator must not return until all workers have released resources. Write the expected behavior down before changing the channel capacity; otherwise a capacity increase can hide the control-flow error in a light test.

Make the profile a focused diagnostic check

After correcting the handoff, run the profile at the same point in the test and assert that it no longer names the old blocked function. Do not assert that the entire profile must always be empty in a large process: framework and test goroutines can be legitimate, and a strict global-empty assertion makes unrelated runtime changes look like regressions.

A practical package test can create the specific error path, force collection only in the test, write the profile to an in-memory buffer, and fail if a known function remains. For a production diagnostic, collect two profiles under comparable load and inspect whether the same blocking location grows. Treat a newly reported location as a code-review and incident signal: identify the primitive, find every owner, and determine which path is supposed to unblock it.

The important limitation remains. No entry does not prove that all goroutines make progress. It proves only that the runtime did not find an unreachable synchronization wait. Pair this profile with request deadlines, bounded queues, cancellation tests, and normal goroutine growth monitoring.

Go 1.27 gives a direct way to find a category of goroutine leak that previously required interpreting a large stack dump. Keep the collection boundary private, reproduce a specific blocked handoff in a small test, and use the reported function to repair the ownership path rather than masking it with an arbitrary channel buffer.

Sources