{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreibukisyiefukzhfr3qovnqv3wphe52kqkjjxnq42ldmyuym222t3i",
"uri": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/app.bsky.feed.post/3mosowth24s2e"
},
"canonicalUrl": "https://rednafi.com/go/channel-iteration-goroutine-leak/",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreibz4kwi3yba6ehh7omf5irvknstbjgvvq6g72d4hmkpe55hg2mehu"
},
"mimeType": "image/png",
"size": 121470
},
"description": "A for-range over a channel that's never closed leaks the receiver. Why a fixed number of receives is safe, why a range isn't, and how to catch it with Go 1.27's leak profile.",
"path": "/go/channel-iteration-goroutine-leak/",
"publishedAt": "2026-06-21T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Concurrency",
"Profiling"
],
"textContent": "I ran into the classic _\"range over a channel\"_ leak while working on a custom cron\nscheduler. I've debugged it on prod many times before, but writing one myself in a small\npiece of code reminded me how easy it is to write bugs like this even when you know about\nit.\n\nHere:\n\n- on each tick, the scheduler dispatches the jobs that are due\n- each job reports its outcome on a channel\n- one collector ranges over that channel to record the run\n\n- (1) each due job sends its outcome on the unbuffered channel\n- (2) the collector ranges over results, recording each outcome and marking it done\n- (3) once every job has reported, wg.Wait unblocks and tick returns\n\nThe producers are fine. Every send is matched by the collector's receive, so each job\ngoroutine sends once and exits. The collector is the problem. After the last outcome it\nloops back to the range and waits for the next value, but nothing ever closes the channel.\nSo it blocks on that receive for the life of the process. Every tick leaks another one.\n\nDrain that same channel by hand and it never leaks. Send three values and take exactly\nthree:\n\nThree receives, then the goroutine returns. Swap those receives for a range and it leaks:\n\nThe two forms stop on different conditions. Three explicit receives stop on their own after\nthe third value. A range keeps reading until the channel closes. Back in the scheduler,\nnothing closes results, so the ranging collector blocks on a receive that never completes.\n\nThe fix is the one line the buggy version is missing: close results once every job has\nreported. The range ends and the collector returns:\n\n> [!WARNING]\n>\n> Reaching for a buffered channel instead won't fix this. A range ends only when the\n> channel is closed. No matter how big the buffer is, the receiver keeps waiting for a close\n> that never comes.\n\nThis is a fairly well-documented leak. Uber called it [channel iteration misuse].\n\nTypically you'd catch a leak like this with [goleak]:\n\n- wire up goleak\n- exercise the path that leaks in a test\n- the test fails with the stuck goroutine's stack\n\nI wrote about the goleak workflow in the [early return leak] post. But goleak only catches a\nleak when a test exercises the buggy path, and my scheduler tests never ran that path. So\ngoleak never saw it.\n\nWhat caught it was [Go 1.27's new leak profile]. I was running it over my own code while\n[writing about it], and it doesn't need a test at all. It leans on the garbage collector to\nfind goroutines blocked on something nothing can ever reach, and reports only those. Run it\nat debug=2 and the stuck collector shows up tagged (leaked):\n\nmain.tick.func2 is the collector, parked on the range at line 43. The profile finds leaks\nlike this deterministically, with no false positives and without a test ever exercising the\npath.\n\n---\n\nClosing the channel stops the leak, but it leaves one odd bit: the WaitGroup is counting\njobs while the collector calls Done.\n\nThe collector should only drain results. Job completion belongs to the goroutine that runs\nthe job. Once every job returns, a waiter can close results, and the range can finish\nnormally.\n\nWith wg.Go, the corrected version becomes:\n\n- (1) wg.Go runs each job and calls Done when it returns, so each job marks its own\n completion\n- (2) a separate goroutine waits for every job, then closes results so the range can end\n- (3) tick drains results itself, so there is no separate collector goroutine\n\nForget the close here and tick blocks on the range after the last result. All producers\nhave exited, so no one can send another value. It's the same missing close, but now it fails\nas a deadlock instead of leaking a background collector.\n\nThe code is available in the [example repo].\n\n\n\n\n[channel iteration misuse]:\n https://www.uber.com/blog/leakprof-featherlight-in-production-goroutine-leak-detection/\n\n[early return leak]:\n /go/early-return-and-goroutine-leak/\n\n[goleak]:\n https://github.com/uber-go/goleak\n\n[Go 1.27's new leak profile]:\n https://go.dev/doc/go1.27#goroutine-leak-profile\n\n[writing about it]:\n /shards/2026/06/go-goroutine-leak-profile/\n\n[example repo]:\n https://github.com/rednafi/examples/tree/main/channel-iteration-leak",
"title": "Channel iteration and goroutine leak"
}