推式迭代器(range-over-func)用起来方便,但有时需要“拉式”:一次取一个,在两个序列之间交替。iter.Pull 能把推式变成拉式,代价是什么?这篇用实验回答:语义、panic、泄漏、耗时,以及它在运行时里是怎么实现的。
next 与 stop 的交互(迭代器每一步都会打印):
[迭代器] 准备 yield(0)
第 1 次 next() → (0, true)
[迭代器] 准备 yield(1)
第 2 次 next() → (1, true)
[迭代器] 自然结束
[迭代器] 清理(defer)
第 3 次 next() → (0, false)
第 4 次 next() → (0, false)
stop() 连续调用两次:没有问题
[迭代器] 准备 yield(0)
只取了一个值 (0, true) 就提前 stop():
[迭代器] yield(0) 返回 false
[迭代器] 清理(defer)
stop 之后再 next() → (0, false)
用 Pull 合并两个有序序列:[1 2 3 4 9 10 11]next 每调用一次,迭代器才执行到下一个 yield;迭代器的 defer 在它结束(取完或被 stop)时才执行。stop 让 yield 返回 false,迭代器走正常的提前退出路径,并且可以重复调用。合并两个有序序列是最典型的用法——两个 Pull,逐个比较。
recover 收到:迭代器自己出错了
迭代器里的 panic 会传到调用 next 的那个 goroutine,所以可以在调用处 recover。这个实验只验证了最简单的一种情形,多个 Pull 嵌套时的传播细节我没有测。
创建 1000 个 Pull 并各取 1 个值、都不 stop:NumGoroutine 1 → 1001 全部 stop() 之后:NumGoroutine = 1 再创建 10000 个 Pull(各取 1 个、不 stop、丢弃引用):GC 之后堆 0.66 → 6.94 MiB;NumGoroutine = 10001
实验显示 iter.Pull 每调用一次就多一个 goroutine,直到取完或 stop。不 stop 又不取完,goroutine 会一直存在:丢弃 next/stop 的引用也回收不了它(10000 个之后 NumGoroutine 还是 10001,堆里多了约 6 MiB)。所以总要 defer stop()。
for range seq(推式,1000 个元素) 中位 2040.39 ns/op(最小 2016.59 / 最大 2146.84) iter.Pull + next()(拉式,1000 个元素) 中位 43979.79 ns/op(最小 43599.63 / 最大 44858.02) 只创建并取 1 个元素的 Pull(创建 + 1 次 next + stop) 中位 207.49 ns/op(最小 196.86 / 最大 211.12) AllocsPerRun:SumRange 2 次;SumPull(1000 个元素)6 次
每次 next 要在两个 goroutine 之间切换一次,实测每个元素约 44 ns,而 for range 不跨 goroutine,每个元素约 2 ns。需要“拉”的时候才用 Pull;能用 range 的地方不要为了写法换成 Pull。这些是 arm64 上的数字。
iter/iter.go:233://go:linknamestd newcoro runtime.newcoro
iter/iter.go:234:func newcoro(func(*coro)) *coro
iter/iter.go:236://go:linknamestd coroswitch runtime.coroswitch
iter/iter.go:237:func coroswitch(*coro)
runtime/coro.go 开头的注释:
// A coro represents extra concurrency without extra parallelism,
// as would be needed for a coroutine implementation.
// The coro does not represent a specific coroutine, only the ability
// to do coroutine-style control transfers.
// It can be thought of as like a special channel that always has
// a goroutine blocked on it. If another goroutine calls coroswitch(c),
// the caller becomes the goroutine blocked in c, and the goroutine
// formerly blocked in c starts running.
// These switches continue until a call to coroexit(c),
// which ends the use of the coro by releasing the blocked
// goroutine in c and exiting the current goroutine.源码里 iter.Pull 通过 newcoro 与 coroswitch(runtime/coro.go)实现。coro 的注释把它描述成“只有并发、没有并行”:切换时调用者与被阻塞的 goroutine 直接对调,不经过通常的调度循环。这解释了为什么它比 channel 往返快得多,但注释之外的实现细节我没有逐行读。