The Goroutines That Refuse to Die — and How Go 1.26 Finally Made Them Visible

8 min read Original article ↗

The experimental goroutineleak profile turns the one bug class Go never showed you into a stack trace you can read.

Cheikh seck

Leonardo Da Vinci called it saper vedere — knowing how to see. Most of us run Go for years seeing only what the runtime lets us see. A goroutine leaks, and it doesn’t crash. It doesn’t panic. It just sits there, forever blocked on a channel nobody will ever close, quietly holding memory until the box groans. For a decade, finding these meant staring at pprof's goroutine count and guessing which one was the ghost.

Go 1.26 changes that. Behind an experiment flag sits a new profile — goroutineleak — that does what the name promises: it finds the goroutines that are stuck and can never be unstuck, then hands you their stack traces. No guessing. You finally see them.

This is a walkthrough, not a press release. We’ll build the leak, run the detector, and read the output like a forensic report.

(This article was co-authored with https://liteagent.cloud/ . Try it today!)

Press enter or click to view image in full size

The one idea you need

A goroutine “leaks” when it’s blocked on a synchronization primitive — a channel, a mutex, a WaitGroup — and nothing in the program can ever unblock it. The classic tell: the primitive it's waiting on has become unreachable. No live reference points to it, so no other goroutine can send, close, or unlock it. The waiter is orphaned.

Go 1.26’s detector rides on the garbage collector. When GC runs, it already computes what’s reachable from the roots (globals, stacks, the heap). The leak profile adds one question: among the goroutines currently blocked on a sync primitive, which ones are waiting on something that’s no longer reachable? Those are your leaks. It’s reachability analysis repurposed as a debugger — exactly the kind of thing Da Vinci would have drawn in the margin.

The experiment

The whole program is under 50 lines. Let’s take it apart by what each part is for.

Section 1 — The imports: four lines that arm the runtime

import (
"fmt"
"os"
"runtime/pprof"
"time"
)
  • fmt — our only end-of-program job is to print a banner and a fallback error, so the standard formatter earns its place.
  • os — we write the profile to os.Stdout and, if anything goes wrong, to os.Stderr.
  • runtime/pprof — the star. This package exposes the live profiles, and in 1.26 it knows about goroutineleak. Both pprof.Lookup and WriteTo come from here.
  • time — two sleeps. One inside each leak function to let the goroutine settle; one in main to give the GC room to run. Timing is the difference between "leak detected" and "leak not yet visible."

Section 2 — leakyGoroutine: the textbook leak

func leakyGoroutine() {
ch := make(chan struct{})
go func() {
<-ch // blocks forever; nothing ever sends on ch
}()
time.Sleep(1 * time.Second)
// ch is now unreachable (no references left) -> the goroutine is leaked
}

Line by line:

  • ch := make(chan struct{}) — an unbuffered channel of empty structs. struct{} costs zero bytes; it's the idiomatic "signal only, no payload" type. We care about the blocking, not the data.
  • go func() { — spin up the goroutine that will leak.
  • <-ch — receive from ch. There is no sender anywhere in the program. This line will never complete; the goroutine parks here permanently.
  • }() — launch it.
  • time.Sleep(1 * time.Second) — the parent (the goroutine running leakyGoroutine) sleeps, then returns. When it returns, the local ch goes out of scope. Nothing else references it. It is now unreachable.
  • The comment is the punchline: because ch is unreachable, no one can send on it or close it. The child goroutine is orphaned. Leak complete.

Section 3 — leakyGoroutineAlt: same leak, different fingerprint

func leakyGoroutineAlt() {
ch := make(chan struct{})
go func() {
<-ch // blocks forever; nothing ever sends on ch
}()
time.Sleep(1 * time.Second)
}

Structurally identical to Section 2 — same unreachable-channel trick. The only difference is the function name. That matters for the output: the detector groups leaks by stack trace, and the stack trace includes the function that spawned the goroutine. Two different names produce two different stacks, so the profile shows them as separate entries. This is how you prove the tool distinguishes where a leak came from, not just that one exists.

Section 4 — main: trigger, wait, reveal

func main() {
// Enable the experimental goroutineleak profile (Go 1.26).
leakyGoroutine()
leakyGoroutine()
// Alt adds different entry to profile to demonstrate that multiple entries can be present.
leakyGoroutineAlt()

// Give the runtime time to run GC and perform reachability analysis
// so the leak can be observed.
time.Sleep(5 * time.Second)

fmt.Println("=== goroutineleak profile ===")
if p := pprof.Lookup("goroutineleak"); p != nil {
if err := p.WriteTo(os.Stdout, 1); err != nil {
fmt.Fprintln(os.Stderr, "write failed:", err)
}
} else {
fmt.Fprintln(os.Stderr, "goroutineleak profile not available")
}
}

  • leakyGoroutine() twice, then leakyGoroutineAlt() once — we create three leaks on purpose: two from the same function, one from the alternate. That 2-and-1 split is what makes the output interesting below.
  • time.Sleep(5 * time.Second) — the critical pause. The leak profile is computed during garbage collection. We need at least one GC cycle after the channels go unreachable, so the collector can mark them dead and the detector can notice the orphaned waiters. Five seconds is plenty for several GCs on a tiny program. Skip this and you may see total 0.
  • fmt.Println("=== goroutineleak profile ===") — a banner so the profile stands out from any other stdout.
  • if p := pprof.Lookup("goroutineleak"); p != nil { — ask the runtime for the named profile. It returns nil if the experiment isn't enabled (e.g., you forgot the GOEXPERIMENT flag). The nil-check is our graceful fallback.
  • p.WriteTo(os.Stdout, 1) — dump the profile. The second argument is the debug level: 1 means "symbolize the stacks" — turn raw hex addresses into main.leakyGoroutine.func1 plus a file:line. Use 0 and you get only hex; use 2 and you get the raw protobuf. 1 is the human-readable one.
  • else { fmt.Fprintln(os.Stderr, "goroutineleak profile not available") } — if the flag wasn't set, tell the user instead of panicking on a nil pointer.

Running it

On a clean Linux box with Go 1.26:

cheikh@devbox:~/go-snippets/goroutine-leak$ GOEXPERIMENT=goroutineleakprofile go run .
=== goroutineleak profile ===
goroutineleak profile: total 3
2 @ 0x47e0ce 0x4158ee 0x415432 0x4da079 0x4845a1
# 0x4da078 main.leakyGoroutine.func1+0x18 /home/cheikh/go-snippets/goroutine-leak/main.go:16

1 @ 0x47e0ce 0x4158ee 0x415432 0x4da139 0x4845a1
# 0x4da138 main.leakyGoroutineAlt.func1+0x18 /home/cheikh/go-snippets/goroutine-leak/main.go:25

How to read the output

This is the part most write-ups skip. Decode every line:

  • goroutineleak profile: total 3 — three leaked goroutines detected. Matches our code: two from leakyGoroutine(), one from leakyGoroutineAlt().
  • 2 @ 0x47e0ce ... 0x4845a1 — the 2 @ prefix means two goroutines share this exact stack trace. Those five hex values are the call stack, bottom to top, as raw program counters. They're identical across both entries because both leaks were spawned the same way.
  • # 0x4da078 main.leakyGoroutine.func1+0x18 .../main.go:16 — the symbolized leaf frame (courtesy of WriteTo(..., 1)). main.leakyGoroutine.func1 is the anonymous goroutine function inside leakyGoroutine — Go names closure goroutines Parent.func1, Parent.func2, and so on. +0x18 is the offset from the function's start. main.go:16 is the line where it's blocked: our <-ch. That's your smoking gun — line 16 is where the orphaned goroutine is parked.
  • 1 @ ... 0x4da139 ... and the next frame — a single goroutine with a different stack, symbolized to main.leakyGoroutineAlt.func1 at main.go:25. Same leak shape, different origin function, so the detector lists it separately. The 1 @ tells you it's a singleton, not part of a cluster.

The takeaway: the profile doesn’t just say “you have leaks.” It tells you how many, where they’re blocked (file:line), and which code path spawned each one. That’s enough to walk straight to the bug.

Under the hood: why this works

The detector is cheap because it borrows the GC’s own work. When the Green Tea collector (now default in 1.26) traces the heap, it already knows which objects are reachable from the roots. A goroutine blocked on a channel holds a reference to that channel’s hchan struct. If, after a GC, that hchan is unreachable from anywhere except the blocked goroutine itself, the goroutine can never be woken — and the runtime flags it. No extra bookkeeping, no shadow accounting; it's a post-pass over data GC already computed.

The fine print

Two honest limits before you wire this into CI:

  1. It only sees unreachable primitives. If your leaked goroutine is blocked on a channel stored in a global variable, that channel is still reachable, so the detector stays quiet. It catches the “forgot to close the local channel” leaks, not the “global singleton never signals” leaks.
  2. It’s experimental. The GOEXPERIMENT=goroutineleakprofile flag and the pprof.Lookup name may shift before this graduates. Don't hard-fail a pipeline on it yet — treat it as a local diagnostic, not a gate.

See for yourself

The full, runnable source is here: https://github.com/cheikh2shift/go-snippets/tree/main/goroutine-leak

Get Cheikh seck’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Next time your service’s goroutine count creeps up like a tide you can’t see the source of, don’t guess. Flip the flag, dump the profile, and let Go 1.26 show you exactly which line is holding the door open. Saper vedere — now the runtime sees with you.

Links & Further Reading

Run it yourself

Official Go 1.26

Implementation