A goroutine can be perfectly idle and perfectly healthy. A leak is different: the goroutine is blocked on a channel or synchronization primitive that can never become reachable from code capable of waking it. Go 1.27 can identify a broad class of these failures through the generally available goroutineleak profile.
Every interactive snippet runs on Go 1.27 via Codapi. Each example is a separate process, so intentionally leaked goroutines disappear when that run finishes.
A leak you can measure
Start with the profile itself
runtime/pprof.Lookup exposes the profile in-process. Taking a snapshot performs the reachability analysis; Count then reports how many leaked stacks it found:
package main
import (
"fmt"
"io"
"runtime/pprof"
)
func leakCount() int {
profile := pprof.Lookup("goroutineleak")
if err := profile.WriteTo(io.Discard, 0); err != nil {
panic(err)
}
return profile.Count()
}
func main() {
fmt.Println("leaked goroutines:", leakCount())
}An ordinary program with no abandoned synchronization should report zero. Now create a sender whose channel is immediately lost:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
func leakCount() int {
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
return profile.Count()
}
func main() {
runtime.GOMAXPROCS(1)
go func() {
make(chan string) <- "nobody can receive this"
}()
for range 10 {
runtime.Gosched()
}
fmt.Println("leaked goroutines:", leakCount())
}The channel is reachable only from the blocked goroutine. No runnable goroutine can receive from it, so the sender cannot make progress.
GOMAXPROCS(1) and the explicit yields make this tiny demonstration deterministic by letting the child reach its blocked state before the snapshot. Production programs do not need either setting to use the profile.
How detection works
Reachability is the key
The runtime already knows which goroutines are blocked and which synchronization objects they are waiting on. During garbage collection, it asks whether a runnable goroutine, or a goroutine that one could unblock, can still reach each object.
runnable goroutine
|
v
reachable channel or lock ---> blocked goroutine can still wake
unreachable channel or lock ---> blocked goroutine is leakedThe profile detects impossibility, not elapsed time. A goroutine waiting for an hour on a reachable channel is not a leak. A goroutine blocked for one millisecond on a discarded channel can be one.
Nil channels and empty selects are provably stuck
A receive from a nil channel has no possible sender:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
var updates chan int
<-updates
}()
for range 10 {
runtime.Gosched()
}
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("nil-channel leaks:", profile.Count())
}An empty select has the same permanent outcome:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
func main() {
runtime.GOMAXPROCS(1)
go func() {
select {}
}()
for range 10 {
runtime.Gosched()
}
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("empty-select leaks:", profile.Count())
}These examples are obvious in isolation. Real leaks usually hide inside ownership mistakes, early returns, and incomplete shutdown paths.
Worker lifecycle leaks
A channel range needs an ending
This worker waits for more tasks after its owner returns. Because the owner never closes the channel and no other runnable code retains it, the worker is abandoned:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
func startAndForget() {
tasks := make(chan string)
go func() {
for range tasks {
}
}()
tasks <- "index document"
}
func main() {
runtime.GOMAXPROCS(1)
startAndForget()
for range 10 {
runtime.Gosched()
}
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("leaked workers:", profile.Count())
}Make shutdown part of the API contract. Closing the task channel lets the range finish, and a WaitGroup confirms the worker has exited:
package main
import (
"fmt"
"io"
"runtime/pprof"
"sync"
)
func runWorker() {
tasks := make(chan string)
var workers sync.WaitGroup
workers.Go(func() {
for range tasks {
}
})
tasks <- "index document"
close(tasks)
workers.Wait()
}
func main() {
runWorker()
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("leaked workers:", profile.Count())
}The useful design lesson is larger than close: whichever component starts a background goroutine should expose and honor a termination protocol.
Fan-out leaks
Returning the first result can strand senders
A common race pattern launches several attempts and accepts the first result. With an unbuffered channel, every losing sender blocks after the receiver returns:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
func acceptFirst(attempts int) {
replies := make(chan string)
for range attempts {
go func() { replies <- "response" }()
}
fmt.Println("winner:", <-replies)
}
func main() {
runtime.GOMAXPROCS(1)
go acceptFirst(3)
for range 10 {
runtime.Gosched()
}
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("stranded senders:", profile.Count())
}Buffer enough space for every sender so completion does not depend on continued receiving:
package main
import (
"fmt"
"io"
"runtime/pprof"
)
func acceptFirst(attempts int) {
replies := make(chan string, attempts)
for range attempts {
go func() { replies <- "response" }()
}
fmt.Println("winner:", <-replies)
}
func main() {
acceptFirst(3)
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("stranded senders:", profile.Count())
}Cancellation is another valid design when losing operations can stop before sending. The invariant is that every launched path must have a way to finish.
Locks can leak too
Self-deadlock on a mutex
The detector is not limited to channels. A goroutine that locks the same non-reentrant mutex twice can never acquire it again when the mutex itself is unreachable elsewhere:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
"sync"
)
func main() {
runtime.GOMAXPROCS(1)
for range 8 {
go func() {
var lock sync.Mutex
lock.Lock()
lock.Lock()
}()
}
for range 20 {
runtime.Gosched()
}
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
fmt.Println("mutex leak detected:", profile.Count() > 0)
}A missing Unlock, an impossible WaitGroup.Wait, and an un-signaled sync.Cond can produce similarly detectable states. The profile reports blocked stacks; it does not explain the business invariant that was violated.
Read the stacks
Text output points to the blocking site
Pass debug level 2 to print leaked stacks in the same style as a fatal runtime dump:
package main
import (
"os"
"runtime"
"runtime/pprof"
)
func abandonedDelivery() {
make(chan string) <- "parcel"
}
func main() {
runtime.GOMAXPROCS(1)
go abandonedDelivery()
for range 10 {
runtime.Gosched()
}
_ = pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 2)
}Look for main.abandonedDelivery in the output. That is where the goroutine became blocked, which is usually the best starting point for following ownership backward.
For machine analysis, debug level 0 writes the gzip-compressed pprof protobuf instead.
Production access over HTTP
net/http/pprof exposes a dedicated endpoint
Register the standard pprof handlers in a service:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
log.Fatal(http.ListenAndServe("localhost:6060", nil))
}Then request readable stacks or open the binary profile:
# Human-readable leaked stacks.
curl 'http://localhost:6060/debug/pprof/goroutineleak?debug=2'
# Interactive pprof session.
go tool pprof http://localhost:6060/debug/pprof/goroutineleak
# Save evidence before restarting an unhealthy process.
curl -o goroutineleak.pb.gz http://localhost:6060/debug/pprof/goroutineleak
go tool pprof goroutineleak.pb.gzBind diagnostic servers to localhost or a protected operations network. Stack data can reveal function names, topology, request labels, and other implementation details.
Know the blind spots
Reachable does not mean recoverable in practice
The detector is intentionally conservative. If a blocked primitive remains reachable from a global variable, the runtime cannot prove that no future code will use it:
package main
import (
"fmt"
"io"
"runtime"
"runtime/pprof"
)
var rescue = make(chan struct{})
func main() {
runtime.GOMAXPROCS(1)
go func() { <-rescue }()
for range 10 {
runtime.Gosched()
}
all := pprof.Lookup("goroutine").Count()
leaks := pprof.Lookup("goroutineleak")
_ = leaks.WriteTo(io.Discard, 0)
fmt.Println("all goroutines:", all)
fmt.Println("provable leaks:", leaks.Count())
}The goroutine is visible in the ordinary goroutine profile but not provably leaked. Locals on runnable goroutine stacks can create the same false negative.
Other limitations follow from the same rule:
- The runtime cannot infer application-level promises, deadlines, or forgotten callbacks.
- Network and file descriptors involve actors outside the Go heap.
- A reachable cancellation function means the runtime must assume cancellation may still happen.
- The profile finds permanently blocked goroutines, not CPU loops or repeatedly waking goroutines.
Use it alongside metrics, the ordinary goroutine profile, block profiles, traces, and application-specific lifecycle tests.
Turn the profile into a test
Compare snapshots around a lifecycle
For focused integration tests, snapshot the leak count before and after exercising a component. This example verifies that a worker honors shutdown:
package main
import (
"fmt"
"io"
"runtime/pprof"
"sync"
"testing"
)
func leakCount() int {
profile := pprof.Lookup("goroutineleak")
_ = profile.WriteTo(io.Discard, 0)
return profile.Count()
}
func TestWorkerLifecycle(t *testing.T) {
before := leakCount()
tasks := make(chan int)
var workers sync.WaitGroup
workers.Go(func() {
for range tasks {
}
})
tasks <- 27
close(tasks)
workers.Wait()
after := leakCount()
if after != before {
t.Fatalf("goroutine leaks changed from %d to %d", before, after)
}
fmt.Println("no new leaks")
}
func main() {
testing.Main(
func(_, _ string) (bool, error) { return true, nil },
[]testing.InternalTest{{Name: "TestWorkerLifecycle", F: TestWorkerLifecycle}},
nil, nil,
)
}Keep such assertions scoped. A process-wide count in a large parallel test suite can include unrelated goroutines and make failures hard to attribute.
Quick reference
| Task | API or command |
|---|---|
| Find the profile | pprof.Lookup("goroutineleak") |
| Take a binary snapshot | profile.WriteTo(writer, 0) |
| Print readable stacks | profile.WriteTo(writer, 2) |
| Count the last snapshot | profile.Count() |
| Inspect every goroutine | pprof.Lookup("goroutine") |
| Enable HTTP profiling | Import net/http/pprof |
| Fetch leaked stacks | /debug/pprof/goroutineleak?debug=2 |
| Analyze interactively | go tool pprof <URL-or-file> |
| Detection basis | GC reachability from goroutines that can make progress |
| Common detections | Abandoned channel ops, impossible waits, deadlocked mutexes and conditions |
| Common blind spots | Globals, live stack references, external I/O, application-level promises |
| Release history | Experimental in Go 1.26; generally available in Go 1.27 |
For complete details, see the runtime/pprof documentation, the net/http/pprof documentation, and the Go 1.27 release notes.
Comments