Theory · Golang · Runtime
Mutex 的 32 位 state 位图 → 饥饿模式;sync.Map 的 read/dirty 双表 —— 每个 API 背后都是一个精巧的位运算状态机
Mutex 位布局与自旋、RWMutex 的负数 readerCount、1ms 饥饿切换(Go 1.9+)
WaitGroup 计数信号量、Once 双检查、atomic CAS 指令与泛型化(1.19+)
sync.Map 双表提升、Pool 两级 victim cache;15 组 QA + 死锁清单
开场 · Why Sync Primitives
先看一段"看起来绝对没错"的代码,再谈工具。
var n int for i := 0; i < 2; i++ { go func() { for j := 0; j < 1_000_000; j++ { n++ // ← 不是一步! } }() } // 期望 2_000_000 // 实测:每次都不一样,常见 1_0xx_xxx ~ 1_9xx_xxx
① 从内存读出 n;② 在寄存器里加 1;③ 写回内存。两个 goroutine 可能同时读到 100、各自算出 101、再各自写回——两次加法,只增加了 1。
| 问题 | 后果 |
|---|---|
| 结果依赖调度时序 | 同一份代码每次跑出的数都不一样,本地复现不了线上的偶发故障 |
| 锁太粗 / 太细 | 太粗 → 并行退化成串行;太细 → 漏锁或死锁 |
| 抢不到锁要睡 | 挂起 + 唤醒要经调度,微秒级——比无竞争时贵两个数量级 |
单个变量 → atomic(一条 CPU 指令,不睡眠,个位数纳秒);多个字段一起改 → Mutex(无竞争时一次 CAS,十几纳秒);等一批人干完 / 只初始化一次 → WaitGroup / Once。选工具 = 选边界的粒度。(以上为量级,绝对值随硬件与版本变化)
读之前 · Before You Read
术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| 数据竞争 data race | 两个 goroutine 同时碰同一块内存、至少有一个在写,且没有约定顺序——结果取决于谁先跑到 |
| 临界区 critical section | 被锁保护的那段代码。同一时刻只允许一个 goroutine 在里面执行 |
| 不变量 invariant | 需要被保护的"正确性约束",通常是多个字段之间必须同时成立的关系(如余额与流水对得上) |
| 原子操作 atomic | 一条 CPU 指令完成的读改写。中间不可能被别的核插进来,要么全做要么全不做 |
| CAS 比较并交换 | "如果现在还是旧值 A,就改成新值 B";发现不是 A,说明被别人抢先了,本次失败 |
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| 互斥锁 Mutex | 同一时刻只允许一个持有者;其余人要么抢、要么睡 |
| 自旋 spin | 不睡觉,反复尝试抢锁,赌持有者马上就放。烧 CPU 换延迟,赌错了就是白烧 |
| 信号量 semaphore | runtime 提供的"挂起 / 唤醒"机制。睡下去不占 CPU,被唤醒才继续——代价是要走一次调度 |
| 饥饿 starvation | 某个等待者长期抢不到锁(总被新来者插队)。Go 用 1ms 阈值切饥饿模式兜底 |
| 顺序一致性 seq-cst | Go atomic 采用的内存序:所有 goroutine 看到的操作顺序一致。简单但略保守,Go 不开放更细的 acquire/release |
atomic 管单个变量的边界,Mutex/RWMutex 管一段代码的边界,WaitGroup/Once/Cond 管时序的边界。选工具 = 选边界的粒度。Panorama
| 原语 | 适用 | 不适用 / 陷阱 | 底层机制 |
|---|---|---|---|
| Mutex | 保护复合不变量(多个字段一致) | 不可重入;defer 太粗放大临界区 | state 位图 + 信号量 sema |
| RWMutex | 读极多写极少;临界区较长 | 不可递归;写多时反而更慢 | w Mutex + 读者计数 + 双信号量 |
| WaitGroup | 等一批 goroutine 收口 | Add/Wait 时机错配;重用需等 Wait 返回 | 64 位计数 + 信号量 |
| Once | 初始化恰好一次 | f panic 也算"完成" | atomic load 快路径 + mutex |
| atomic | 单个标量的原子读写改 | 无法保护多字段一致性 | CPU 原子指令(LOCK CMPXCHG 等) |
| sync.Map | 读多写少 / key 集合不相交 | 写多场景慢于 mutex+map | read/dirty 双表 + miss 提升 |
| sync.Pool | 复用无状态临时对象减 GC | 连接等有状态资源;可能被 GC 静默收走 | per-P 缓存 + 两级 victim |
Mutex · state Bits
Lock Path
Starvation · Go 1.9+
Unlock 唤醒队头 waiter,但锁不直接给它——唤醒者要与新到的 goroutine 竞争 CAS。新来者正在 CPU 上跑、无需调度开销,赢面大(插队 barging)。临界区短时吞吐高;副作用:队头可能一直抢不过 → 尾延迟雪崩。
waiter 等待超过 1ms(starvationThresholdNs = 1e6)→ 置 starving 位。此后 Unlock 直接把锁交给队头(handoff),新来者不再尝试 CAS,乖乖去队尾排队。队头拿到即可能让本 waiter 退出饥饿(等待时长重新评估)。
| 维度 | 正常模式 | 饥饿模式 |
|---|---|---|
| 锁的移交 | 唤醒后竞争(新来者可插队) | FIFO 直接交接给队头 waiter |
| 自旋 | 允许(满足四条件) | 禁止(自旋破坏公平) |
| 切换入 | — | 某 waiter 等待 > 1ms |
| 切换出 | — | 队尾 waiter(自己是最后一个)或本次等待 < 1ms |
RWMutex · Write Preference
// src/sync/rwmutex.go(对照 Go 1.27) type RWMutex struct { w Mutex // 写者互斥 writerSem uint32 // 写者等读者清场 readerSem uint32 // 读者等写者离开 readerCount atomic.Int32 // 读者计数(可为负) readerWait atomic.Int32 // 写者前需等走的读者数 } const rwmutexMaxReaders = 1 << 30 // Lock(): 先抢 w.Mutex,再 // readerCount -= 1<<30 → 变负 = "有写者在等" // readerWait = 现存读者数,等它们 RUnlock 清场
| 规则 | 内容 |
|---|---|
| 写优先(防写饿死) | 文档:Lock 被调用后,"concurrent calls to RLock will block until the writer has acquired (and released)"——已持读锁者不受影响,新读者全部让路 |
| 不可递归 | 持 RLock 再 RLock:若此刻有写者排队 → 第二次 RLock 睡在 readerSem,而写者在等你放锁 → 死锁(文档明确警告) |
| readerWait 的作用 | 写者记录"还要等几个读者退出",最后一个 RUnlock 发现 --readerWait==0 → 唤醒 writerSem |
| 成本提醒 | RLock/RUnlock 各一次原子加减;写多场景读者原子操作互相"打架"(cache line 弹跳)——写多反而比 Mutex 慢 |
WaitGroup · Go 1.25 Go()
// src/sync/waitgroup.go(对照 Go 1.27) type WaitGroup struct { noCopy noCopy state atomic.Uint64 // 高32位:计数 低32位:waiters sema uint32 } wg.Add(n) // counter += n;归零且 waiters>0 // → semrelease 唤醒全部 wg.Wait() // counter==0 → 快路径返回; // 否则 waiters++ 挂 sema wg.Done() // 就是 Add(-1) // Go 1.25 新增: wg.Go(func() { … }) // ≈ Add(1)+go+defer Done()
go vet copylocks 检查所有含 noCopy 的类型传值行为。| 规则(文档原文级) | 说明 |
|---|---|
| 正数 Add 必须先于 Wait(或等价的并发前置) | counter 为零时 Wait 立即返回——晚 Add = 丢等。Go 1.25 新增 go vet waitgroup 分析器静态抓这个错 |
| counter 归零后再 Add 的时机 | "new Add calls must happen after all previous Wait calls have returned"——重用必须等 Wait 全部返回;违者 panic("WaitGroup is reused before previous Wait has returned") |
| counter 不能为负 | Add 负数越过零 → panic "negative WaitGroup counter" |
go func(){ wg.Add(1) … }:Wait 可能在 Add 执行前就跑完。正确姿势:go 之前 Add,或直接用 1.25 的 wg.Go(f)——从 API 层消灭这个错位。
Once · OnceFunc (1.21)
// src/sync/once.go(对照 Go 1.27) type Once struct { done atomic.Uint32 m Mutex } func (o *Once) Do(f func()) { if o.done.Load() == 0 { // 快路径:原子读 o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done.Load() == 0 { // 双检查 defer o.done.Store(1) // ⚠ 先于 f 的 defer f() // → f panic 也置 done } }
done.Store 挂在 f 之前的 defer 上:panic 展开时先执行它——这就是"f panic 也算完成"的实现机制
| 行为 | 定义(官方文档) |
|---|---|
| 并发 Do | 恰好一个 goroutine 执行 f,其余阻塞等待其完成——不是跳过 |
| f panic(Once.Do) | "Do considers it to have returned; future calls of Do return without calling f"——done 已置位,后续调用静默返回(实测确认) |
| OnceFunc/OnceValue(s)(Go 1.21+) | "the returned function will panic with the same value on every call"——每次调用重放同一个 panic,失败不被吞 |
sync/atomic · CAS
CAS → x86 LOCK CMPXCHG / arm64 LDAXR+STXR;Add → LOCK XADD。全部顺序一致性(seq-cst),不开放 acquire/release——比 C++ 内存序简单,略保守。
atomic 适合单标量:计数器、标志位、指针替换;复合不变量(多字段一起改)必须 Mutex。atomic 不睡眠不调度,Mutex 慢路径要走调度——开销量级差一个数量级。
// Go 1.19+ 泛型类型(自动处理对齐) var n atomic.Int64 // 内嵌 align64 n.Add(1) · n.Load() · n.CompareAndSwap(o, v) var p atomic.Pointer[Config] p.Store(cfg) // 指针替换:无锁配置热更新 // atomic.Value:any 版(1.4+) var v atomic.Value v.Store(Config{…}) v.Store("str") // panic: inconsistently typed // Go 1.23 新增:返回旧值 atomic.And(&x, mask) · atomic.Or(&x, bits)
| API | 要点 |
|---|---|
| atomic.Value | Store 后类型固定:再存其他类型 panic(inconsistently typed);Load 返回 any 需断言 |
| atomic.Int64/Pointer[T] 等(1.19+) | 泛型封装:类型安全 + 32 位平台 8 字节对齐由内嵌 align64 保证——手写字段对齐曾是经典坑 |
| atomic.And/Or(1.23+) | 原子位运算,返回旧值(release notes 原文) |
atomic.Pointer 存不可变快照:写方整份替换、读方 Load 用本地副本——生态最常用的"无锁读、低频写"结构(sync.Map 同款)。
sync.Map · read/dirty
When to Use · Pool Victim
① key 集合写入后基本不变(append-only cache);② 多 goroutine 读写不相交的 key 集合。命中任一即显著快于 Mutex+map;写新 key 多则反复 dirty 重建,反而慢——先 benchmark。
per-P 的 poolLocal:private 槽(无锁)+ shared 链(可被偷);外加victim 两级:GC 时 local 降级为 victim、旧 victim 丢弃(1.13+)——对象最多活两个 GC 周期,是"软缓存"不是持久存储。
Get(): 1. 本 P private // 无锁 2. 本 P shared 头部 // 加锁 3. 偷其他 P shared // 负载均衡 4. victim / victimShared // 上一代 5. New()(若设置)
poolCleanup 挂在 GC STW 阶段:local→victim、victim 清空(src/sync/pool.go)
| Pool 误用 | 后果 / 修法 |
|---|---|
| 存连接、文件等有状态资源 | GC 静默收走对象,连接裸奔——有状态资源要显式池化(自带生命周期管理) |
| Put 回带残留状态的对象 | 复用后读到上次的脏字段——Put 前 Reset,或只放"纯缓冲"([]byte 等) |
| Put 大对象当免费缓存 | 两个 GC 周期内被清——当"长期缓存"用会反复 miss + New,白忙 |
| 期望 Get 出来的对象一直可用 | Get 后对象所有权归你、用完 Put;跨 GC 的假设不成立 |
Cond · errgroup
c := sync.NewCond(&mu) func consumer() { mu.Lock() for !ready { // ⚠ 必须 for 循环 c.Wait() // Unlock→挂起→醒来再 Lock } mu.Unlock() } func producer() { mu.Lock(); ready = true; mu.Unlock() c.Broadcast() }
g, ctx := errgroup.WithContext(ctx) g.SetLimit(8) // 并发上限(信号量) for, url := range urls { url := url g.Go(func() error { return fetch(ctx, url) }) } err := g.Wait() // 第一个非nil error // + ctx 自动 cancel
| 行为 | 说明 |
|---|---|
| Wait 返回值 | 第一个非 nil error(其余仍会等完) |
| WithContext | 首个 error 触发 ctx cancel——扇出取消的标准姿势 |
| panic 传递 | Go() 内部 recover,Wait 时 re-panic——补上跨 goroutine panic 缺口(呼应 defer deck) |
| SetLimit | 内置信号量限流,替代手写 worker pool |
Deadlock Checklist
| 坑 | 现象 / 根源 | 修法 / 检测 |
|---|---|---|
| ① Mutex 重入 | 同一 goroutine 二次 Lock:等自己放锁 → 永久阻塞 | 拆临界区 / 递归数据重构;Go 锁故意不支持重入(保持简单) |
| ② 双锁交叉 | G1 持 A 等 B,G2 持 B 等 A | 全局固定加锁顺序;检测:go run -race + 压测 |
| ③ RWMutex 递归读锁 | 读锁内再 RLock,写者插入即死锁(第 6 页) | 重入逻辑上提 / 改 RWMutex→Mutex |
| ④ mutex 拷贝 | struct 按值传递/赋值后锁失效,两份 state 各自为政 | 一律 *Mutex;go vet copylocks 静态检查(noCopy) |
| ⑤ WaitGroup.Add 错位 | goroutine 里 Add → Wait 提前返回 | go 前 Add 或 wg.Go(1.25);vet waitgroup |
| ⑥ WaitGroup 重用竞态 | counter 归零后立刻复用,旧 Wait 尚未返回 | 等 Wait 返回再重用;或每轮新 WaitGroup |
| ⑦ Cond 用 if 判条件 | 被唤醒但条件已被抢走 → 脏数据 | for + 条件检查(第 12 页) |
| ⑧ atomic.Value 换类型 | Store 第二种具体类型 → panic | 类型恒定;多类型用 Pointer[T] 分变量 |
| ⑨ sync.Pool 存有状态资源 | GC 静默回收 → 连接消失(第 11 页) | 有状态资源显式池化 |
Interview QA · 1/2
state int32(低三位 locked/woken/starving + 高位 waiter 计数)+ sema uint32。无竞争时 Lock 就是一次 CAS(0→locked),用户态一条原子指令;有竞争才进 lockSlow 的自旋/排队。
waiter 等待超过 1ms(starvationThresholdNs=1e6)切饥饿:Unlock 直接把锁交队头,新来者只排队不自旋不插队。退出:队尾 waiter 或本次等待 <1ms。1.9 引入,治重负载尾延迟。
多核且 GOMAXPROCS>1、本 P 本地 runq 空、非饥饿模式、自旋次数上限 4。逻辑:单核自旋白烧时间片;runq 有活别占 CPU;饥饿下自旋破坏 FIFO 公平;次数上限防期望收益为负。
写者 Lock 时先拿 w.Mutex,再把 readerCount 减 1<<30 使其变负——负数即"写者在等",新读者 RLock 发现负数就挂 readerSem 让路;readerWait 记录还需等几个读者退出,最后一个 RUnlock 唤醒 writerSem。
持读锁再 RLock:若此刻有写者在排队,第二次 RLock 发现 readerCount 为负 → 挂起等写者;写者又在等第一次读锁释放 → 循环等待。文档原文明确警告不支持递归读锁。
noCopy + 一个 64 位原子值(高 32 位计数、低 32 位 waiters)+ sema。Add 归零且有人等 → semrelease 全唤醒。重用规则:新 Add 必须发生在上次 Wait 全部返回之后,否则 panic;counter 减过零 panic "negative WaitGroup counter"。
Do:done 的置位挂在 f 之前的 defer 上,panic 展开先置位 → 后续 Do 静默返回不重试(文档原文 considers it to have returned)。OnceFunc(1.21)相反:返回的函数每次调用重放同一 panic——失败不被吞。
首次 Store 锁定具体类型,再 Store 别的类型 panic。绕法:类型恒定设计,或多类型用 atomic.Pointer[T] 各存各的;1.19 后泛型类型(Int64/Pointer[T])取代手写对齐的 int64 字段。
Interview QA · 2/2
read 是 atomic 指针指的只读 map,Load 命中零锁;dirty 是含新 key 的全量表,由 mu 保护。Load miss 计数达 len(dirty) 时整表提升为 read。entry 用原子指针持值——改值只动指针,read 不失效。
官方两个场景:key 写后基本不变;多 goroutine 操作不相交的 key 集合。写新 key 频繁 → 反复 miss + dirty 全量重建,比 Mutex+map 慢。默认 mutex+map,benchmark 证明读多写少再换。
GC 时 local 降级为 victim、旧 victim 丢弃——对象最多活两个 GC 周期。所以是减 GC 压力的软缓存:连接等有状态资源不能用(被静默收走),只放无状态纯缓冲;Get 顺序 private→shared→偷→victim→New。
单变量的计数/标志/指针替换用 atomic(用户态指令、不调度);需要"多字段一起变"的复合不变量必须 mutex。atomic 全部顺序一致性,Go 不开 acquire/release;CAS 循环自己写容易出 ABA 与忙等——能用 mutex 就别炫技。
Wait 返回第一个非 nil error(其余 goroutine 仍等完);WithContext 后首个 error cancel ctx——扇出取消标准姿势。Go() 内部 recover、Wait 时 re-panic:跨 goroutine panic 有了官方通道。SetLimit 信号量限流。
copylocks:锁按值拷贝(noCopy);waitgroup:Add 位置错(1.25 新分析器);lostcancel:context cancel 被丢。死锁类(重入、双锁)vet 查不了——靠 -race、压测与 review。CI 里 vet + -race 是并发代码最低配置。
保护的是数据/不变量,不是那几行代码——心智模型错了就会漏锁(不同路径访问同字段却走不同锁)。defer Unlock 保证路径完备但把临界区拉到函数尾:长尾操作(IO/RPC)放锁外,先拷出数据再 defer。
Related & References
channel 底层实现 →(hchan 的锁与 sudog 队列,同一信号量机制)
GMP 调度模型 →(sema 挂起/唤醒与 G 的调度交互)
内存分配与逃逸分析 →(per-P 缓存思想同源)
defer/panic/recover →(errgroup 的 panic 传递、OnceFunc 与 panic)
OS · 同步与互斥 →(锁的内核侧:自旋/睡眠权衡与 futex 快慢两阶段)
Mutex → state 位图 → CAS 快路径 → 自旋 → 1ms 饥饿
RWMutex → 负数 readerCount → 写优先 → 不可递归
WaitGroup/Once → 计数+信号量 → 配对规则 → panic 语义
sync.Map/Pool → 结构决定场景 → 误用清单 + vet 四件套
参考来源(全部结论可溯源)
| src/sync/mutex.go(对照 Go 1.27) | state 位布局、lockSlow 自旋条件、正常/饥饿模式切换(含 "Mutex fairness" 注释) |
| src/sync/rwmutex.go | readerCount 负数哨兵、rwmutexMaxReaders、readerWait 清场 |
| src/sync/{waitgroup,once,pool,map}.go | 64 位计数、done 先置位、victim 两级清理、read/dirty 与 expunged |
| pkg.go.dev/sync | RWMutex 写优先与递归警告、Once.Do panic 语义、Pool 的 GC 行为、WaitGroup 重用规则原文 |
| go.dev/doc/go1.19 · go1.21 · go1.23 · go1.25 | 泛型 atomic 类型 / OnceFunc 系 / atomic.And、Or / WaitGroup.Go 与 vet waitgroup 分析器 |
| pkg.go.dev/golang.org/x/sync/errgroup | Wait 首个 error、WithContext 级联取消、SetLimit、panic 传递 |