少数派报告----Edward's Webblog

Some raw thought.

Download as .zip Download as .tar.gz View on GitHub
12 March 2026

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: