iter.Pull needs its stop¶
Level: 301 · anyone who needs next() rather than a range — two sequences in step, a parser with lookahead
One line: iter.Pull turns a push iterator into next and stop: nothing runs until the first next, each next runs the producer to its next yield, and the producer's deferred cleanup runs either when it finishes on its own or when stop is called — so a caller that stops early without calling stop leaves the producer suspended for good, which is why the rule is defer stop() on the line after iter.Pull.
Orders yields three orders, says what it is doing at each step, and defers a cleanup line. main pulls two orders and calls stop; pulls a fresh sequence to the end; calls stop on a third before any next; and pulls from a producer that panics after its first value.
Verified output of iter_pull_needs_its_stop_go.go — regenerated by tools/run_examples.py, never hand-typed.
-- pull two, then stop
main: iter.Pull returned; nothing has run yet
producer: yielding order-1
main: next -> "order-1", true
producer: yielding order-2
main: next -> "order-2", true
main: calling stop
producer: yield returned false
producer: deferred cleanup ran
main: stop returned
main: next after stop -> "", false
main: a second stop is allowed and does nothing
-- pull to the end
producer: yielding order-1
main: next -> "order-1", true
producer: yielding order-2
main: next -> "order-2", true
producer: yielding order-3
main: next -> "order-3", true
producer: finished on its own
producer: deferred cleanup ran
main: next -> "", false
main: stop after the end is allowed and does nothing
-- stop before any next
main: stop returned; the producer never started
main: next -> "", false
-- a panic in the producer comes out of next
main: next -> 1, true
main: recovered: sensor unplugged
Reading the output¶
iter.Pull returned; nothing has run yet. The producer's first line, yielding order-1, comes with the first next, not with Pull. Pull sets up the machinery and returns.
Each next runs the producer to its next yield, and no further. yielding order-1, then next -> "order-1", true; then nothing until the next next. Between calls the producer is suspended inside its call to yield, with its stack intact. That is a coroutine: Pull runs the producer on a second goroutine and switches between the two with runtime.coroswitch ↗, so that only one of them runs at any time — Russ Cox's Coroutines for Go ↗ is the design, and it measures the switch at a few tens of nanoseconds, about ten times cheaper than doing the same with channels. (Not machine-checked here.)
stop is what makes yield return false. calling stop, then yield returned false, then deferred cleanup ran, then stop returned. The producer was suspended in yield("order-2"); stop resumed it with a false, it returned, its defer ran, and stop returned only after that. The documentation ↗ makes this the caller's duty: stop must be called when the caller is no longer interested in values and next has not yet reported the end.
After stop, next returns "", false; a second stop does nothing. Both are documented as valid. The zero value and false is the same signal as reaching the end.
Pulled to the end, the cleanup ran before the final next returned. finished on its own, deferred cleanup ran, then next -> "", false: the producer returned, and that is when next reports the end. stop afterwards is a no-op.
stop before any next: the producer never started. No yielding, no cleanup line — there was nothing to clean up, because the producer's function was never entered.
A panic in the producer comes out of next. The producer yielded 1, then panicked on its second step; the second next panicked with the same value, and the caller's recover got sensor unplugged. The documentation: if the iterator panics during a call to next or stop, that call panics with the same value.
iter_pull_needs_its_stop_go.go
iter_pull_needs_its_stop_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// iter.Pull turns a push iterator into next and stop. The iterator starts on
// the first next, runs one step per call, and finishes -- its deferred
// cleanup included -- either when it runs out on its own or when stop is
// called. After stop, next returns the zero value and false.
//
// go build iter_pull_needs_its_stop_go.go && ./iter_pull_needs_its_stop_go
package main
import (
"fmt"
"iter"
)
// Orders yields three orders and says what it is doing at each step.
func Orders(yield func(string) bool) {
defer fmt.Println("producer: deferred cleanup ran")
for _, o := range []string{"order-1", "order-2", "order-3"} {
fmt.Println("producer: yielding", o)
if !yield(o) {
fmt.Println("producer: yield returned false")
return
}
}
fmt.Println("producer: finished on its own")
}
func main() {
fmt.Println("-- pull two, then stop")
next, stop := iter.Pull(iter.Seq[string](Orders))
fmt.Println("main: iter.Pull returned; nothing has run yet")
order, ok := next()
fmt.Printf("main: next -> %q, %t\n", order, ok)
order, ok = next()
fmt.Printf("main: next -> %q, %t\n", order, ok)
fmt.Println("main: calling stop")
stop()
fmt.Println("main: stop returned")
order, ok = next()
fmt.Printf("main: next after stop -> %q, %t\n", order, ok)
stop()
fmt.Println("main: a second stop is allowed and does nothing")
fmt.Println("-- pull to the end")
next, stop = iter.Pull(iter.Seq[string](Orders))
for {
order, ok := next()
fmt.Printf("main: next -> %q, %t\n", order, ok)
if !ok {
break
}
}
stop()
fmt.Println("main: stop after the end is allowed and does nothing")
fmt.Println("-- stop before any next")
next, stop = iter.Pull(iter.Seq[string](Orders))
stop()
fmt.Println("main: stop returned; the producer never started")
order, ok = next()
fmt.Printf("main: next -> %q, %t\n", order, ok)
fmt.Println("-- a panic in the producer comes out of next")
func() {
defer func() { fmt.Println("main: recovered:", recover()) }()
next, stop := iter.Pull(iter.Seq[int](func(yield func(int) bool) {
yield(1)
panic("sensor unplugged")
}))
defer stop()
n, ok := next()
fmt.Printf("main: next -> %d, %t\n", n, ok)
n, ok = next()
fmt.Printf("main: next -> %d, %t\n", n, ok)
}()
}
What a forgotten stop leaves behind¶
The producer runs on a goroutine of its own, which the runtime lists in state [coroutine]. With stop it is gone; without stop it stays, and a garbage collection does not remove it, because the next and stop closures still refer to it. The goroutine numbers change from run to run, so this is a demo, not an example:
before: 1 goroutine 1 [running]:
after one next: 2 goroutine 1 [running]: | goroutine 7 [coroutine]:
producer: deferred cleanup ran
after stop: 1 goroutine 1 [running]:
forgot stop, GC ran: 2 goroutine 1 [running]: | goroutine 8 [coroutine]:
Run go run demo/forgotten_stop.go from this folder to see it on your machine. A server that pulls a few values per request and forgets stop grows a [coroutine] goroutine per request, each holding the producer's stack and whatever it opened — the same slow leak as A leaked goroutine never ends, with no channel in sight.
What to do¶
next, stop := iter.Pull(seq)anddefer stop()on the next line. Every time. The documentation's own example does it, and there is no situation in which thedeferis wrong: after the end it is a no-op.- Reach for
Pullonly when arangewill not do: walking two sequences in step, peeking at the next element before deciding, handing a sequence to code that wants a cursor. For everything else arangeis simpler and has nothing to forget. - Do not call
nextorstopfrom two goroutines at once. The documentation calls it an error; the coroutine switch assumes one caller. - Expect the producer's cleanup at
stop, not at your lastnext, when you stop early — a file the producer opened is open until then.
Practice¶
Write Zip(a iter.Seq[A], b iter.Seq[B]) iter.Seq2[A, B], which pairs elements of two sequences and ends with the shorter — the thing a push iterator cannot do on its own, because two producers cannot both be in charge of the loop. Range over a and pull from b in step with it. Give each input a deferred cleanup line, make the pulled one the longer, and show that its cleanup ran because stop was called, after the ranged one finished on its own.
Solution
iter_pull_needs_its_stop_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata: Zip two sequences, which a push iterator cannot do on its own. One
// side is ranged over; the other is pulled in step with it, and the deferred
// stop is what lets the pulled side finish when it is the longer one.
//
// go build iter_pull_needs_its_stop_kata_go.go && ./iter_pull_needs_its_stop_kata_go
package main
import (
"fmt"
"iter"
)
// Named returns an iterator over values that reports when it finishes.
func Named[V any](name string, values ...V) iter.Seq[V] {
return func(yield func(V) bool) {
defer fmt.Printf("%s: cleanup ran\n", name)
for _, v := range values {
if !yield(v) {
return
}
}
}
}
// Zip pairs the elements of a and b and ends with the shorter of the two.
func Zip[A, B any](a iter.Seq[A], b iter.Seq[B]) iter.Seq2[A, B] {
return func(yield func(A, B) bool) {
next, stop := iter.Pull(b)
defer func() {
fmt.Println("zip: calling stop")
stop()
}()
for va := range a {
vb, ok := next()
if !ok || !yield(va, vb) {
return
}
}
}
}
func main() {
sensors := Named("sensors", "hall", "garage")
readings := Named("readings", 21, 19, 23)
for name, r := range Zip(sensors, readings) {
fmt.Printf("%s = %d\n", name, r)
}
fmt.Println("main: the loop ended")
}
Verified output of iter_pull_needs_its_stop_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.
In other languages¶
- The Rust library's
zipandenumerate↗ and ImplementingIterator↗ — every Rust iterator is a pull iterator,next()is the primitive, sozipis one adapter call and there is nostop: dropping the iterator runs itsDrop, with no second goroutine involved. Go chose push as the primitive and pays for pull with a coroutine. - Python's generator is a pull iterator from the start:
next(gen)isnext, andgen.close()↗ isstop— with the same rule that a generator left open keeps its frame until it is collected. - The Concurrency library's Coroutine ↗ and Goroutine ↗ —
iter.Pull's producer is both: a goroutine to the runtime's list, a coroutine in how it is scheduled, since only one of the pair ever runs.
Sources¶
iter.Pull↗ and the package's Pulling Values ↗ section — "clients … must call stop".- Russ Cox, Coroutines for Go ↗, 17 July 2023 — the design of
coro.Pull, and the switch cost measured against channels. src/iter/iter.goat go1.25.5 ↗ —Pullonnewcoroandcoroswitch;src/runtime/coro.go↗ — the runtime side; the[coroutine]wait reason iswaitReasonCoroutineinsrc/runtime/runtime2.go↗.- Ian Lance Taylor, Range Over Function Types ↗, the Go blog, 20 August 2024 — the "Pull iterators" section.
- Go Wiki: Rangefunc Experiment ↗ — "Why are the semantics not exactly like if the iterator function ran in a coroutine or goroutine?"
- This library's A leaked goroutine never ends.