Channel为什么慢
by
Channel 为什么会慢
最近在压测一个服务,goroutine 跑到几千个的时候,CPU 打满了但吞吐量却没有线性增长。排查了一圈,最后发现问题出在一个被大量 goroutine 共享的 channel 上。
这让我重新想了一遍 channel 的内部机制。
Go 的 channel 用起来很自然,以至于很多人会不假思索地把它当成并发场景下的万能胶。但 channel 不是魔法,它的底层是一把锁:hchan.lock。每次发送和接收,都要先拿锁,操作完再释放。单个 goroutine 访问时几乎感觉不到这个成本,但一旦并发量上来,大量 goroutine 同时争这一把锁,情况就变了——goroutine 开始排队,CPU cache line 在核间反复失效,调度器跟着频繁切换。程序看起来还在跑,但吞吐量已经被这把锁卡住了。
锁竞争之外还有阻塞的成本。channel 满或空时,goroutine 会被挂起,runtime 为它分配一个 sudog 结构,挂进发送队列或接收队列,等条件满足再唤醒重新调度。这个过程单次很轻,但如果阻塞极其频繁,调度器的压力就会悄悄累积,表现出来就是延迟莫名升高。
还有一个更容易被忽视的地方:channel 传递的是值的拷贝。发送一个 int 无所谓,但如果结构体里带着几 KB 的数据,每次发送都是一次完整的内存复制。高频场景下,这个开销加起来相当可观。
想清楚这几点之后,优化方向就比较自然了。
最直接的是把一个 channel 拆成多个。一个 channel 是单个热点,并发越高争用越激烈。拆分之后流量分散,锁竞争自然下降:
const N = 8
var chans [N]chan Task
func route(id int, task Task) {
chans[id%N] <- task
}
另一个思路是不要让大量 goroutine 同时去轰一个 channel。用固定数量的 worker 来消费,生产端只管往 channel 里丢,goroutine 的总数稳定下来之后,调度压力和锁竞争都会好很多。
发送大对象的时候,传指针而不是值。这个改动一行代码,但在高频场景下效果立竿见影。
有些场景其实根本不需要 channel。高频计数用 sync/atomic 就够了,性能远高于任何基于锁的方案。某些高吞吐队列,mutex + ringbuffer 的组合甚至比 channel 更快,因为它绕过了 sudog 分配和调度介入这两层开销。
channel 很适合表达并发结构,用来传递控制流、组织 pipeline、协调 goroutine 的生命周期。但如果把它当成搬运数据的主力通道,在高并发下迟早会遇到这些问题。把它用在它擅长的地方,其他的交给更合适的工具。
tags: