skip to content

Why would an iter.Pull merge step leave 40,000 goroutines parked in the goroutine profile?

level: seniorimportance: nice to knowfreq 22%

answer

  1. one suspended sequence per pull
  2. it parks inside yield between values
  3. only draining or stopping ends it
  4. goroutines are never garbage collected
  5. the early return skipped one stop

basics

~20 s

Each iter.Pull runs its sequence on a coroutine parked inside yield between values. Abandoning the cursor without calling stop leaves that sequence parked forever, its cleanup unrun, one goroutine per call. The fix is defer stop() after every Pull.

solid answer

~50 s

`iter.Pull` runs the sequence function on its own coroutine, which is parked inside `yield` whenever it is not producing a value. It only finishes in two ways: you drain the sequence until `next` returns `false`, or you call `stop`, which resumes it with `yield` returning `false` so it returns and runs its defers. A merge that returns early — the first side runs dry, a write fails, the caller breaks — and does not call `stop` on the other side leaves that coroutine parked with nobody left holding its `next`. Parked goroutines are never garbage collected, so each invocation adds one, along with everything its stack and locals pin: the reader, the buffer, the file handle. In the goroutine profile the signature is unmistakable — thousands of goroutines with identical stacks inside the same iterator function. The fix is `defer stop()` on the line after every `iter.Pull`, before any statement that can return.

code

go · 12 lines
go
nextA, stopA := iter.Pull(a)
defer stopA()
nextB, _ := iter.Pull(b) // stop discarded: nothing can ever end b

for {
	va, okA := nextA()
	vb, okB := nextB()
	if !okA || !okB {
		return // b stays parked inside yield for the life of the process
	}
	emit(va, vb)
}

go deeper

for a junior

Know the rule that avoids this entirely: capture stop from every iter.Pull and defer it on the next line. Never assign it to the blank identifier.

for a middle

Explain the mechanics. The sequence runs on a coroutine parked inside yield, and only draining it or calling stop lets it return, which is also when its deferred cleanup runs.

for a senior

Reason from the evidence to the code: identical stacks in the goroutine profile, counts scaling with traffic, and an early return above a missing stop. Say what else the stranded sequences hold open.

for a principal

Set the standard that stops repeats: pull cursors treated as resources in review, a preference for plain range where it suffices, and agreement on what a rising goroutine count should page on.

## What the profile is showing you A goroutine profile listing 40,000 goroutines with the *same* stack, all inside one iterator function, is a leak with a single origin, not a diffuse one. The stack tells you which sequence and, because the counts scale with request or batch volume, roughly how many times the offending code path ran. ## Why a pull creates something that can be stranded `iter.Pull(seq)` does not copy or buffer the sequence. It starts running `seq` on a coroutine and suspends it at each `yield`, so calling `next` resumes it, it produces one value, and it parks again. Between calls, the sequence is a **live, suspended computation**: mid-loop, holding its local variables, with pending `defer` statements not yet run. There are exactly two ways for that computation to end: 1. **Exhaustion.** You keep calling `next` until it returns `false`, meaning the sequence function ran off its end and returned. Nothing is left parked, and calling `stop` afterwards is a harmless no-op. 2. **`stop`.** It resumes the suspended sequence with the pending `yield` returning `false`; the iterator sees that, returns, and its defers run. If neither happens, nothing else will make it happen. Dropping the `next` and `stop` variables does not help: the suspended goroutine is itself a root, so it is never collected, and neither is anything its stack references. There is no finalizer, no scope-exit hook, and no runtime cleanup for an abandoned cursor. ## The merge that does it The classic instance is a merge or a scan over two sequences that returns as soon as one side runs dry: ``` nextA, stopA := iter.Pull(a) defer stopA() nextB, _ := iter.Pull(b) // stop discarded for { va, okA := nextA() if !okA { return // b is left parked mid-yield, forever } ... } ``` Side A is protected; side B is not. Every call where A ends first — or where the caller breaks early, or a write returns an error — strands one coroutine. The code is correct in its output and leaks a goroutine per invocation, which is why it survives review and shows up weeks later as steadily climbing memory and goroutine counts. The same bug wears several disguises: `_` for the stop function; calling `stop()` at the bottom of the function so early returns skip it; a `defer` placed after a statement that can return; or stopping only on the error path and not on the success path. ## What the leak actually costs A parked goroutine's own stack is small, but the leak is rarely just that. The stranded sequence still owns whatever it opened — an `io.Reader` over a network connection, a `*os.File`, a `bufio.Scanner` and its buffer, database rows. Because its `defer f.Close()` never runs, those are held too. So the visible symptoms are usually a rising goroutine count *plus* rising heap *plus* file descriptors or pooled connections that never come back — and each of those has a separate, misleading first hypothesis. ## Confirming and fixing it The goroutine profile is the confirming evidence, not a hunch: identical stacks, count proportional to how often the path ran, and every one of them parked at the same `yield`. From the stack you get the iterator function, and from its callers the pull sites. Then audit every `iter.Pull` and `iter.Pull2` in that path for the one invariant: ``` next, stop := iter.Pull(seq) defer stop() ``` The `defer` goes on the line immediately after the pull, before any statement that can return. `stop` is safe to call more than once and safe to call on an already-exhausted sequence, so the unconditional `defer` never needs a guard and never needs to know which exit path you took. ## The framing that prevents it Treat a pull cursor as a resource acquisition, exactly like `os.Open` or `sql.DB.Query`. `iter.Pull` returns an open thing and its closer in one expression, and the closer is deferred on the very next line — no exceptions, including the paths you are sure will drain the sequence. Conversely, when a plain `for range` over the sequence will do, prefer it: `break`, `return` and panic all make the compiler-generated `yield` return `false`, so the sequence always gets to return and there is nothing to strand.

  • If the code drains a sequence until next returns false and never calls stop, does it leak?
    No. Exhaustion means the sequence function ran to its end and returned, so nothing is parked and its defers already ran. `stop` would be a no-op there. The leak needs an *abandoned* cursor: one left suspended mid-sequence with no further calls coming.
  • Why doesn't the garbage collector reclaim the abandoned sequence once next and stop are unreachable?
    Because the suspended goroutine is itself a GC root. It and everything its stack references stay reachable regardless of who still holds the cursor functions. Goroutines are only reclaimed when they return, and this one cannot return without being resumed.
  • Beyond the goroutine count, what else climbs when this leaks?
    Whatever the stranded sequences hold. Their deferred cleanup never runs, so file descriptors, network connections, scanner buffers and pooled database connections accumulate alongside the goroutines. That is why the symptom often presents first as heap growth or descriptor exhaustion rather than as a goroutine count.
  • What review rule catches this before it ships?
    Treat iter.Pull like os.Open: the stop function is a closer, it is never assigned to the blank identifier, and `defer stop()` goes on the line immediately after the pull, above any statement that can return. Two pulls means two defers, adjacent to their own pull.

A pull cursor is a waiter standing at your table mid-order. Finishing the order or telling them you are done sends them away; walking out silently leaves them standing there for the life of the restaurant.

saying these in an interview costs you the question

  • Assumes the GC collects a parked goroutine once the cursor is unreachable
  • Says every iter.Pull leaks unless stop is called, even when drained
  • Calls stop only on the error path
  • Assigns the stop function to the blank identifier
  • Blames the sequence author rather than the missing stop at the call site