2023 年 6 月 3 日,在
golang-nuts询问了sync.Mutex源码注释中的 waiter 和饥饿模式。Ian Lance Taylor 参与回复,共有 6 封邮件。完整讨论:Can we further optimize the scheduling order of goroutines in sync.Mutex?。
Gosync.Mutex为什么有正常模式和饥饿模式?
第一次读 Go 的 Mutex 源码时,最吸引人的不是 CAS,而是这段很长的注释:
// Mutex can be in 2 modes of operations: normal and starvation.// In normal mode waiters are queued in FIFO order, but a woken up waiter// does not own the mutex and competes with new arriving goroutines over// the ownership.// ...// If a waiter fails to acquire the mutex for more than 1ms,// it switches mutex to the starvation mode.我当时有两个疑问。
第一,注释里的 waiter 指队头 goroutine,还是等待队列里的任意 goroutine?
第二,如果是某个等待者因为超过 1ms 触发了饥饿模式,为什么不让“触发饥饿的那一个”马上获得锁,而是继续按照队列顺序移交?
Ian Lance Taylor 的回复很短,却指出了我理解中的关键误区:waiter 指的是队头等待者;等待队列本身已经是 FIFO,队头天然就是等待时间最长的 goroutine。
要理解这句话,需要先理解 Mutex 为什么不从一开始就严格公平。
快路径:大多数 Lock 不会进入复杂逻辑
现代 Go 中,sync.Mutex的实现主体位于internal/sync/mutex.go。2023 年讨论时,相关实现和注释还直接位于sync/mutex.go;目录会随版本调整,但核心算法保持连续。
Mutex 内部有两个字段:
typeMutexstruct{stateint32semauint32}state的低位记录锁状态:
const(mutexLocked=1<<iotamutexWoken mutexStarving mutexWaiterShift=iota)没有竞争时,Lock的快路径只是一次 CAS:
ifatomic.CompareAndSwapInt32(&m.state,0,mutexLocked){return}这条路径很重要。Mutex 是基础原语,绝大多数未竞争或低竞争加锁都应该尽量短,才能被内联并保持低开销。
只有 CAS 失败,代码才进入lockSlow,处理自旋、排队、唤醒和饥饿模式。
正常模式:队列是 FIFO,但锁不是严格 FIFO
正常模式下,等待者按 FIFO 排队。
这句话容易让人误以为:解锁后,队头一定获得锁。实际不是。
解锁 goroutine 会唤醒队头,但被唤醒只意味着它从睡眠状态变成可运行状态。它还需要被调度到 CPU 上,再次尝试 CAS。
这段时间里,新来的 goroutine 可能已经在 CPU 上运行:
持锁者 Unlock ↓ 唤醒队头 waiter ↓ waiter 等待调度 与此同时: 新 goroutine 已经在 CPU 上执行 Lock ↓ 先一步 CAS 成功这类行为通常被称为 barging 或抢占式获取。新到达者越过等待者拿走了锁。
从公平性看,它不太好;从吞吐量看,却可能更快。新 goroutine 已在运行,不需要额外的唤醒和上下文切换。缓存也可能仍然是热的。
如果强制每次都把锁交给沉睡的队头,就会增加调度延迟。高频短临界区下,CPU 可能花更多时间在线程切换,而不是执行实际工作。
所以正常模式选择的是:
允许一定程度的不公平,换取更高吞吐量。
被唤醒后又失败,会发生什么
队头 waiter 被唤醒,却被新来的 goroutine 抢走锁后,会重新进入等待。
为了避免它回到队尾、再等完整一轮,源码会把这种已经被唤醒过但失败的 waiter 放到队列前部。
这样设计仍然不是严格公平,但会给老等待者更高优先级。
如果竞争一直很激烈,新 goroutine 连续抢锁,某个 waiter 可能多次失败。于是需要第二种模式。
1ms 后进入饥饿模式
源码使用:
starvationThresholdNs=1e6也就是 1 毫秒。
当队头等待者等待超过这个阈值,Mutex 会进入 starvation mode。
这里的 1ms 不是 API 承诺,也不意味着业务代码可以依赖“最多等 1ms”。它是当前实现用于平衡吞吐与尾延迟的经验阈值,未来版本可以调整。
进入饥饿模式后,规则发生变化:
- 解锁 goroutine 把锁的所有权直接交给队头;
- 新来的 goroutine 即使看到锁表面上未被占用,也不尝试抢锁;
- 新来的 goroutine 不自旋,直接排到队尾;
- 队列按顺序向前推进。
这时,锁更接近严格 FIFO。
为什么不是“谁触发饥饿,谁插队”
我当时设想:如果队列中某个 goroutine 等了很久,能否记录每个人的等待时间,再选择最久者?
Ian 指出,FIFO 已经隐含了等待时间顺序。
假设队列是:
G1 → G2 → G3 → G4在正常入队规则下,G1 最早进入队列,也就是等待最久的 goroutine。没有必要再维护一个按时间排序的优先队列。
额外记录等待时间会带来:
- 更多状态;
- 更复杂的更新;
- 更高的原子操作成本;
- 更难证明的并发正确性;
- 在基础同步原语热路径上的持续开销。
而结果仍然大概率是选择队头。
至于“触发饥饿的 goroutine”,正常情况下它就是当前被唤醒的队头。如果不是队头,就说明它还没有到获取锁的时机;让它越过前面的等待者,反而破坏了已经存在的公平顺序。
饥饿模式为什么还要退出
如果公平模式能避免饿死,为什么不一直保持?
因为源码注释明确指出:正常模式性能明显更好。一个 goroutine 甚至可能在有等待者的情况下连续几次获取 Mutex,这对尾延迟不公平,却可能减少上下文切换,提高整体吞吐。
饥饿模式的退出条件是,获得锁的 waiter 发现:
- 它已经是队列最后一个等待者;或者
- 它实际等待时间少于 1ms。
满足条件后,Mutex 回到正常模式。
这说明 starvation mode 不是“更正确的模式”,而是一种故障保护:在出现病态尾延迟时临时增强公平性,情况缓解后立即恢复高性能策略。
自旋在这里扮演什么角色
低竞争、临界区极短时,立即让 goroutine 睡眠可能得不偿失。锁很可能马上释放,当前 goroutine 在 CPU 上自旋几次就能获得它。
Go 运行时会结合多个条件决定是否主动自旋,例如:
- 当前机器是否有多个逻辑处理器;
- 当前 goroutine 是否有机会继续运行;
- 自旋次数是否过多;
- Mutex 是否处于饥饿模式。
正常模式允许有限自旋。饥饿模式下,新来者不再自旋,因为锁的所有权已经按队列直接移交,继续竞争只会破坏公平性。
自旋同样体现了吞吐与资源消耗的平衡:
- 临界区很短时,自旋避免调度;
- 临界区较长时,自旋浪费 CPU;
- 竞争过强时,公平移交降低尾部等待。
Mutex 的四个状态维度
把源码简化后,可以把state理解为四类信息:
locked 当前是否被持有 woken 是否已有 waiter 被唤醒,避免重复唤醒 starving 是否处于饥饿模式 waiters 等待队列中的 goroutine 数量这些信息被压在一个int32中,通过 CAS 更新。
为什么不使用更清晰的多个字段?因为多个独立字段会引入中间状态:一个 goroutine更新了 locked,却还没更新 waiter 数;另一个 goroutine可能观察到不一致组合。单个状态字让关键转换能通过一次 CAS 原子完成。
代价是源码难读。lockSlow里大量位运算和循环,并不是为了炫技,而是在一个极小状态空间里编码并发协议。
公平锁不一定更好
业务开发中,“公平”听上去天然优于“不公平”。但锁的评价指标至少有三个:
- 平均吞吐量;
- 平均等待时间;
- P99、P999 等尾延迟。
严格 FIFO 往往改善尾延迟,却可能降低吞吐。允许 barging 则可能让平均性能更好,但极端情况下会让某个 waiter 长期失败。
Go Mutex 没有选择其中一端,而是动态切换:
正常竞争 → 允许抢锁和有限自旋 → 优先吞吐 等待超过阈值 → 直接移交 → 优先避免饿死和病态尾延迟 队列恢复正常 → 回到正常模式这种设计比“公平锁”或“非公平锁”的标签更接近真实运行负载。
业务代码需要知道这些吗
大多数时候,不需要根据 Mutex 内部模式编写业务逻辑。
但理解它能解释几类现象。
锁没有严格 FIFO 保证
不能假设先调用Lock的 goroutine 必然先进入临界区。sync.Mutex的公开 API 没有承诺公平顺序。
锁竞争会出现尾延迟
即使平均等待很低,正常模式下也可能有人连续抢锁。运行时用饥饿模式抑制病态情况,但业务仍应减少过长临界区。
短临界区和长临界区表现不同
Mutex 的自旋和抢锁策略适合短临界区。把网络 I/O、磁盘操作或大计算放在锁内,会让算法假设失效。
不要围绕 1ms 写逻辑
1ms 是实现细节,不是锁的服务等级协议。需要超时控制时,应在业务层使用 Context、channel 或其他机制。
怎样减少 Mutex 竞争
第一,缩小临界区:
mu.Lock()v:=cache[key]mu.Unlock()// 在锁外做昂贵处理use(v)第二,不要持锁执行未知回调:
mu.Lock()callback()// callback 可能很慢、重入甚至再次加锁mu.Unlock()第三,先用 mutex profile 定位,而不是猜测:
runtime.SetMutexProfileFraction(1)然后通过 pprof 查看竞争来源。
第四,只有数据和访问模式确实允许时,才考虑分片、原子变量或无锁结构。复杂并发结构的正确性成本,通常比一次 Mutex 更昂贵。
结论
Go Mutex 的两种模式解决的是一对无法彻底消除的冲突:
- 正常模式允许新 goroutine 抢锁,提高吞吐;
- 饥饿模式按队列直接移交,防止等待者长期失败;
- FIFO 队列已经表达等待时间先后,无需另建按时长排序的结构;
- 1ms 是触发保护的实现阈值,而不是公开语义;
- 当竞争恢复正常,Mutex 会主动回到性能更好的模式。
我最初的问题是“能不能进一步优化等待顺序”。读完回复和源码后才发现,很多看似额外的信息已经隐含在队列结构中,而真正困难的不是找出最公平的顺序,是让公平性只在需要时付费。
这也是sync.Mutex源码最值得学习的地方:它不是简单的一把锁,而是一套围绕 CPU 调度、缓存、自旋、唤醒和尾延迟不断折中的小型状态机。
参考链接
- golang-nuts:完整讨论
- 当前 Go 源码:internal/sync/mutex.go
- sync.Mutex 文档
- Go Memory Model
- runtime.SetMutexProfileFraction
- Go Blog:Profiling Go Programs