跳转至

从自旋到休眠:「等」的成本谱系

07-1 的 CAS 队列里有个循环:

for atomic.LoadInt64(&q.committed) != head {
    runtime.Gosched() // 排队交卷:等前面的生产者先发布
}

注释说 Gosched() 是"自旋 vs 休眠的妥协",但妥协在谱系的哪个位置、往哪边偏、什么时候该换档,一句注释装不下。这篇把"等"这件事从最贵到最便宜排成完整谱系——每一档让出什么、唤醒花多少、什么条件下选它。

1. 五档谱系

档位 机制 让出什么 唤醒成本 代表
1 纯自旋 for {} 什么都不让 零(下次循环立刻发现) 忙等
2 让步自旋 Gosched 时间片(仍就绪) 一次调度机会 Go 手写等待循环
3 混合自旋 先转 N 圈再挂起 前期零、后期切换 glibc adaptive mutex、JVM
4 内核等待 CPU(线程挂起) 两次系统调用 + 调度 futex、pthread mutex 慢路径
5 goroutine park 仅 g 的执行权(M 不睡) goready + 调度 channel、sync.Mutex 慢路径

从上往下:发现变化的延迟越来越高,CPU 占用越来越低,可容忍的等待时长越来越长。没有最好的档位,只有匹配的档位

2. 纯自旋的死亡陷阱

自旋的适用前提常被总结成"等待很短",但还有一条更致命的:推进你所等条件的人,得有 CPU 可用

在 Go 里这条能推出一个具体的活锁。goroutine 跑在 M 上,M 持有 P;纯自旋的 for {} 占满这个 P。如果你等的那个生产者恰好排在 同一个 P 的本地队列 里,它永远得不到执行——你在原地烧 CPU,等一个被你亲手挡在门外的人。不是死锁(别人不占你的锁),是活锁(大家都有进度,条件永远不达成)。

Gosched 的解法:把自己放回全局队列、交出 P,本地队列里的人才轮得到。这也是 07-1 选它而不是空循环的真实理由——让出的不是自己的延迟,是别人的进度

3. futex:快慢路径的分界

Linux 的 pthread mutex 是两段式的:

fast path: 用户态 CAS 抢锁,成功即走(零系统调用)
slow path: CAS 失败 → futex(WAIT) 系统调用 → 内核把线程挂到等待队列
唤醒:      释放者 → futex(WAKE) → 内核挑一个线程复活

精髓在 无竞争时完全不进内核,竞争才交系统调用的税。挂起加唤醒两次系统调用,微秒级——所以 glibc 提供 adaptive mutex:先自旋几十圈赌锁马上会释放,赌输了再进内核。

这就是档位 3 的来历:自旋和休眠不是二选一,可以 按预期等待时长动态换档。JVM 的自适应自旋是同一个思想:根据同一把锁历史上的持有时长,决定这次转几圈。

4. Go 的答案:M 不睡,g 睡

Go 把整个谱系下移了一档:runtime 的阻塞几乎从不在 OS 线程层面发生。

  • gopark 只挂起 goroutine——一个 g 结构加 KB 级的栈,不碰内核
  • 等待队列按资源组织:channel 的 sendq/recvq 直接挂在 hchan 上,sync.Mutex 的等待者走 semtable(信号量地址哈希到 251 个桶,每桶一棵 treap)
  • 唤醒是 goready:把 g 放回运行队列,零系统调用

对比档位 4:OS 线程挂起要过内核调度器,线程栈以 MB 计;goroutine 挂起就是改个状态字换个人,百纳秒量级。Go 的"廉价阻塞"让很多本来必须自旋的场景可以大方地 park——channel 收发干脆全走 park,上一篇 拆过它的挂起姿势。

代价是依赖:这套便宜建立在 runtime 调度器自己能跑的前提上,而调度器内部(runtime.mutex)兜底用的还是 futex——谱系的根始终扎在内核里。

5. 怎么选:三条判据

  1. 等多久:条件达成的时间对比一次上下文切换的成本(约 1μs 量级)。远小于它,自旋;远大于它,休眠;说不准,混合档或 Gosched
  2. 推进者缺不缺 CPU:你等的条件由别人推进,而别人可能和你在同一个核上排队——必须让步,否则回到 §2 的活锁
  3. 唤醒后要不要立刻跑:延迟敏感(中断路径、高频信号)偏自旋;吞吐优先(后台任务、批处理)偏休眠

Tip

三种货币不能混着花:自旋买 延迟,休眠买 吞吐,让步自旋买 别人的进度。Gosched 之所以叫"妥协",因为它同时小额支付了前两种,主要买的是第三种。

小结

谱系收进一句:等待的代价 = 让出单位的成本 × 唤醒频率。纯自旋让出最少、发现最快,但烧 CPU 且可能同核互锁;futex 让出最多、最省心,每次唤醒微秒级;Go 的 park 夹在中间——让出 g 不让出 M,把休眠的门槛砍到百纳秒,代价是依赖一个自己也要睡觉的调度器。

一句话概括:没有免费的等待,只有匹配的等待。无锁系列到此收束——下一篇换战场,回存储线:崩溃之后,WAL 和影子分页各自怎么把系统救回来。


最后更新:2026-09-08

评论