bigywater.tech
Go · 81

channel 流水线:fan-out、fan-in 与取消

用 channel 串起来的流水线写起来简单,但有三个实际问题:消费者提前走了怎么办、并行之后顺序怎么办、缓冲区开多大。这篇用真实运行回答,并量并行 worker 数的收益。

+100 vs +0
消费者提前走掉时,无取消机制与有 cancel 的流水线新增的 goroutine 数
794 处
4 个 worker 并行后,2000 个元素的输出里出现逆序的位置
594 → 129 ns
级间 channel 容量从 0 到 256,每个元素的耗时

取消是必须的

实测 · 本机执行输出
  没有取消机制:100 条流水线各只读 3 个元素就丢弃 → goroutine 数 1 → 101
  读 3 个后 cancel():同样 100 条两级流水线 → goroutine 数 101 → 101(起点 101 里包含上一组已经泄漏的 100 个,这一组没有新增)

每一级发送都只用 out <- v,消费者读了 3 个就走,上游 goroutine 永远卡在发送上,100 条流水线泄漏 100 个。每一级把发送和 ctx.Done() 放进同一个 select,cancel() 之后全部退出。

fan-out 会乱序

实测 · 本机执行输出
  4 个 worker 并行处理 0..1999:收到 2000 个,元素齐全=true,输出里“比前一个小”的位置有 794 处(顺序被打乱)

4 个 worker 并行处理、再合并,2000 个元素全部收到,但顺序不再是输入的顺序(794 处逆序)。需要顺序就要带序号并在合并后排序。

worker 数的收益

实测 · 本机执行输出
  第二级 worker 数 = 1:处理 2 万个元素用时 596 ms
  第二级 worker 数 = 2:处理 2 万个元素用时 320 ms
  第二级 worker 数 = 4:处理 2 万个元素用时 176 ms
  第二级 worker 数 = 8:处理 2 万个元素用时 114 ms

瓶颈在第二级且是 CPU 计算时,1、2、4、8 个 worker 分别用 596、320、176、114 ms。到 8 个时没有 8 倍的加速,我推测是生成和合并阶段的 goroutine 也在抢 CPU,没有验证。

缓冲区

实测 · 本机执行输出
  三级流水线,每级 channel 容量   0:100 万个元素    594 ms, 594 ns/个
  三级流水线,每级 channel 容量   1:100 万个元素    419 ms, 419 ns/个
  三级流水线,每级 channel 容量  16:100 万个元素    229 ms, 229 ns/个
  三级流水线,每级 channel 容量 256:100 万个元素    129 ms, 129 ns/个

三级几乎不做事的流水线,容量 0 时每个元素 594 ns,容量 256 时 129 ns,因为级间交接不用每次都等对方。但缓冲区只是在摊薄同步成本,并不改变整体的吞吐上限,而且会让背压来得更晚。

分步演示

channel 流水线
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。泄漏实验用 runtime.NumGoroutine,读完 3 个元素后等 50 ms;乱序实验的处理时间由 time.Sleep 制造;并行实验的重负载函数是 2 万次乘加,GOMAXPROCS=8;缓冲实验三个 goroutine 串联,每个设置单次测量
    • 没有验证:有序 fan-in 的写法及其代价、errgroup 与流水线结合、channel 关闭时机的其它错误模式、更多级数时的开销、泄漏 goroutine 的内存占用(单个约 2 KB 的说法没有在本文测量)
    京ICP备2026067502号-1京公网安备11010502063487号