{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/shards/2026/06/go-goroutine-leak-profile/",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreibz4kwi3yba6ehh7omf5irvknstbjgvvq6g72d4hmkpe55hg2mehu"
},
"mimeType": "image/png",
"size": 121470
},
"description": "Notes on Go's accepted goroutine leak profile and how it reuses the GC to find them.",
"path": "/shards/2026/06/go-goroutine-leak-profile/",
"publishedAt": "2026-06-18T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Concurrency",
"Profiling"
],
"textContent": "Go 1.27 is getting a goroutine leak detector in runtime/pprof. The [proposal] was accepted\nin April.\n\nA few common goroutine leaks\n\nA goroutine leaks when it blocks on a channel or lock that nothing will ever release, so it\nlingers for the life of the process. I've been using [uber-go/goleak] to catch them in\ntests.\n\nOne is an early return that strands a sender, which I covered in [Early return and goroutine\nleak]. It looks like this:\n\nHere:\n\n- (1) each task sends its result on the unbuffered channel through wg.Go\n- (2) the first error returns early, so the tasks still queued to send block forever\n\nGiving errs a buffer big enough for every task, or draining all the results before\nreturning, keeps the sends from blocking.\n\nA related leak shows up when you send a request to several replicas and keep only the first\nanswer:\n\nHere:\n\n- (1) every replica races to send its answer on the unbuffered channel\n- (2) the first answer returns, and the slower replicas block forever on their sends\n\nSame as before, a buffer sized for every replica lets the slower ones send and exit.\n\nAnother is a forgotten close:\n\nHere:\n\n- (1) the range keeps pulling from out until it's closed\n- (2) stream returns without close(out), so the range never ends and the goroutine leaks\n\nThe fix is to close(out) after the last send, which ends the range and lets the goroutine\nreturn.\n\nThey're obvious once you spot them, but easy to let slip past under an early return or once\nthe surrounding code grows. goleak catches them in tests. In production you've got the\nregular /debug/pprof/goroutine profile. It shows what each goroutine is blocked on, not\nwhether it will ever unblock, so you're guessing which are stuck for good and which are just\nidle.\n\nThis list is nowhere near exhaustive, and not every leak is in your own code. A dependency,\nor one of its transitive deps, can leak one too. Uber [catalogued the patterns across its Go\nmonorepo].\n\nThe stdlib leak profile can now find them\n\nIt came out of Uber, the same place as goleak, and was designed by Vlad Saioc and Milind\nChabbi. The [detection rides on the garbage collector]. A goroutine is leaked when it's\nblocked on a channel or lock that no runnable goroutine can reach, directly or through\nanother goroutine a runnable one could unblock. Nothing can ever wake it. The GC flags it.\n\n> [!NOTE]\n>\n> Read that as a reachability test. If a goroutine is blocked on primitive P, and P is\n> unreachable from any runnable goroutine or from any goroutine those runnable ones could\n> unblock, then P cannot be unblocked. The goroutine can never wake up.\n\ngoleak and the profile answer different questions:\n\n| | goleak | goroutineleak profile |\n| --------------- | -------------------------------------- | ------------------------------------ |\n| Asks | what's still running you didn't expect | what can never run again |\n| How it decides | a snapshot, no proof | a reachability proof, via the GC |\n| Works in | tests, at teardown | a live process |\n| False positives | yes, on a live server | none, only provably stuck goroutines |\n\nThe split is about where each one runs. At a test's teardown nothing should be left running.\nHanding back whatever's there is exactly what you want from goleak. A live server is the\nopposite. Most of its goroutines are blocked on purpose, waiting for the next request, and\ngoleak can't tell those from a real leak.\n\nThe profile proves it instead. It starts from the goroutines that can still run, follows\nwhat they can reach, and rescues any blocked goroutine whose channel or lock is still in\nplay. Whatever's left has nothing that could ever touch it. It's stuck for good. Uber had\nalready tried [in-production leak detection] with a sampling tool, but sampling flags by\nheuristic and turns up false alarms. The GC pass reports only goroutines it can prove are\nstuck. That's the [no false positives] guarantee.\n\nThe profile ships without goleak's VerifyNone(t) or VerifyTestMain(m). The [test\nsection] shows how to roll your own.\n\nThe API is tiny. There's no new type or function, just a profile named goroutineleak. It\nships registered, and the standard pprof tooling reads it like any other profile.\n\nYou can pull the profile in the usual four ways\n\n> [!NOTE]\n>\n> For now the profile is behind a build flag. Run the examples below with\n> GOEXPERIMENT=goroutineleakprofile, or pprof.Lookup(\"goroutineleak\") returns nil. Go\n> 1.27 will make it [generally available] and drop the flag.\n\nFrom your own code\n\nYou pull the profile and write it out yourself. Start with debug=0, which dumps a gzipped\nprotobuf to a file:\n\npprof.Lookup returns the profile, and WriteTo runs the leak-detecting GC cycle before\nwriting it out. Open the file with go tool pprof, the same as a CPU or heap profile.\n\nWriteTo's second argument is the debug level. 1 and 2 give text instead, which you\ncan send straight to os.Stdout. At debug=1, a signal handler lets kill -USR1 <pid>\ndump leaks on demand:\n\nThe text points straight at the goroutines that leaked:\n\ndebug=2 is a full goroutine dump, with the leaked goroutines tagged (leaked):\n\nA normal dump reads [chan send] and [chan receive]. The (leaked) suffix is what the\nprofile adds.\n\nIn a test\n\nOne helper runs the detection and returns whatever's stuck:\n\nFor one test, wrap it in a verifyNone you defer:\n\nFor the whole suite, write a verifyTestMain and call it from TestMain:\n\nOver HTTP\n\nImporting net/http/pprof registers it on the default mux. Serve that mux, the nil\nhandler below, and the endpoint is live with no extra code:\n\nThen read the profile off the endpoint:\n\nWith go tool pprof\n\ngo tool pprof reads it like any other profile, pointed at that endpoint or a saved\ndebug=0 dump:\n\nWhat it can't catch\n\nIt won't catch every leak. The Go 1.27 notes admit it [can't catch every case] and only\npromise a large class of them.\n\nThe reason is the no-false-positives rule. To avoid ever flagging a goroutine that could\nstill wake up, the GC leaves alone any whose channel or lock is still reachable. A global,\nor the locals of a runnable goroutine, can keep that channel or lock reachable long after\nanything will actually touch it, so the goroutine blocked on it goes unflagged. Everything\nthe profile reports is a real leak. Some real leaks just don't show up.\n\nEvery snippet here is a runnable program in the [example repo]. I ran them on the 1.26\ntoolchain and the profile flagged each leak at the exact line.\n\n\n\n\n[proposal]:\n https://github.com/golang/go/issues/74609\n\n[uber-go/goleak]:\n https://github.com/uber-go/goleak\n\n[Early return and goroutine leak]:\n /go/early-return-and-goroutine-leak/\n\n[detection rides on the garbage collector]:\n https://go.dev/design/74609-goroutine-leak-detection-gc\n\n[generally available]:\n https://go.dev/doc/go1.27#goroutineleak-profiles\n\n[can't catch every case]:\n https://go.dev/doc/go1.27#::text=impossible%20to%20detect%20permanently%20blocked\n\n[no false positives]:\n https://go.googlesource.com/proposal/+/master/design/74609-goroutine-leak-detection-gc.md#::text=theoretically%20sound\n\n[in-production leak detection]:\n https://www.uber.com/blog/leakprof-featherlight-in-production-goroutine-leak-detection\n\n[test section]:\n #in-a-test\n\n[catalogued the patterns across its Go monorepo]:\n https://arxiv.org/abs/2312.12002\n\n[example repo]:\n https://github.com/rednafi/examples/tree/main/goroutine-leak-profile",
"title": "Accepted proposal: a goroutine leak profile in the Go standard library"
}