一把锁罩住一切:Go channel 为什么不追求无锁
上一篇 结尾预告了这个问题:07-1 里说过,Go 标准库的 channel 干脆用一把 hchan.lock 罩住整个通道,收发全锁——无锁系列写了三篇,标准库为什么反着来?
答案不神秘,但值得拆到源码级:channel 不是队列,是同步原语。它比环形队列多操心一个维度——goroutine 的睡眠与唤醒。这个维度一进来,"每字段唯一写者"的前提就保不住了。
1. hchan 长什么样
runtime/chan.go 里的结构体,删掉注释:
type hchan struct {
qcount uint // 队列里的元素数
dataqsiz uint // 环形缓冲容量
buf unsafe.Pointer // 环形缓冲
elemsize uint16
closed uint32
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 等待接收的 goroutine 链表
sendq waitq // 等待发送的 goroutine 链表
lock mutex // 一把锁,罩住上面所有
}
环形队列的影子还在:buf、sendx、recvx 就是 SPSC 的 buf/head/tail。多出来的是两个等待队列和 closed——它们就是"同步原语"的部分。锁的管辖范围,源码注释写得直白:
runtime/chan.go
lock protects all fields in hchan, as well as several fields in sudogs redirected to this channel.
翻译:这把锁不只保数据,还保 挂在等待队列上的 goroutine 的状态。
2. 无锁为什么在这儿活不下去
第一篇 的无锁公式是"每字段唯一写者"。对着 hchan 逐项检查,三项全崩:
- 字段写者不唯一。缓冲 channel 上,同一个 goroutine 可以又收又发:
sendx的写者是"所有发送者",recvx是"所有接收者",两个集合可以重叠;closed谁都能关。单写多读无从谈起 - 等待队列的生死交接需要互斥。发送者把自己挂上
sendq的同时,接收者可能正在摘下它、拷数据、唤醒它——链表操作和 goroutine 生命周期变更必须互斥,这不是一次 CAS 能覆盖的状态转移 - select 要整体原子。
select探测 n 个 channel,要么全看到一致状态要么重试。锁的方案是按地址排序依次加锁(sellock),天然可组合;无锁的方案要为 n 路通道设计一套多对象 CAS 协议——Disruptor 花了大力气才做到两路
3. 这把锁拿得不冤:快路径三连
上锁不等于慢。channel 的热路径做了三层优化,无竞争时你几乎只付一次 CAS:
第一层,锁本身快。 无竞争时 runtime.mutex 的加锁就是一条原子 CAS,和 sync.Mutex 的 fast path 同级,二十纳秒上下。
第二层,直接发送。 发送时如果 recvq 里已有等待的接收者,数据 不进环形缓冲,直接从发送者栈拷到接收者栈,然后唤醒对方。源码注释原话:
runtime/chan.go
Found a waiting receiver. We pass the value we want to send directly to the receiver, bypassing the channel buffer (if any).
一次拷贝代替两次(入 buf + 出 buf),还省一次索引推进。接收侧对称地有直接接收。
第三层,冷热路径分离。 关闭、panic、唤醒全部等待者这些慢逻辑全部集中到低频路径;收发快路径上只做最小判断。慢的让给冷路径,快的才能真的快。
4. 挂起的姿势:持锁入睡
最精妙的一处是 挂起与解锁的原子交接。发送者在缓冲满时把自己挂上 sendq,然后入睡——runtime 把"挂起这个 goroutine"和"释放通道锁"打包成一次不可分割的交接,中间不存在"已解锁但还没睡着"的窗口。
为什么这个窗口致命:假如拆成两步——先解锁再睡——中间有人唤醒我,唤醒信号落在"还没睡"的缝隙里就丢了;先睡再解锁,锁永远没人放。这正是无锁算法里最难写对的部件,锁的版本一个函数回调就搞定。
反过来,唤醒方(接收者)摘下 sendq 队头、拷数据、goready,全程持锁——所以 sudog 的字段才被那把锁保护。锁在这里保护的不是内存的字节,是两个 goroutine 的交接协议本身。
5. 对比与选型
| 无锁 SPSC/MPMC | channel | |
|---|---|---|
| 快路径成本 | 几条普通指令(x86 上屏障免费) | 一次锁 CAS + 少量判断 |
| 阻塞成本 | 自旋(Gosched)或返回 false | park/unpark,调度器接管 |
| 语义 | 固定容量、无关闭、无 select | 关闭、select、阻塞唤醒一体 |
| 状态前提 | 每字段唯一写者 | 不需要,锁兜底 |
| 适用 | 极致吞吐热路径、禁止阻塞的场景 | 一切默认场景 |
贵的从来不是锁,是 阻塞路径上的睡眠与唤醒。无锁队列没有解决这个问题,只是把它甩给了调用方——要么自旋烧 CPU,要么返回 false 让上层自己重试。
小结
无锁的前提是状态能拆成单写字段;channel 的状态拆不动——收发身份可重叠、等待者生死与数据纠缠、select 要求多对象原子。一把锁是能同时摆平这三件事的最简方案,省下的工程量全部投进了快路径。
一句话概括:标准库不是不会无锁,是算过账之后选了"锁 + 快路径"——正确性和可组合性是特性,微优化是选项。下一个问题顺理成章:阻塞路径的睡眠与唤醒到底多贵?下一篇 把"等"的成本从自旋到休眠排成完整谱系。
最后更新:2026-09-08