sync.Mutex 的文档里有一句话很容易被跳过:"Mutex 有正常模式和饥饿模式两种"。这一篇不满足于"知道有这么回事",而是直接读了 internal/sync/mutex.go 的真实源码,找到 sync.Mutex 内部那个未导出的 state 字段在内存里的真实偏移量,用 unsafe.Pointer 在一个真实运行的 Go 程序里,把这个状态字从"正常"翻转到"饥饿"再翻转回来的全过程,原原本本地录了下来。另外还搞清楚了 WaitGroup 的计数器到底是怎么塞进一个 uint64 里的,以及 Once 为什么必须用一把锁,而不能只是一个原子 CAS。
延续上一篇的方法论:本机装的就是真实 Go 1.26.6,标准库源码就是本机 go run 时真正在跑的那份代码。全部实验都是本机真实执行的 Go 程序,没有伪代码。
Go 1.26 把 sync.Mutex 的算法整个挪到了 internal/sync.Mutex,sync.Mutex 只是一层薄薄的转发。
$GOROOT/src/sync/mutex.go(go1.26.6 本机源码) · L30 type Mutex struct { _ noCopy mu isync.Mutex // internal/sync.Mutex,真正的算法在这里 }
$GOROOT/src/internal/sync/mutex.go(go1.26.6 本机源码) · L20 type Mutex struct { state int32 sema uint32 } const ( mutexLocked = 1 << iota // bit 0 mutexWoken // bit 1 mutexStarving // bit 2 mutexWaiterShift = iota // 剩下的高位是等待者计数 starvationThresholdNs = 1e6 // 1ms )
state 是一个 int32,低 3 位是三个标志位(是否已锁、是否有人被唤醒、是否处于饥饿模式),剩下的高位记录当前排队等待的 goroutine 数量。noCopy 是个零大小类型,不占内存,所以 sync.Mutex 这个结构体的第一个字节,就是 internal/sync.Mutex.state 的第一个字节——这意味着可以用 unsafe.Pointer 直接把一个正在使用中的 sync.Mutex 强转成 *int32 来只读地观测它的真实内部状态,不需要改标准库、不需要 go:linkname。
$GOROOT/src/internal/sync/mutex.go(go1.26.6 本机源码) · L129 // 当前 goroutine 把 mutex 切换到饥饿模式。 // 但如果 mutex 现在是解锁状态,就不要做这个切换。 if starving && old&mutexLocked != 0 { new |= mutexStarving }
$GOROOT/src/internal/sync/mutex.go(go1.26.6 本机源码) · L150 runtime_SemacquireMutex(&m.sema, queueLifo, 2) starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
一个排队等锁的 goroutine,每次被唤醒后都会检查"我这次排队总共等了多久";只要超过 starvationThresholdNs(1ms),starving 标志就会永久置真,下一次它有机会修改 state 时,就会把 mutexStarving 位真正设置到共享状态上。饥饿模式下解锁的 goroutine 不再靠"谁先抢到 CPU 谁赢"这种正常模式的竞争方式,而是直接把 mutex 的所有权移交给队列最前面的等待者(unlockSlow 里对应的分支,不再新开一段贴代码)。
起两个 goroutine:一个 hammer 在紧循环里反复 Lock() → sleep 100µs → Unlock()(跟标准库自己的 TestMutexFairness 测试用的是同一个模式);一个 victim 延迟 2ms 后只调用一次 Lock()。第三个 goroutine 每 5µs 用 unsafe.Pointer 读一次 state 字段,只在值变化时记录。
func rawState(mu *sync.Mutex) int32 {
return atomic.LoadInt32((*int32)(unsafe.Pointer(mu)))
}
t=27µs locked=true woken=false starving=false waiters=0 // hammer 拿到锁
t=2064µs locked=true woken=false starving=false waiters=1 // victim 开始排队
t=3146µs locked=true woken=false starving=true waiters=1 // 排队满 1082µs,饥饿模式开启!
t=3251µs locked=true woken=false starving=false waiters=0 // victim 拿到锁,饥饿模式退出
victim 实测等待: 1194µs
这 4 条状态变化全部来自同一次真实运行,不是拼凑的示意图。第 2 条到第 3 条之间隔了 1082µs——超过了源码里写死的 1ms 阈值,starving 位应声而起;第 4 条里 waiters 归零的那一刻,starving 位立刻又被清掉,跟源码里"最后一个等待者拿到锁后退出饥饿模式"的注释完全对应。而且这一刻(t=3251µs,距离 victim 开始排队 1187µs)跟另一个独立测量的 victim 实测等待 1194µs 只差 7µs——两种完全不同的测量方式(内部状态轮询 vs 外部 time.Since 计时)相互印证。
还是同一个 hammer,让 victim 循环调用 400 次 Lock(),记录每一次真实的等待延迟。
for i := 0; i < 400; i++ {
t0 := time.Now()
mu.Lock()
waits[i] = time.Since(t0)
mu.Unlock()
}
mean=14.4µs p50=41ns p99=1.119ms max=1.168ms
等待超过 1ms 的调用: 5/400
(第二次独立运行: mean=20.1µs p50=42ns p99=1.141ms max=1.193ms, 7/400)
p50 只有 41 纳秒——绝大多数时候 victim 靠正常模式的快速路径,几乎瞬间抢到锁;但尾部延迟被死死摁在 1ms 出头,两次独立运行的 max 分别是 1.168ms 和 1.193ms,没有一次显著超过阈值太多。这正是源码注释里说的"饥饿模式的意义在于防止病态的尾延迟"——如果没有这个机制,victim 有可能在正常模式下被 hammer 无限期地抢跑。
$GOROOT/src/sync/waitgroup.go(go1.26.6 本机源码) · L51 // Bits (high to low): // bits[0:32] counter // bits[32] flag: synctest bubble membership // bits[33:64] wait count state atomic.Uint64
WaitGroup 把"任务计数器"和"等待者计数"打包进同一个 atomic.Uint64,这样 Add 和 Wait 之间的协调可以用一次原子操作完成,不需要额外加锁。
sleeps := []time.Duration{90*ms, 30*ms, 60*ms, 10*ms, 50*ms, 20*ms}
for i, d := range sleeps {
wg.Go(func() { time.Sleep(d); ... })
}
wg.Wait()
task3(10ms) 完成于 11ms
task5(20ms) 完成于 21ms
task1(30ms) 完成于 31ms
task4(50ms) 完成于 51ms
task2(60ms) 完成于 61ms
task0(90ms) 完成于 91ms
全部完成,总耗时=91ms(6 个任务的 sleep 加总是 260ms)
wg2.Wait() 在计数器已经是 0 时立即返回,耗时 208ns
wg3.Add(-1) 在空 WaitGroup 上真实 panic: "sync: negative WaitGroup counter"
6 个任务的完成时间点跟各自的 sleep 时长严丝合缝对上——总耗时 91ms 约等于最长的那个任务(90ms),而不是 6 个任务加总的 260ms,证明它们真的是并发跑的。Wait() 在计数器已经为 0 时有一条快速路径,208 纳秒就返回,完全不涉及信号量;而 Add 一个负值让计数器跌破 0 会真实触发 panic,这是源码里显式检查过的。
$GOROOT/src/sync/once.go(go1.26.6 本机源码) · L52 // Do 保证:当它返回时,f 一定已经执行完毕。 // 如果只用 CAS——先 CAS 成功的那个 goroutine 去跑 f(), // 其它 goroutine CAS 失败就立刻返回——那就不满足这个保证了。 func (o *Once) Do(f func()) { if !o.done.Load() { o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if !o.done.Load() { defer o.done.Store(true) f() } }
doSlow 用一把真正的 Mutex:没抢到执行权的 goroutine 不是"CAS 失败就走人",而是阻塞在 o.m.Lock() 上,直到执行 f() 的那个 goroutine 跑完并 Unlock()——这样每一个调用 Do() 的 goroutine,拿到的返回值都保证 f() 已经跑完了。另外 defer o.done.Store(true) 在 f() panic 时依然会执行,所以"f panic 了"也被算作"已经执行过"。
// 20 个 goroutine 并发调用同一个 once.Do(f),f 内部 sleep 60ms
for i := 0; i < 20; i++ {
go func() { once.Do(f); returnedAt[i] = time.Since(start) }()
}
f() 实际执行次数: 1
20 个 goroutine 里最早返回和最晚返回都是 61ms(不是"赢家跑完立刻返回,输家瞬间返回")
// f() panic 的情况
once2.Do(func(){ panic("init failed") }) → panic 冒泡出 Do(),值为 "init failed"
once2.Do(func(){ ranSecond = true }) → ranSecond 仍然是 false,f 没有再跑第二次
20 个并发调用者的返回时间全部聚集在 61ms 附近,跟"赢家 60ms 跑完之后大家才一起返回"完全吻合——如果 Once 只是一个 CAS,19 个"输家"应该在接近 0ms 时就返回了。panic 这一段也验证了源码注释说的"panic 算作已完成":f 炸了一次,第二次 Do() 依然不会再跑它。
把上面几组真实实验按发生顺序串成一条演示。
全部数据来自本机真实 go run 的输出(starve_trace.go 的 4 条状态变化、tail_latency.go 的 400 次延迟统计、waitgroup_demo.go/once_demo.go 的真实完成时间),Python 脚本逐项断言核对,并且用状态字第 3 条跟第 2 条的时间差(1082µs)独立验证了"超过 1ms 才会饥饿"这条规则,再用第 4 条相对第 2 条的时间差(1187µs)跟另一个独立测量的 victim 等待时间(1194µs)交叉核对,两者只差 7µs。