跳转至

一把锁罩住一切: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          // 一把锁,罩住上面所有
}

环形队列的影子还在:bufsendxrecvx 就是 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 逐项检查,三项全崩:

  1. 字段写者不唯一。缓冲 channel 上,同一个 goroutine 可以又收又发:sendx 的写者是"所有发送者",recvx 是"所有接收者",两个集合可以重叠;closed 谁都能关。单写多读无从谈起
  2. 等待队列的生死交接需要互斥。发送者把自己挂上 sendq 的同时,接收者可能正在摘下它、拷数据、唤醒它——链表操作和 goroutine 生命周期变更必须互斥,这不是一次 CAS 能覆盖的状态转移
  3. 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

评论