Go 标准库与生态 · 第二篇

sync:Mutex 怎么从"不公平"切换到"公平",WaitGroup 和 Once 又是怎么实现的

sync.Mutex 的文档里有一句话很容易被跳过:"Mutex 有正常模式和饥饿模式两种"。这一篇不满足于"知道有这么回事",而是直接读了 internal/sync/mutex.go 的真实源码,找到 sync.Mutex 内部那个未导出的 state 字段在内存里的真实偏移量,用 unsafe.Pointer 在一个真实运行的 Go 程序里,把这个状态字从"正常"翻转到"饥饿"再翻转回来的全过程,原原本本地录了下来。另外还搞清楚了 WaitGroup 的计数器到底是怎么塞进一个 uint64 里的,以及 Once 为什么必须用一把锁,而不能只是一个原子 CAS。

延续上一篇的方法论:本机装的就是真实 Go 1.26.6,标准库源码就是本机 go run 时真正在跑的那份代码。全部实验都是本机真实执行的 Go 程序,没有伪代码。

5 个真实 Go 程序
本机真实 go run,包含一次对 Mutex 内部状态字的真实运行时探测
4 处真实源码
internal/sync/mutex.go、sync/waitgroup.go、sync/once.go,跟本机 go1.26.6 完全同版本

真实源码:Mutex 的真身其实在 internal/sync 里

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

真实源码:1ms 阈值和"谁能不排队直接抢"

$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 无限期地抢跑。

真实实验:WaitGroup 的计数确实是并发的,不是排队执行

$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,这样 AddWait 之间的协调可以用一次原子操作完成,不需要额外加锁。

真实实测
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,这是源码里显式检查过的。

真实源码 + 真实实验:Once 为什么必须是锁,不能只是 CAS

$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() 依然不会再跑它。

交互演示:从状态字翻转到 WaitGroup/Once 的真实数据回放

把上面几组真实实验按发生顺序串成一条演示。

sync 实录未开始
点击"下一步"或"播放"开始。

全部数据来自本机真实 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。

参考与说明

  • 本文源码引用(sync.Mutex/internal/sync.Mutex 结构体与 lockSlow/unlockSlowWaitGroup.state 的位布局、Once.Do/doSlow)均取自本机安装的 Go 1.26.6 自带标准库源码($GOROOT/src/sync/$GOROOT/src/internal/sync/),跟本机 go run 实际执行的是同一份代码。
  • 全部实验(starve_trace.gotail_latency.gowaitgroup_demo.goonce_demo.go)均为本机真实 go run 产生的输出,未做删改。读取 sync.Mutex 内部状态字用的是只读的 unsafe.Pointer 转换,没有修改标准库、没有用 go:linkname,纯粹的运行时观测。
  • 演示数据的自检:starve_trace.go 的 4 条真实状态变化被 Python 脚本逐项校验(等待者出现的时机、饥饿位翻转前必须等待 > 1ms、饥饿位清除必须发生在 waiters 归零那一刻),并跟外部独立测得的等待时长在 150µs 容差内交叉核对(实际只差 7µs);WaitGroup 的并发模型(耗时=max(sleep) 而非 sum(sleep))跟真实测得的 91ms 吻合;Once 的状态机模型(锁 + done 标志,panic 也算完成)逐项匹配真实输出。
  • 没有涉及:sync.RWMutex(读写锁的实现,尤其是它如何复用 Mutex 防止写者饿死)、sync.Map 的分段/无锁读路径、sync.Pool 跟 GC 的协作机制、sync.Cond
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电