Go设计取舍之六: sync.Mutex正常模式与饥饿模式

2023 年 6 月 3 日,在 golang-nuts 询问了 sync.Mutex 源码注释中的 waiter 和饥饿模式。Ian Lance Taylor 参与回复,共有 6 封邮件。完整讨论:Can we further optimize the scheduling order of goroutines in sync.Mutex?

Go sync.Mutex 为什么有正常模式和饥饿模式?

第一次读 Go 的 Mutex 源码时,最吸引人的不是 CAS,而是这段很长的注释:

go 复制代码
// 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 内部有两个字段:

go 复制代码
type Mutex struct {
    state int32
    sema  uint32
}

state 的低位记录锁状态:

go 复制代码
const (
    mutexLocked = 1 << iota
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
)

没有竞争时,Lock 的快路径只是一次 CAS:

go 复制代码
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
}

这条路径很重要。Mutex 是基础原语,绝大多数未竞争或低竞争加锁都应该尽量短,才能被内联并保持低开销。

只有 CAS 失败,代码才进入 lockSlow,处理自旋、排队、唤醒和饥饿模式。

正常模式:队列是 FIFO,但锁不是严格 FIFO

正常模式下,等待者按 FIFO 排队。

这句话容易让人误以为:解锁后,队头一定获得锁。实际不是。

解锁 goroutine 会唤醒队头,但被唤醒只意味着它从睡眠状态变成可运行状态。它还需要被调度到 CPU 上,再次尝试 CAS。

这段时间里,新来的 goroutine 可能已经在 CPU 上运行:

text 复制代码
持锁者 Unlock
    ↓
唤醒队头 waiter
    ↓
waiter 等待调度

与此同时:
新 goroutine 已经在 CPU 上执行 Lock
    ↓
先一步 CAS 成功

这类行为通常被称为 barging 或抢占式获取。新到达者越过等待者拿走了锁。

从公平性看,它不太好;从吞吐量看,却可能更快。新 goroutine 已在运行,不需要额外的唤醒和上下文切换。缓存也可能仍然是热的。

如果强制每次都把锁交给沉睡的队头,就会增加调度延迟。高频短临界区下,CPU 可能花更多时间在线程切换,而不是执行实际工作。

所以正常模式选择的是:

允许一定程度的不公平,换取更高吞吐量。

被唤醒后又失败,会发生什么

队头 waiter 被唤醒,却被新来的 goroutine 抢走锁后,会重新进入等待。

为了避免它回到队尾、再等完整一轮,源码会把这种已经被唤醒过但失败的 waiter 放到队列前部。

这样设计仍然不是严格公平,但会给老等待者更高优先级。

如果竞争一直很激烈,新 goroutine 连续抢锁,某个 waiter 可能多次失败。于是需要第二种模式。

1ms 后进入饥饿模式

源码使用:

go 复制代码
starvationThresholdNs = 1e6

也就是 1 毫秒。

当队头等待者等待超过这个阈值,Mutex 会进入 starvation mode。

这里的 1ms 不是 API 承诺,也不意味着业务代码可以依赖"最多等 1ms"。它是当前实现用于平衡吞吐与尾延迟的经验阈值,未来版本可以调整。

进入饥饿模式后,规则发生变化:

  1. 解锁 goroutine 把锁的所有权直接交给队头;
  2. 新来的 goroutine 即使看到锁表面上未被占用,也不尝试抢锁;
  3. 新来的 goroutine 不自旋,直接排到队尾;
  4. 队列按顺序向前推进。

这时,锁更接近严格 FIFO。

为什么不是"谁触发饥饿,谁插队"

我当时设想:如果队列中某个 goroutine 等了很久,能否记录每个人的等待时间,再选择最久者?

Ian 指出,FIFO 已经隐含了等待时间顺序。

假设队列是:

text 复制代码
G1 → G2 → G3 → G4

在正常入队规则下,G1 最早进入队列,也就是等待最久的 goroutine。没有必要再维护一个按时间排序的优先队列。

额外记录等待时间会带来:

  • 更多状态;
  • 更复杂的更新;
  • 更高的原子操作成本;
  • 更难证明的并发正确性;
  • 在基础同步原语热路径上的持续开销。

而结果仍然大概率是选择队头。

至于"触发饥饿的 goroutine",正常情况下它就是当前被唤醒的队头。如果不是队头,就说明它还没有到获取锁的时机;让它越过前面的等待者,反而破坏了已经存在的公平顺序。

饥饿模式为什么还要退出

如果公平模式能避免饿死,为什么不一直保持?

因为源码注释明确指出:正常模式性能明显更好。一个 goroutine 甚至可能在有等待者的情况下连续几次获取 Mutex,这对尾延迟不公平,却可能减少上下文切换,提高整体吞吐。

饥饿模式的退出条件是,获得锁的 waiter 发现:

  • 它已经是队列最后一个等待者;或者
  • 它实际等待时间少于 1ms。

满足条件后,Mutex 回到正常模式。

这说明 starvation mode 不是"更正确的模式",而是一种故障保护:在出现病态尾延迟时临时增强公平性,情况缓解后立即恢复高性能策略。

自旋在这里扮演什么角色

低竞争、临界区极短时,立即让 goroutine 睡眠可能得不偿失。锁很可能马上释放,当前 goroutine 在 CPU 上自旋几次就能获得它。

Go 运行时会结合多个条件决定是否主动自旋,例如:

  • 当前机器是否有多个逻辑处理器;
  • 当前 goroutine 是否有机会继续运行;
  • 自旋次数是否过多;
  • Mutex 是否处于饥饿模式。

