核心解答
sync.Mutex 的核心实现可以概括为:一个 state 整型字段(bit 标志 + 等待者计数)配合一个信号量 sema。
- 无竞争快路径:通过 CAS 把
state从 0 改为“locked”,成功就直接拿到锁。 - 有竞争慢路径:可能先短暂自旋(减少睡眠/唤醒开销),抢不到就把自己加入等待队列并
Semacquire挂起,等待Unlock唤醒或“交接”。 - 两种模式:
- 正常模式(偏吞吐):允许新来的 goroutine “抢锁/插队”,等待者可能出现长尾等待。
- 饥饿模式(偏公平):锁倾向于直接交接给队首等待者,限制长时间饥饿。
关于 Go 1.18 前后差异:Mutex 的这套“state + sema、正常模式/饥饿模式、自旋 + 挂起/唤醒”的核心框架在 1.18 前后保持一致;面试中更重要的是把这套机制讲清楚,而不是死背某个版本的微调细节。
解答思路
- 先讲数据结构:
state负责记录“锁是否被持有、是否已经唤醒过 waiter、是否进入饥饿模式、等待者数量”,sema负责真正的阻塞/唤醒。 - 再讲两条路径:无竞争用 CAS 快路径;有竞争走“自旋 → 入队 → 信号量挂起 → 被唤醒后重试”的慢路径。
- 最后讲取舍:正常模式吞吐更好但可能饿死;饥饿模式更公平但吞吐可能下降(handoff 与调度切换开销)。
深度解析与面试技巧
state里通常存什么?
state是一个整型位图(再加等待者计数),常见的语义是:
- locked:锁已被持有
- woken:已经唤醒过一个等待者,避免重复唤醒造成惊群
- starving:饥饿模式标志
- waiter count:等待队列里的 goroutine 数量(通常放在高位,通过 shift 计算)
正常模式为什么会“饿死”?正常模式下,
Unlock往往只是“释放锁 + 唤醒一个等待者”,但并不保证把锁直接交给被唤醒者。如果此时被唤醒者还没来得及真正运行,新来的 goroutine 可能立刻通过 CAS 抢到锁;在高竞争与不利调度下反复发生,就会出现“老 waiter 长时间排队却一直拿不到锁”的饥饿现象。
饥饿模式解决了什么?代价是什么?
- 解决:通过更强的公平性策略(更接近“handoff 给队首”)来限制长尾等待,让等待时间有上界。
- 代价:吞吐可能下降,因为 handoff 与唤醒/调度切换更频繁,并且新来的 goroutine 不能直接抢锁,更多走入队等待。
什么时候进入/退出饥饿模式?常见的表述是“如果某个等待者等待超过一个阈值(典型约 1ms)就认为发生饥饿,进入饥饿模式”,在饥饿缓解后(例如队列变短/等待时间不再很长)再退出回正常模式,以恢复吞吐。
面试官追问方向
- 为什么要自旋?自旋多久?:自旋能减少一次睡眠/唤醒的系统开销,但自旋过度会浪费 CPU;Go 会根据竞争程度与运行环境决定是否自旋及自旋次数。
woken位的意义:避免同一轮解锁唤醒多个 waiter 造成惊群和无谓竞争。- “公平 vs 吞吐”怎么取舍?:正常模式偏吞吐,饥饿模式偏公平;设计目标是大多数场景吞吐优先,极端长尾时切到公平止血。