bigywater.tech
Go · 31

iter.Pull:把推式迭代器变成拉式,代价是什么

推式迭代器(range-over-func)用起来方便,但有时需要“拉式”:一次取一个,在两个序列之间交替。iter.Pull 能把推式变成拉式,代价是什么?这篇用实验回答:语义、panic、泄漏、耗时,以及它在运行时里是怎么实现的。

1 → 1001
创建 1000 个 Pull、各取一个、不 stop 之后的 goroutine 数
21.6 倍
1000 个元素,Pull 逐个 next 与 for range 的耗时比(43980 ns vs 2040 ns)
207 ns
创建一个 Pull、取一个元素并 stop 的耗时

语义

实测 · 本机执行输出
  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,逐个比较。

panic

实测 · 本机执行输出
  recover 收到:迭代器自己出错了

迭代器里的 panic 会传到调用 next 的那个 goroutine,所以可以在调用处 recover。这个实验只验证了最简单的一种情形,多个 Pull 嵌套时的传播细节我没有测。

goroutine 泄漏

实测 · 本机执行输出
  创建 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 往返快得多,但注释之外的实现细节我没有逐行读。

分步演示

iter.Pull
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。语义与 panic 用真实程序验证;goroutine 数来自 runtime.NumGoroutine,堆来自 runtime.ReadMemStats(先 GC);基准测试 7 轮取中位数;读过的源码:iter/iter.go 里 Pull 对 newcoro / coroswitch 的使用与 runtime/coro.go 开头的注释
    • 没有验证:Pull2 的行为(与 Pull 对称,没有单独运行)、从另一个 goroutine 调用 next 的限制、与 LockOSThread 的交互(coro.go 里有相关字段)、嵌套 Pull 的 panic 传播、与 channel 实现同样功能时的耗时对比
    京ICP备2026067502号-1京公网安备11010502063487号