正常模式允许有限自旋。饥饿模式下,新来者不再自旋,因为锁的所有权已经按队列直接移交,继续竞争只会破坏公平性。

自旋同样体现了吞吐与资源消耗的平衡:

  • 临界区很短时,自旋避免调度;
  • 临界区较长时,自旋浪费 CPU;
  • 竞争过强时,公平移交降低尾部等待。

Mutex 的四个状态维度

把源码简化后,可以把 state 理解为四类信息:

text 复制代码
locked     当前是否被持有
woken      是否已有 waiter 被唤醒,避免重复唤醒
starving   是否处于饥饿模式
waiters    等待队列中的 goroutine 数量

这些信息被压在一个 int32 中,通过 CAS 更新。

为什么不使用更清晰的多个字段?因为多个独立字段会引入中间状态:一个 goroutine更新了 locked,却还没更新 waiter 数;另一个 goroutine可能观察到不一致组合。单个状态字让关键转换能通过一次 CAS 原子完成。

代价是源码难读。lockSlow 里大量位运算和循环,并不是为了炫技,而是在一个极小状态空间里编码并发协议。

公平锁不一定更好

业务开发中,"公平"听上去天然优于"不公平"。但锁的评价指标至少有三个:

  • 平均吞吐量;
  • 平均等待时间;
  • P99、P999 等尾延迟。

严格 FIFO 往往改善尾延迟,却可能降低吞吐。允许 barging 则可能让平均性能更好,但极端情况下会让某个 waiter 长期失败。

Go Mutex 没有选择其中一端,而是动态切换:

text 复制代码
正常竞争
  → 允许抢锁和有限自旋
  → 优先吞吐

等待超过阈值
  → 直接移交
  → 优先避免饿死和病态尾延迟

队列恢复正常
  → 回到正常模式

这种设计比"公平锁"或"非公平锁"的标签更接近真实运行负载。

业务代码需要知道这些吗

大多数时候,不需要根据 Mutex 内部模式编写业务逻辑。

但理解它能解释几类现象。

锁没有严格 FIFO 保证

不能假设先调用 Lock 的 goroutine 必然先进入临界区。sync.Mutex 的公开 API 没有承诺公平顺序。

锁竞争会出现尾延迟

即使平均等待很低,正常模式下也可能有人连续抢锁。运行时用饥饿模式抑制病态情况,但业务仍应减少过长临界区。

短临界区和长临界区表现不同

Mutex 的自旋和抢锁策略适合短临界区。把网络 I/O、磁盘操作或大计算放在锁内,会让算法假设失效。

不要围绕 1ms 写逻辑

1ms 是实现细节,不是锁的服务等级协议。需要超时控制时,应在业务层使用 Context、channel 或其他机制。

怎样减少 Mutex 竞争

第一,缩小临界区:

go 复制代码
mu.Lock()
v := cache[key]
mu.Unlock()

// 在锁外做昂贵处理
use(v)

第二,不要持锁执行未知回调:

go 复制代码
mu.Lock()
callback() // callback 可能很慢、重入甚至再次加锁
mu.Unlock()

第三,先用 mutex profile 定位,而不是猜测:

go 复制代码
runtime.SetMutexProfileFraction(1)

然后通过 pprof 查看竞争来源。

第四,只有数据和访问模式确实允许时,才考虑分片、原子变量或无锁结构。复杂并发结构的正确性成本,通常比一次 Mutex 更昂贵。

结论

Go Mutex 的两种模式解决的是一对无法彻底消除的冲突:

  • 正常模式允许新 goroutine 抢锁,提高吞吐;
  • 饥饿模式按队列直接移交,防止等待者长期失败;
  • FIFO 队列已经表达等待时间先后,无需另建按时长排序的结构;
  • 1ms 是触发保护的实现阈值,而不是公开语义;
  • 当竞争恢复正常,Mutex 会主动回到性能更好的模式。

我最初的问题是"能不能进一步优化等待顺序"。读完回复和源码后才发现,很多看似额外的信息已经隐含在队列结构中,而真正困难的不是找出最公平的顺序,是让公平性只在需要时付费。

这也是 sync.Mutex 源码最值得学习的地方:它不是简单的一把锁,而是一套围绕 CPU 调度、缓存、自旋、唤醒和尾延迟不断折中的小型状态机。

参考链接

相关推荐
Zane19942 小时前
CAS 与原子类:Java 如何实现无锁编程
java·后端
颜进强2 小时前
前端看后端 13:什么是 Cookie 和 Session?
前端·后端
TunerT_TQ2 小时前
【智能体安全治理|专栏第9期】从“一堆规则”到“数字宪法”:智能体治理的下一个阶段
java·开发语言·安全·开源治理·大模型安全·ai基础设施·智能体安全
Aurorar0rua2 小时前
CS50 x 2024 Notes Algorithms - 07
c语言·开发语言·学习方法
gitboyzcf2 小时前
mpegts.js解决Chrome无法播放7568×142分辨率报错 DOMException问题
前端·后端
程序员-Benothing3 小时前
Java ForkJoinPool 详解:从分治思想到高性能并行计算
java·开发语言·后端·面试·职场和发展
脚踏实地皮皮晨3 小时前
003006001_WrapPanel控件
开发语言·c#·.net·wpf·visual studio
上海安当技术3 小时前
单点登录 SSO 怎么选协议?SAML 2.0 / OAuth 2.0 / OIDC / CAS 对比与 ERP、OA、CRM 接入实战
java·开发语言
不才不才不不才4 小时前
Spring 源码系列(14): JDK 动态代理 vs CGLIB,以及拦截器责任链
java·开发语言·spring