Go sync.Mutex(互斥锁)有哪两种模式?(正常模式 vs 饥饿模式)
Go 语言的 sync.Mutex 在设计上为了平衡性能(吞吐量)和公平性,实现了两种模式:正常模式(Normal Mode)和饥饿模式(Starvation Mode)。
这两种模式是自动切换的,以下是详细对比:
1. 正常模式 (Normal Mode)
这是 Mutex 的默认模式,主要为了性能优化。
工作机制:
- 当一个 Goroutine 释放锁时,它会唤醒等待队列(FIFO)头部的 Goroutine。
- 关键点(抢占): 被唤醒的 Goroutine 不会直接拥有锁,而是需要和新来的 Goroutine 竞争。
- 新来的优势: 新来的 Goroutine 正在 CPU 上运行(处于活跃状态),而刚被唤醒的 Goroutine 需要上下文切换(从睡眠中醒来)。因此,新来的 Goroutine 往往能抢到锁。
- 自旋(Spinning): 新来的 Goroutine 如果发现锁被占用,会尝试“自旋”(空转 CPU 等待一小会儿),如果在这期间锁释放了,它就能立刻拿到,避免了昂贵的系统调用和上下文切换。
优点:
- 吞吐量极高。减少了 Goroutine 的上下文切换和系统调用开销。
缺点:
- 不公平。已经在排队的 Goroutine 可能会一直抢不到锁(因为总有新来的插队),导致尾部延迟(Tail Latency)甚至饿死。
2. 饥饿模式 (Starvation Mode)
这是为了解决“尾部延迟”和“饿死”问题而引入的模式,主要为了公平性。
工作机制:
- 严格 FIFO: 锁的所有权会从释放锁的 Goroutine 直接移交(Handoff)给等待队列头部的 Goroutine。
- 禁止抢占: 新来的 Goroutine 不会尝试自旋,也不会尝试去抢锁。
- 乖乖排队: 新来的 Goroutine 会直接加入到等待队列的尾部。
优点:
- 绝对公平。保证了等待时间最长的 Goroutine 能够优先获得锁。
- 消除了长尾延迟。
缺点:
- 性能较低。因为禁用了自旋,且强制进行上下文切换,导致 CPU 缓存亲和性(Cache Locality)变差,整体吞吐量下降。
3. 模式切换条件
Go 运行时会根据情况自动在两种模式间切换:
A. 从 正常模式 -> 切换到 -> 饥饿模式
触发条件:
如果一个 Goroutine 在等待队列中等待超过 1 毫秒(1ms) 还没有获取到锁,Mutex 就会切换到饥饿模式。
B. 从 饥饿模式 -> 切换回 -> 正常模式
触发条件(满足其一即可):
- 队列空了: 获得锁的 Goroutine 是等待队列中的最后一个(即它后面没有人在排队了)。
- 等待时间短: 获得锁的 Goroutine 等待时间小于 1 毫秒。
总结对比表
| 特性 | 正常模式 (Normal) | 饥饿模式 (Starvation) |
|---|---|---|
| 侧重点 | 性能 / 吞吐量 | 公平性 / 延迟控制 |
| 锁获取方式 | 竞争获取(新来的 vs 唤醒的) | 直接移交(Handoff)给队头 |
| 新 Goroutine 行为 | 尝试自旋,尝试抢锁(插队) | 不自旋,直接去队尾排队 |
| 适用场景 | 锁竞争不激烈,持有锁时间短 | 锁竞争激烈,且有 Goroutine 等待过久 |
| 默认状态 | 是 | 否 |
核心设计哲学
Go 的 Mutex 设计认为:通常情况下,性能优先于公平。但在极端情况下(有人等太久),必须牺牲性能来保证公平,防止程序逻辑死锁或响应超时。