sync.Mutex 源码深度拆解:正常模式(自旋)与饥饿模式
一、核心概念与架构设计
sync.Mutex 是 Go 并发体系里最基础、也是被调用次数最多的同步原语。一个 Lock()/Unlock() 只占几纳秒的快速路径,背后是一套为两种截然相反的目标做权衡的状态机:
- 吞吐优先:锁释放的瞬间,让正在 CPU 上自旋的竞争者立刻接手,避免一次昂贵的 goroutine 挂起与唤醒。
- 公平优先:当某个等待者已经被晾了太久,锁必须直接移交给队列队首,不能让"插队的新来者"无限拖延。
Go 1.9 之前的实现只有第一种目标,代价是极端争抢下会出现尾随延迟(tail latency):等待队列里的 goroutine 可能被反复"截胡",几十毫秒都拿不到锁。Go 1.9 引入的饥饿模式(starvation mode)用一个 1ms 的时间阈值把两种模式粘在了一起:大部分时间跑在吞吐优先的正常模式 ,一旦出现不公平迹象就临时切换到饥饿模式,交接完成后自动退回。
另一个值得知道的版本事实:从 Go 1.23 开始,sync.Mutex 的实现被搬到了 internal/sync 标准库内层包(internal/sync/mutex.go),sync 只是薄薄一层转发。所以在 Go 1.23+ 的死锁堆栈里,你会看到 internal/sync.(*Mutex).unlockSlow 这样的帧,这是正常的,不是什么第三方库。
二、深度原理与底层剖析
2.1 state:一个 int32 承载四种信息
Mutex 只有两个零值可用的字段:
go
// 位于 internal/sync/mutex.go(Go 1.23+,此前在 sync/mutex.go)
type Mutex struct {
state int32 // 复合状态字,见下方位掩码
sema uint32 // 信号量,挂起与唤醒等待者
}
const (
mutexLocked = 1 << 0 // 第 0 位:锁被持有
mutexWoken = 1 << 1 // 第 1 位:已有等待者被唤醒,正在转起来
mutexStarving = 1 << 2 // 第 2 位:饥饿模式开关
mutexWaiterShift = 3 // 第 3 位起:等待者数量(高位整数)
)
const starvationThresholdNs = 1e6 // 1ms:切入饥饿模式的时间阈值
state 的 32 个位被切成两段:低 3 位是三个标志位,高位是等待者计数。一次 state 的原子读就能同时回答"锁在谁手里""有没有人刚被唤醒""是不是饥饿模式""队列里还有多少人"四个问题。这种位压缩让 Mutex 在 64 位机器上只占 8 字节,两个 int32 恰好对齐,也是它能被零值直接使用、可嵌入任意结构体的原因。
2.2 Lock 快速路径:一次 CAS 决胜负
go
func (m *Mutex) Lock() {
// 快速路径:state 从 0(完全空闲)CAS 成 mutexLocked,
// 无竞争时只花一次原子指令 + 一次 CAS,约 10~20ns。
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
m.lockSlow() // 竞争失败,进入慢速路径
}
state == 0 意味着没人持锁、没人被唤醒、非饥饿、队列空。绝大多数无竞争的 Lock 都在这条路径上终结,这就是 Mutex "零值可用且几乎免费"的本质。
2.3 lockSlow:自旋与饥饿的完整状态机
慢速路径(节选并简化注释):
go
func (m *Mutex) lockSlow() {
var waitStartTime int64 // 本 goroutine 开始排队的时间
var starving bool // 是否已进入饥饿心态
var awoke bool // 是否是被唤醒的那一个
iter := 0 // 自旋轮数计数
for {
old := atomic.LoadInt32(&m.state)
// 分支一:锁被持有、非饥饿模式,尝试自旋。
// 自旋有硬门槛:多核、GOMAXPROCS>1、至少还有一个 P 在跑、
// 当前 P 的本地运行队列为空(自旋才划算)。最多自旋 4 次,
// 每次执行 procyield(30)(x86 上的 PAUSE 指令,约 30 个周期)。
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 自己是被唤醒的、且 woken 位还没人设置,就占住 woken 位,
// 阻止 Unlock 再去唤醒别的 goroutine,减少无效唤醒。
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
continue // 自旋结束后重新读 state 再战
}
new := old
if old&mutexStarving == 0 {
new |= mutexLocked // 正常模式:尝试把锁位占上
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift // 抢不到,排队人数 +1
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving // 等得太久,把饥饿标志写进全局状态
}
if awoke {
new &^= mutexWoken // 转入正式流程,清掉 woken 位
}
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 分支二:CAS 成功且此前的 state 是完全空闲 + 非饥饿,
// 说明拿到了锁,收工。
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// 分支三:排队路径。记录首次排队时间,随后信号量挂起。
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 饥饿中传入 lifo=true:挂入信号量等待队列的队首
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 醒来后重新评估:本 goroutine 的累计等待是否超过 1ms
starving = waitStartTime != 0 && runtime_nanotime()-waitStartTime > starvationThresholdNs
old = atomic.LoadInt32(&m.state)
// 分支四:正常模式下被唤醒,但锁位/饥饿位又被别人占了,
// 说明醒来晚了,回到循环顶部重新竞争。
if old&(mutexLocked|mutexStarving) != 0 {
old = 0
continue
}
// 分支五:饥饿交接完成,锁的所有权已经属于本 goroutine,
// 锁位尚未设置,由本 goroutine 自己置位并做账目结算:
// 锁位 +1,等待者 -1。
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 0 {
// 自己已不饥饿、或自己是队列最后一个等待者:
// 顺手清掉饥饿标志,状态机退回正常模式。
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = false
}
}
三个关键设计读一遍源码就能看清:
自旋的本质是赌"锁马上就释放" 。临界区普遍在几十纳秒量级时,自旋 4 次(约百 ns)远比走一次信号量挂起(涉及 gopark、调度器切换,微秒级)便宜。runtime_canSpin 里的"P 本地队列为空"检查很精妙:如果 P 上还有别的 goroutine 可跑,自旋就是浪费别人的 CPU 时间片。
饥饿模式的判定权在等待者,执行权在 Unlock 。等待者在挂起醒来后检查自己等了多久,超过 1ms 就把 mutexStarving 写回 state。此后 Unlock 的行为发生根本变化:不再"唤醒一个竞争者让他回来重新抢",而是调用 runtime_Semrelease(&m.sema, true, 1),handoff=true 直接把信号量移交给等待队列队首,锁的所有权在唤醒的那一刻就完成了转移,新来者连自旋的机会都没有(lockSlow 分支一要求 old&mutexStarving == 0 才允许自旋)。
饥饿模式是自限的 。交接时如果发现自己是队列里最后一个等待者(或最后一段饥饿时间已耗尽),就在减等待者计数的同时清掉 mutexStarving,状态机退回正常模式。这保证饥饿模式只在拥塞尖峰时短暂存在,不会永久牺牲吞吐。
2.4 Unlock:正常唤醒 vs 饥饿移交
go
func (m *Mutex) Unlock() {
// 快速路径:state -= mutexLocked。若结果非 0,说明还有标志位或等待者。
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
if new&mutexStarving != 0 {
// 饥饿模式:锁的所有权直接移交队首等待者(handoff = true),
// 被移交者醒来即持锁,不需要再竞争。
runtime_Semrelease(&m.sema, true, 1)
return
}
// 正常模式:
if new>>mutexWaiterShift == 0 {
return // 没有等待者,无事发生
}
for {
old := atomic.LoadInt32(&m.state)
// 已有人被唤醒 / 已有饥饿标志,交给他们处理
if old&(mutexWoken|mutexStarving) != 0 { return }
// 自己这轮 Unlock 已经置过 locked=0(Add 已完成),
// 只需把一个等待者从队列减掉,并设置 woken 位
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
}
}
注意 Unlock 里减去的是 mutexLocked 而不处理等待者计数,等待者计数的减少由被唤醒者在分支四完成。状态的所有变更都通过原子操作完成,没有任何一个字段需要互斥保护,这是它能在快速路径做到十几纳秒的根本原因。
三、完整可运行示例
下面这段程序可以在本机直接 go run,用两个场景分别复现正常模式的尾随延迟和饥饿模式的交接公平性:
go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
// 场景一:一个"贪婪"的持有者在正常模式下反复抢锁,
// 观察等待者平均要等多久才能拿到锁。
func greedyHolder() {
var mu sync.Mutex
const waiters = 4
mu.Lock() // 主 goroutine 先持有锁
var wg sync.WaitGroup
start := make(chan struct{})
latency := make([]time.Duration, waiters)
for i := 0; i < waiters; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start // 所有等待者就位后再开始计时
t0 := time.Now()
mu.Lock()
latency[i] = time.Since(t0) // 从调用 Lock 到真正获锁的耗时
time.Sleep(50 * time.Microsecond)
mu.Unlock()
}(i)
}
close(start)
// 持有者不放锁,而是不停地 Lock/Unlock 空转。
// 正常模式下唤醒的等待者会与新来的 Lock 竞争,
// 而自旋的新来者往往能抢赢刚被唤醒、还没来得及执行的等待者。
for i := 0; i < 200; i++ {
mu.Unlock()
runtime.Gosched() // 让出 P,但并没有真正"让出锁"
mu.Lock()
}
// 此刻锁上大概率已积压等待者,Unlock 会触发交接;
// 若等待时间超过 1ms,sync 内部会切入饥饿模式,锁直接移交给队首。
mu.Unlock()
wg.Wait()
var total time.Duration
for i, d := range latency {
fmt.Printf(" waiter#%d 获锁延迟: %v\n", i, d)
total += d
}
fmt.Printf(" 平均获锁延迟: %v\n\n", total/time.Duration(waiters))
}
// 场景二:受控场景,直接观察饥饿模式交接后的公平性。
func fairHandoff() {
var mu sync.Mutex
done := make(chan struct{})
mu.Lock() // 主 goroutine 持锁,制造持续 5ms 的持有窗口
// 后台线程持续争抢,模拟"新来的竞争者"
go func() {
for {
select {
case <-done:
return
default:
}
mu.Lock()
mu.Unlock()
}
}()
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
time.Sleep(time.Duration(i) * time.Millisecond) // 错峰入队
mu.Lock()
fmt.Printf(" waiter#%d 拿到锁(此刻处于饥饿交接或队列队首)\n", i)
mu.Unlock()
}(i)
}
// 持锁 5ms,远超 starvationThresholdNs = 1e6(1ms),
// Unlock 时 sync 内部以 handoff=true 释放信号量,锁直接移交队首等待者。
time.Sleep(5 * time.Millisecond)
mu.Unlock()
wg.Wait()
close(done)
}
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
fmt.Println("== 场景一:正常模式下贪婪持有者造成的尾随延迟 ==")
greedyHolder()
fmt.Println("== 场景二:饥饿模式交接(handoff)的公平性 ==")
fairHandoff()
}
逐段解读几个容易忽略的点:
- 场景一的
runtime.Gosched()是关键陷阱还原。它只让出当前 P 的执行权,锁本身仍在持有者手里反复加解,等待者被唤醒后依然要和新 Lock 的自旋竞争,这正是尾随延迟的产生机制。实测中 4 个等待者的平均获锁延迟在几十到百余微秒之间波动,且不同轮次差异明显,这个"不可预测"本身就是结论。 - 场景二把持锁时间拉到 5ms,超过 1ms 阈值后 Unlock 走
handoff=true路径,三个等待者按入队顺序依次拿到锁,看不到自旋插队。 latency统计的是"调用 Lock 到获锁"的墙钟时间。如果把 Sleep 从 50μs 调大,等待者会更快跨过 1ms 阈值进入饥饿路径,延迟反而更稳定,可以动手验证状态机切换的效果。
四、生产踩坑与调优建议
1. 临界区大于 1ms 时警惕饥饿抖动。 数据库序列化写、加密压缩这类长临界区,在高并发下会频繁触发模式切换。表现为 p99 剧烈抖动而 p50 正常。缓解方向是缩小临界区(把 IO 移出锁外、换更细粒度的锁分片),而不是调阈值,1ms 阈值没有暴露任何配置项,这是刻意为之。
2. Unlock 未持锁会直接 fatal,不是 panic。 unlock of unlocked mutex 是不可 recover 的运行时错误,进程直接退出。它通常由三种写法引起:条件分支里只有部分路径加了锁、双层函数各自 Unlock、以及把 Unlock 写在了错误的 defer 作用域。防御手段是固定使用 mu.Lock(); defer mu.Unlock() 模式,让加解锁在词法上配对。
3. 不要用 time.Sleep "等锁释放"。 Mutex 没有超时语义,TryLock(Go 1.18 引入)返回 false 就该立即放弃。需要"持锁一段时间不成就放弃"的场景,用 TryLock 加重试循环,或者换成带超时的 channel 信号量。
4. runtime 内部还有一套自己的锁,别和 sync.Mutex 混为一谈。 Go 1.24 给 runtime 内部的互斥量引入了 spinbit 优化(GOEXPERIMENT=nospinbitmutex 可关闭),在 GOMAXPROCS 较大的机器上降低了调度器内部锁的争用开销。它作用于 runtime.mutex,对你代码里的 sync.Mutex 没有影响,但分析调度器相关的性能问题时需要知道这条线。
5. 锁分片前先测自旋开销的真实占比。 正常模式下 Mutex 的无竞争成本约 15ns,8 核高争抢下可能恶化到微秒级。但在临界区极短、竞争模式随机的业务里,盲目分片反而增加代码复杂度。判断依据应来自 pprof 的 sync.Mutex.Lock 采样和 mutex profile(runtime.SetMutexProfileFraction),而不是感觉。
五、总结
Mutex 用一个 int32 位掩码 + 一个信号量,在"吞吐优先的自旋竞争"与"公平优先的队列交接"之间做了动态平衡:无竞争时一次 CAS、轻度竞争时最多 4 轮自旋、重度且长久的排队超过 1ms 后切换为饥饿交接并自动退回。整个状态机没有任何锁保护锁本身,全部由原子操作和信号量原语撑起。