- Published on
The goroutineleak Profile in Go 1.26 — Catching Goroutine Leaks with GC Marking
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Why Goroutine Leaks Never Showed Up in Profiles
- What Go 1.26 Added
- The Core Idea — Reachability as a Proxy for Liveness
- The Algorithm — Six Steps Around the Mark Phase
- Trying It Out
- What It Can't Catch — Two False-Negative Patterns
- Cost — Where the Overhead Lands
- Relationship to goleak — A Complement, Not a Replacement
- Where the Paper and Upstream Diverge — Recovery Got Left Out
- When to Use It, and When Not To
- Closing
- References
Introduction — Why Goroutine Leaks Never Showed Up in Profiles
Anyone who has debugged a goroutine leak knows this frustration. Service memory keeps trending up and to the right. You pull a goroutine profile. There are 40,000 goroutines. Which handful of them will never wake up?
The profile can't answer. The goroutine profile just dumps the stack of every goroutine, and a [chan receive] entry doesn't distinguish "a normal worker that will wake up in 200ms" from "a zombie holding a channel nobody references, asleep forever." Both are equally blocked on a channel receive. The Go 1.26 proposal puts it precisely — goroutine leaks are notoriously hard to debug, and sometimes their very existence is hard to notice even with fairly thorough diagnostic information like memory and goroutine profiles.
So in practice, teams have bolted tools like uber-go/goleak onto their tests. It compares goroutine stacks before and after a test to catch whatever's left over. It works well, but only inside tests. Goroutines actually leaking in production were still out of reach.
Go 1.26, released on February 10, 2026, put a tool into that gap: a new profile type in runtime/pprof, goroutineleak.
What Go 1.26 Added
Let's start with the release note's definition. A leaked goroutine is "a goroutine blocked on some concurrency primitive — a channel, sync.Mutex, sync.Cond, and so on — that can never become unblocked." What matters here is "can never" — not the fact that it's blocked right now, but the fact that it can never wake up under any future execution.
The release notes explain directly how the runtime decides this. If goroutine G is blocked on concurrency primitive P, and P is unreachable from any runnable goroutine and from any goroutine those goroutines could wake, then P can never become unblocked, so G can never wake up.
In Go 1.26 this is an experimental feature, so you have to turn it on at build time.
# build/run with the profile enabled
GOEXPERIMENT=goroutineleakprofile go run ./cmd/server
# build without the experiment flag and pprof.Lookup("goroutineleak") returns nil
Turning on the experiment also registers the net/http/pprof endpoint /debug/pprof/goroutineleak.
Two caveats the release notes attach to this feature stand out. One: the implementation itself is production-ready, and it's classified as experimental purely to gather feedback on the API — specifically, whether making this a new profile type was the right call. Two: it's designed to add no extra runtime overhead unless you're actually using it. In other words, if you leave it on but never pull the profile, it's essentially free.
And the Go 1.27 draft release notes already say this: the goroutineleak profile is now generally available, and the goroutineleakprofile GOEXPERIMENT setting has been removed. Go 1.27 is slated for release in August 2026, so this feature will simply be on by default next month. That's why it's worth understanding now.
The Core Idea — Reachability as a Proxy for Liveness
Here's the elegant part. No new tracing infrastructure was built. It recycles the GC marking that already exists.
The logic goes like this. Say goroutine G is blocked on channel ch. To wake G, someone has to send on ch or close it. To do that, that someone has to be able to get hold of a reference to ch. And being able to get hold of a reference is exactly what the GC calls memory reachability.
So in the paper's terms, memory reachability soundly over-approximates the liveness of concurrency operations. "Over-approximates" is the key word — the set the GC judges "reachable" is guaranteed to fully contain every goroutine that's actually alive. A live goroutine is never misclassified as unreachable.
Why this matters is that this direction of soundness is what guarantees no false positives. The proposal states it explicitly — the approach is theoretically sound, meaning there are no false positives. If the profile says "this goroutine is leaked," it really is leaked. It's not a guess.
The paper also explains why this guarantee is essential. False positives could cause memory still in use to be freed, leading to unexpected crashes. Because this technique runs inside the GC, getting a judgment wrong isn't just a bad report — it's a memory-safety incident. Soundness wasn't optional here; it was a requirement.
The Algorithm — Six Steps Around the Mark Phase
The official proposal lays out the changes to the GC cycle in six steps. Summarized:
- Prepare mark roots. Normally the GC treats every goroutine as a root. A leak-detection cycle treats only runnable goroutines as roots. This one line is where everything starts.
- Mark. Mark memory reachable from this root set.
- Check. Once marking finishes, look for goroutines that are still unmarked but are blocked on a concurrency primitive that got marked in step 2.
- Promote. Treat such goroutines as "eventually runnable," add them to the roots, and go back to step 2 to resume marking.
- Fixed point and reporting. Repeat this until reaching a fixed point, then report as leaked whatever goroutines never made it into the roots. Then, using the leaked goroutines as roots, mark once more so that every reachable piece of memory ends up marked, just as in a normal GC cycle.
- Sweeping proceeds as usual.
The repetition in steps 3–4 is the crux. It follows chains like "A wakes B, B wakes C" all the way to a fixed point. A single marking pass would misjudge "a goroutine that's blocked right now but about to wake up" as leaked. The repetition is precisely the device that eliminates false positives.
The last sentence of step 5 shouldn't be skimmed past either. We'll return to this later, but the memory of a leaked goroutine is not reclaimed. It's only reported; the mark stands.
One implementation detail worth noting too. For this approach to hold together, certain pointers have to be traced only conditionally. The *sudog object (the struct a blocked goroutine hangs on a wait queue) is globally reachable via allgs and semtable, so left alone, the GC would mark even channels that haven't yet been judged reachable. So the runtime introduces the concept of a maybe-traceable pointer, and during a leak-detection cycle, it makes the relevant field of *sudog untraceable. This isn't a feature that came for free — it required carefully touching the runtime's internals.
Trying It Out
Let's build a typical leak.
package main
import (
"os"
"runtime/pprof"
"time"
)
func leak() {
ch := make(chan int) // unbuffered channel
go func() {
<-ch // nobody sends -> blocks forever
}()
// ch goes out of scope here, and no runnable goroutine references it
}
func main() {
leak()
time.Sleep(100 * time.Millisecond) // give the goroutine time to actually block
f, _ := os.Create("leak.pprof")
defer f.Close()
pprof.Lookup("goroutineleak").WriteTo(f, 1) // debug=1: human-readable summary
}
Run it with GOEXPERIMENT=goroutineleakprofile go run main.go and the leak gets caught. Run it without the experiment flag and pprof.Lookup("goroutineleak") returns nil and panics right there — watch out.
The meaning of the debug level is defined in the proposal — if debug < 2, only goroutines in a leaked state are filtered in; if debug >= 2, you get a full stack dump of every goroutine. And with debug=0 you get a gzipped protobuf like any other profile, so you can open it with go tool pprof.
For a server, the HTTP endpoint is more convenient.
# importing net/http/pprof registers this on the default mux
curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'
# or attach directly with go tool pprof
go tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak'
The debug=2 output looks almost identical to a familiar goroutine dump, except the status line gets one extra tag.
goroutine 21 [chan receive (leaked)]:
main.leak.func1()
/path/main.go:13 +0x6c
(leaked). That one word is the whole feature. In an ordinary goroutine dump you'd just see [chan receive] with no way to tell normal from zombie — now the runtime makes that call for you.
There's an important behavior to note here — pulling a profile isn't just reading a snapshot. Per the proposal, extracting the profile queues a leak-detection GC cycle and waits for it to complete, then pulls a goroutine profile and filters it down to the leaked state. In other words, a single curl /debug/pprof/goroutineleak forces one special GC cycle. This isn't something you wire into a dashboard to poll every 10 seconds.
What It Can't Catch — Two False-Negative Patterns
Now for the honest part. Opposite the guarantee of no false positives sits the fact that false negatives are entirely possible. The release notes don't hide this either — detecting all cases of permanently blocked goroutines is impossible, and this approach detects a large class of such leaks. "A large class," not "all of them."
The proposal sums up the limitation in one line — the more heap resources are over-exposed in memory, meaning reachable from each other, the less effective it becomes. What kind of code this actually means in practice is shown by two patterns the paper found in real code.
First, global channels. If a channel hangs off a global variable, it's always reachable in memory. So a goroutine blocked on that channel is always classified as "could eventually wake up" — even if, in reality, nobody ever touches that channel again.
var gch = make(chan int) // global -> always reachable
func neverDetected() {
go func() {
<-gch // a real leak, but it never shows up in the profile
}()
}
The release notes say the same thing — because this technique is built on reachability, it may fail to identify leaks caused by goroutines blocked on concurrency primitives reachable through global variables or local variables of a runnable goroutine.
Second, and this one's nastier — what the paper calls "runaway live goroutines." Here's an example the paper pulled from real code.
type dispatcher struct {
ch chan struct{}
ticks int
}
func newDispatcher() *dispatcher {
d := &dispatcher{ch: make(chan struct{})}
go func() { // heartbeat goroutine
for ; ; time.Sleep(time.Second) {
d.ticks++ // keeps holding onto d
}
}()
return d
}
func main() {
d := newDispatcher()
go func() { d.ch <- struct{}{} }() // this goroutine can leak
if someCondition {
return // nobody receives from d.ch
}
<-d.ch
}
See what happens. The heartbeat goroutine is always runnable because it loops on time.Sleep. And because it references d, d is reachable, and so d.ch is reachable too. From the GC's point of view, there's no choice but to conclude "couldn't the heartbeat goroutine eventually send on d.ch?" — even though the heartbeat only ever touches d.ticks and never even looks at d.ch. Reachability is per-object, not per-field.
This pattern is nasty precisely because it's a structure common in well-built services — heartbeats, metrics reporters, background refreshers. If one such goroutine holds onto a struct, any leak in a channel hanging off that same struct becomes completely invisible. If your codebase comes back clean on this profile, whether that means there really are no leaks, or that the heartbeats are just hiding them, is something you have to think through separately.
Cost — Where the Overhead Lands
The numbers here come from two different sources, and they need to be kept separate.
What upstream says. The Go 1.26 release notes state that this feature is designed to add no extra runtime overhead unless it's actually in use. No specific figures are given. The proposal likewise only mentions a prototype and doesn't include performance data.
What the paper measured (author-measured, ASPLOS 25). From here on, these are numbers measured by the Saioc et al. paper using a tool called Golf — an implementation that extends the GC of Go 1.22.5 — and it is not the same configuration as what shipped in Go 1.26 (explained below). Carried over as-is:
- In microbenchmarks, the median slowdown of the GC mark phase, on normal programs, was 0.96x — actually slightly faster. It went as low as 0.13x (876µs → 112µs) at best. But in the worst case it slowed down to 4.8x (113µs → 538µs).
- On programs that do have leaks, the median is 0.71x, faster still, because there's less memory to mark. The worst case was 5.87x (136µs → 798µs).
- In absolute time, the mark phase still finished within 10ms. For normal examples, the max was 2ms (3.27x versus a 619µs baseline).
- Running Uber's real service (1.7M lines) in a controlled environment, the dominant cost was GC pause time. The average pause per GC cycle was about 2.5x the baseline. Still, even under a 10%-leak scenario, total GC pause time came out to only 0.65% of a 30-second run.
The slowdown multipliers alone look alarming, but you have to weigh them against the fact that the absolute values are in milliseconds, and that the multipliers swing widely. And all of these numbers are the authors measuring their own tool on their own benchmarks.
The practically important thing to remember is actually simple. The moment you pull a profile, a special GC cycle is forced to run. If you don't pull it, (per the release notes' stated design intent) it's essentially free. So the cost model isn't "the cost of leaving it on" — it's "the cost of calling it."
Relationship to goleak — A Complement, Not a Replacement
If you're already using uber-go/goleak, there's no need to prepare to switch. The paper ran a direct comparison, and the results are modest.
On Uber's codebase test suite, goleak reported 29,513 individual leaks (357 after deduplication). Under the same conditions, Golf detected 17,872 (60%), and after deduplication, 180 — about 50% of the 357. In the paper's own words, goleak is, by design, more effective within test suites.
Digging a bit deeper, when faulty code surfaces a leak during testing, Golf found 82% of what goleak found (by area under the curve of per-report ratios), and among the 180 deduplicated reports, it found every individual leak goleak reported in 103 (55%) of them. In microbenchmarks, overall detection was much better, at 94.75%.
Why the numbers diverge like this matters. As the paper points out, Golf's effectiveness depends on when a GC cycle happens to run relative to when the leak occurred and when the test ends. Tests are short, and the GC might not run at all. goleak, by contrast, compares stacks directly at test-end time, independent of the GC.
So the division of labor is natural.
- Use goleak for tests and CI. In a short, deterministic environment, stack comparison catches more, more tightly.
- Use the goroutineleak profile in and around production. It's a place goleak can't go, and the no-false-positives guarantee earns its keep during an on-call incident. Not having to re-verify "is this actually a real leak?" at 3am is a bigger deal than it sounds.
The release notes also recommend trying it out across tests, CI, and production alike, so framing this as a choice between the two is itself the wrong question.
Where the Paper and Upstream Diverge — Recovery Got Left Out
This is something that's easy to miss if you only read the release notes, so it deserves its own section.
The paper's title is "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection." Detection isn't the whole story — recovery is the other half. The paper's Golf reports deadlocked goroutines under a stop-the-world condition and force-kills them, and as a result it reports that under a 10%-leak scenario, server memory usage drops by about 49x, throughput rises 9%, and tail latency drops by about 1.5x (again, author-measured).
But what shipped in Go 1.26 is only half of that. Look again at step 5 of the proposal — after reporting the leak, it marks once more using the leaked goroutines as roots so every reachable piece of memory ends up marked, just as in a normal GC cycle. In other words, the upstream implementation deliberately keeps alive the memory a leaked goroutine is holding onto. The release notes only say the profile reports leaks — nothing about reclaiming memory or killing goroutines.
This reads less like a compromise than a reasonable design decision. A language runtime unilaterally killing user goroutines and reclaiming memory is a line a diagnostic tool shouldn't cross. However solid the soundness proof, a production runtime force-terminating code based on that judgment carries a different weight of risk than simply printing a report.
The practical implication is clear. This profile does not fix the leak for you. It doesn't give the memory back either. It only tells you, via a stack trace, where the leak is — fixing it is still on you. Don't expect the paper's striking 49x memory reduction from Go 1.26 — that number belongs to a tool that does recovery.
When to Use It, and When Not To
When it earns its keep
- A long-running service's memory keeps trending up and you can't pin down why — especially if the goroutine count is climbing alongside it. This is the first profile to pull.
- You're on-call, guessing without confirmation whether "these goroutines are really zombies." The no-false-positives guarantee earns its value exactly in this moment.
- You've already wired goleak into CI, but you're chasing a nondeterministic leak that only shows up in production.
When it's overkill or the wrong fit
- Short, deterministic unit tests. There's no guarantee the GC will even run, so detection is spotty — goleak is the right tool here.
- An always-on monitoring dashboard. Every time you pull the profile, you force a GC cycle. This is a tool you reach for a few times during an incident, not a metric you poll periodically.
- A codebase tightly woven with global channels and heartbeat goroutines. False negatives dominate, and a "clean" result might tell you nothing at all.
- Wanting to conclude "no leaks" just because the codebase passed this profile. That's not a conclusion this tool supports.
Closing
To sum up: Go 1.26's goroutineleak profile detects goroutine leaks by recycling the GC's marking phase. It marks using only runnable goroutines as roots, repeatedly promotes goroutines blocked on marked primitives into roots until reaching a fixed point, then reports whatever's left as leaked. Thanks to the insight that memory reachability is a sound over-approximation of liveness, it has no false positives — and precisely because it's built on that same reachability, it produces false negatives in the face of global variables and runaway live goroutines.
Personally, what I like about this feature isn't the performance numbers. It's that it pulled near-free information out of a mechanism that already existed. The GC was already computing "who can reach what" on every cycle. Recognizing that this same computation is also an answer to "who can wake whom" — that's the whole of this research. And that insight actually made it from PhD research into the Go runtime — an ASPLOS 25 paper landing in the standard library in under a year.
When Go 1.27 ships in August 2026, you'll be able to use this with no flag at all. Before then, I'd recommend pulling it once against your own service with GOEXPERIMENT=goroutineleakprofile. If something shows up, it's real. If nothing does — that's a reason to go count your heartbeat goroutines.
References
- Go 1.26 release notes — Experimental goroutine leak profile
- Go 1.26 is released (February 10, 2026)
- Go 1.27 draft release notes — goroutineleak profile generally available
- Proposal: Goroutine leak detection via garbage collection (Saioc, Chabbi)
- golang/go#74609 — runtime/pprof,runtime: new goroutine leak profile
- Dynamic Partial Deadlock Detection and Recovery via Garbage Collection (Saioc, Lee, Møller, Chabbi — ASPLOS 25)
- uber-go/goleak — a goroutine leak detector for tests
- From PhD research to the Go runtime (Aarhus University)