sync同步原语剖析
# 10.sync同步原语剖析
卷三第十篇——Go 的
sync包只有 6 个核心类型,但Mutex的 state 字段里藏着自旋、饥饿、信号量三层机制。RWMutex的读偏向会让写者饿死?Once的双重检查锁真的安全吗?Pool怎么利用 victim cache 躲过 GC?读完本篇,你会把sync/mutex.go的每个 bit 位都刻进脑子里。关键词:Mutex 状态机、自旋、饥饿模式、RWMutex 读偏向、Once double-check、Pool victim cache。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Mutex:自旋→信号量→饥饿
- 4. RWMutex:读偏向的代价
- 5. WaitGroup:64 位原子分身
- 6. Once:最快的双重检查锁
- 7. Pool:与 GC 共舞的对象缓存
- 8. Cond:条件变量的广播与信号
- 9. Mutex 常见陷阱 Top 5
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一个配置中心微服务——用 sync.Once 做配置懒加载 + sync.WaitGroup 协调启动任务:
package main
import (
"fmt"
"sync"
"time"
)
type ConfigService struct {
once sync.Once
config map[string]string
mu sync.Mutex
}
func (s *ConfigService) Load() error {
s.once.Do(func() {
fmt.Println("loading config...")
// 模拟:从远程加载配置需要 3 秒
time.Sleep(3 * time.Second)
// BUG:加载配置时也需要加锁写入
// 但 s.mu 可能已被另一个 goroutine 持有!
s.mu.Lock()
s.config = map[string]string{
"host": "localhost",
"port": "8080",
}
s.mu.Unlock()
})
return nil
}
func (s *ConfigService) Get(key string) string {
// 先获取读锁——确保 Load 完成
s.mu.Lock()
defer s.mu.Unlock()
if s.config == nil {
// 配置尚未加载 → 触发加载
s.Load()
}
return s.config[key]
}
func main() {
svc := &ConfigService{}
var wg sync.WaitGroup
// 启动时多个 goroutine 同时请求配置
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
val := svc.Get(fmt.Sprintf("key_%d", id))
fmt.Printf("goroutine %d got: %s\n", id, val)
}(i)
}
// 主 goroutine 也来获取
wg.Add(1)
go func() {
defer wg.Done()
val := svc.Get("host")
fmt.Printf("main got: %s\n", val)
}()
wg.Wait()
fmt.Println("all done")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
现象:服务启动后卡死——所有 goroutine 阻塞,进程不退出:
goroutine 6 [sync.Mutex.Lock]:
main.(*ConfigService).Get(...)
/app/config.go:28 +0x68
goroutine 7 [sync.Once.Do]:
main.(*ConfigService).Load.func1(...)
/app/config.go:20 +0xa5
2
3
4
5
6
7
死锁! 第一个 goroutine 在 Get 中持有 s.mu.Lock(),发现 s.config == nil,然后调用 s.Load()。Load 中 once.Do 的回调里也需要 s.mu.Lock()——但此时 s.mu 已经被当前 goroutine 持有了——同一个 goroutine 对同一把 Mutex 重复 Lock 导致死锁。
更糟的是:第二个 goroutine 也到达 Get,试图 s.mu.Lock()——但锁被第一个持有——它也阻塞了。所有 6 个 goroutine 全部堵死。
# 1.2 顺藤摸到根因
逐步排查:
假设 1:Mutex 是不是递归锁(reentrant)?——Go 的 sync.Mutex 不是递归锁。同一个 goroutine 重复 Lock 会死锁。Java 的 ReentrantLock 允许同一线程多次 lock,但 Go 选择不提供——因为递归锁是"设计缺陷的创可贴"(Russ Cox 语)。
假设 2:为什么不在 Load 的回调中不加锁?——因为 Load 负责填充 s.config,这是写操作,需要互斥保护。但设计缺陷在于:Once.Do 的回调不应该持有调用者已经持有的锁。
假设 3:WaitGroup 的使用正确吗?——wg.Add(1) 和 go func() { defer wg.Done() } 的配对没有问题。WaitGroup 本身不是 bug 原因。
死锁链条:
- Goroutine A 调用
Get()→mu.Lock()成功 → 发现config==nil→ 调用Load() Load()→once.Do(f)→ 执行函数 f → f 中mu.Lock()→ 死锁(同一 G 重复 Lock)- Goroutine B~F 调用
Get()→mu.Lock()→ 阻塞(等待 Goroutine A 释放) - 所有 goroutine 阻塞 → 进程挂起
# 1.3 我们要回答什么
这个事故藏着至少 8 个原理点:
① sync.Mutex 的 state 字段怎么用 3 个 bit + 1 个 int 管理所有状态? → 第 3 章
② Mutex 的自旋是什么?在什么条件下自旋?什么时候放弃自旋走信号量? → 第 3.3
③ 饥饿模式解决什么问题?Go 怎么判断"该切换到饥饿模式了"? → 第 3.4
④ RWMutex 为什么偏爱读者?怎么写者会被饿死? → 第 4 章
⑤ WaitGroup 怎么用 64 位原子操作无锁管理 counter + waiter? → 第 5 章
⑥ Once 的双重检查锁(fast path + slow path)怎么做到毫秒级延迟? → 第 6 章
⑦ sync.Pool 的 victim cache 怎么帮对象"躲过 GC"? → 第 7 章
⑧ Go 的 Mutex 为什么不设计成递归锁? → 第 9 章
2
3
4
5
6
7
8
本篇路线:
架构总图 (第 2 章) ── sync 包六大原语全景
↓
Mutex (第 3 章) ── state 位域 + 自旋 + 饥饿 + 信号量
↓
RWMutex (第 4 章) ── 读偏向 + 写饥饿防御
↓
WaitGroup (第 5 章) ── 64 位原子分身 + 时序保证
↓
Once (第 6 章) ── fast/slow 双重检查锁
↓
Pool (第 7 章) ── per-P 本地池 + victim cache
↓
Cond + 陷阱 (第 8-9 章) ── 条件变量 + Mutex 五大陷阱
↓
综合案例 (第 10 章) ── 完整复原 + 修复 + 设计哲学
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:这是 Go「并发编程」主题的第二篇。上接 channel 的 CSP 模型,本篇进入共享内存的世界——用锁来保护临界区。虽然 Go 的口号是"用通信共享内存",但现实世界中
sync.Mutex的使用量远超channel。理解它的内部实现是排查锁竞争、死锁、饥饿的前提。
# 2. 架构概览
# 2.1 sync 包全景
sync 包的核心类型:
┌──────────────────────────────────────────────────────────────┐
│ Mutex 互斥锁 │
│ state: int32 ← 4 个 bit 位 + 28 位 waiter 计数 │
│ sema: uint32 ← runtime 信号量 │
│ │
│ RWMutex 读写锁 │
│ w: Mutex ← 写锁(底层就是 Mutex) │
│ writerSem: uint32 ← 写者信号量 │
│ readerSem: uint32 ← 读者信号量 │
│ readerCount: int32 ← 当前读者数 + 写者等待标志 │
│ readerWait: int32 ← 写者等待读者完成的数量 │
│ │
│ WaitGroup 等待组 │
│ state: [12]byte ← 8 字节 counter + 4 字节 waiter 原子 │
│ │
│ Once 一次性执行器 │
│ done: uint32 ← 原子标志位 │
│ m: Mutex ← 慢路径锁 │
│ │
│ Pool 对象池 │
│ local: [P]poolLocal ← per-P 本地池 │
│ victim: [P]poolLocal ← victim cache(跨 GC 周期存活) │
│ New: func() interface{} ← 对象工厂 │
│ │
│ Cond 条件变量 │
│ L: Locker ← 关联的锁 │
│ notifyList ← 等待通知的 G 列表 │
└──────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 2.2 为什么 Go 需要自己的同步原语
疑惑:Linux 已经有 futex(fast userspace mutex),Go 为什么不直接用?
论证:
futex和 goroutine 不兼容——futex阻塞的是 OS 线程(M),不是 goroutine(G)。Go 的Mutex.Lock阻塞时应该让 G 挂起,让 M 去执行其他 G——如果阻塞了 M,P 上的其他 G 都被饿死。G 粒度的休眠与唤醒——Go 的
Mutex用runtime_Semacquire(runtime 的信号量)而不是 OS 的futex。runtime_Semacquire调用gopark挂起 G——不是阻塞线程——让 P 可以调度其他 G。零值可用——Go 的
sync.Mutex零值就是 unlocked 状态(state=0),不需要pthread_mutex_init。这在 §7 篇已经论证过——Go 的类型零值哲学。自旋优化——Go 的 Mutex 在特定条件下自旋(空转 CPU 等待),而不立即休眠。自旋可以避免昂贵的
gopark+上下文切换,但只适用于"锁很快会释放"的场景。futex没有这个智能。反向验证——如果在 Go 中使用 C 的
pthread_mutex,每次锁竞争都会阻塞 OS 线程 → 需要创建新线程来处理其他 G → GMP 调度的线程池模型被破坏 → 线程数暴涨。
结论:Go 的 sync.Mutex 表面看是"重新发明轮子",实则是 GMP 调度的必然要求——锁必须作用在 G 上而非线程上。
# 3. Mutex:自旋→信号量→饥饿
# 3.1 state 字段的 4 个 bit 位
// sync/mutex.go
type Mutex struct {
state int32 // 状态位 + 等待者计数
sema uint32 // runtime 信号量
}
2
3
4
5
state 的位布局(32 位):
31 3 2 1 0
┌────────────────────────────────────┬───┬───┬───┬───┐
│ waiterCount (29 bits) │ S │ W │ S │ L │
│ 等待者的数量 │ t │ o │ t │ o │
│ │ a │ k │ a │ c │
│ │ r │ e │ r │ k │
│ │ v │ n │ v │ e │
│ │ i │ │ i │ d │
│ │ n │ │ n │ │
│ │ g │ │ g │ │
└────────────────────────────────────┴───┴───┴───┴───┘
bit 0: Locked ← 1 = 已加锁
bit 1: Starving ← 1 = 饥饿模式(后续 Lock 请求直接排到队尾)
bit 2: Woken ← 1 = 有 goroutine 正在被唤醒
bit 3: 已废弃(Go 1.9 前用于其他用途)
bit 4-31: waiterCount ← 自旋 + 信号量等待的 goroutine 总数
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
四种操作的原子性:
const (
mutexLocked = 1 << iota // 1 → bit 0
mutexWoken // 2 → bit 1
mutexStarving // 4 → bit 2
mutexWaiterShift = iota // 3 → waiterCount 从 bit 3 开始
)
// 原子操作
func (m *Mutex) Lock() {
// 快速路径:CAS state: 0 → mutexLocked
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // 拿到了!不需要进入慢路径
}
m.lockSlow() // 慢路径
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.2 Lock 的完整状态机
Lock() 的慢路径 lockSlow():
G 想要 Lock:
├─ state 的 Locked=0 且 Starving=0(正常模式未加锁)
│ └─ 直接 CAS(0, Locked) → 返回
│
├─ state 的 Locked=1(已加锁)
│ │
│ ├─ 是否允许自旋?
│ │ 条件:1) 运行在多核机器上
│ │ 2) GOMAXPROCS > 1
│ │ 3) 当前 P 的本地队列为空
│ │ 4) 自旋次数未超过上限(active_spin=4)
│ │
│ ├─ 允许自旋 → 自旋等待(空转 CPU 30 个 PAUSE 指令)
│ │ └─ 每次自旋后重新检查 state→Locked 是否变 0
│ │ └─ 自旋 4 次后仍未拿到 → 停止自旋
│ │
│ └─ 不自旋或自旋用完 → waiterCount++
│ │
│ ├─ 正常模式 → runtime_SemacquireMutex(&m.sema, ...)
│ │ └─ G 挂起(gopark)→ 等待被唤醒
│ │
│ └─ 饥饿模式 → 直接入队
│
│ 被唤醒后:
│ ├─ Starving=1(饥饿模式)→ 一定拿到锁
│ │ └─ if waiterCount==1 → 退出饥饿模式(Starving=0)
│ └─ Starving=0(正常模式)→ 和新的 G 竞争 CAS
│ ├─ 竞争成功 → 拿到锁
│ └─ 竞争失败 → 再次循环(waiterCount 已增加)
│
└─ 进入饥饿模式的条件:
waiter 等待时间 > 1ms
→ Starving=1 → 后续 Lock 不再自旋,直接排到队尾
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 3.3 自旋的成本与收益
疑惑:自旋就是 CPU 空转——浪费 CPU 但可能减少延迟。Go 怎么决定"该不该自旋"?
论证:
自旋的条件非常苛刻——必须是多核机器 + GOMAXPROCS>1 + 当前 P 的本地队列空 + 最多自旋 4 次。如果 P 的本地队列不空,说明有 G 等着执行——自旋不如让出自己的 CPU 去执行其他 G。
单次自旋 = 30 个
PAUSE指令——PAUSE指令告诉 CPU "我在自旋",CPU 会优化功耗并提示超线程的兄弟逻辑核。每次自旋大约 ~10ns。自旋的最大收益——临界区极短(几个指令)的场景下,自旋避免了
gopark+goready的上下文切换(~1μs)。4 次自旋 ≈ 40ns < 上下文切换的 1000ns → 值得。自旋必须配合
Woken标志——当某个自旋的 G 发现锁释放时,它只唤醒一个等待者而不是全部(通过 Woken 标志协调)。如果锁释放时 Woken=1,就不再做runtime_Semrelease——避免惊群效应。反向验证——如果去掉自旋,高并发下的短临界区性能会下降 30%~50%。
sync.Mutex的基准测试(benchmark)明确显示了自旋对吞吐量的提升。
结论:自旋是"用 CPU 换延迟"的经典权衡,Go 的 30 个 PAUSE × 4 次 = ~120ns 上限保证自旋不会太贪。
# 3.4 饥饿模式防止尾部延迟
饥饿问题的产生——正常模式下,刚醒来的 waiter 要和新来的 goroutine 竞争 CAS。如果新来的 goroutine 持续到达(因为它们在运行中,已经持有 CPU),waiter 可能一直竞争失败——永远拿不到锁。
饥饿模式的触发(sync/mutex.go):
// 等待时间超过 1ms → 切换到饥饿模式
starving = starving || runtime_nanotime()-waitStartTime > 1e6 // 1ms
2
饥饿模式的行为:
Starving=1时,新来的 Lock 请求不尝试自旋,不尝试 CAS——直接排队(waiterCount++,挂到信号量上)- 锁释放时,直接将锁交给队首的 waiter——不需要 CAS 竞争
- 最后一个 waiter 拿到锁 + waiterCount==1 →
Starving=0,退出饥饿模式
设计意图:饥饿模式不是"性能优化"——是公平性的保证。它牺牲吞吐量(强制排队),换取尾延迟的可预测性。
# 4. RWMutex:读偏向的代价
# 4.1 读写锁的状态结构
// sync/rwmutex.go
type RWMutex struct {
w Mutex // 写锁——保护 readerCount 和 readerWait
writerSem uint32 // 写者等待信号量
readerSem uint32 // 读者等待信号量
readerCount int32 // 正数=当前读者数;负数=有写者在等待
readerWait int32 // 写者需要等待的读者数
}
2
3
4
5
6
7
8
关键字段解读:
readerCount int32——正数时表示活跃读者数。当写者尝试获取写锁时,将 readerCount 减去 rwmutexMaxReaders(一个很大的负数),使其变为负数。后续 RLock 看到 readerCount < 0 → 知道有写者在等待 → 阻塞。
readerWait int32——写者获取写锁前,记录当前活跃的读者数。写者等待这些读者全部 RUnlock 后才真正获得写锁。
# 4.2 读锁与写锁的互斥博弈
RLock(读者加锁):
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// 有写者正在等待!→ 本读者阻塞
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
// readerCount >= 0 → 没有写者在等 → 直接进入
}
2
3
4
5
6
7
Lock(写者加锁):
func (rw *RWMutex) Lock() {
// 1. 获取写锁(w.Lock)——阻止其他写者
rw.w.Lock()
// 2. 宣布"有写者来了"
// readerCount -= rwmutexMaxReaders → 变成负数
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 3. 如果有活跃读者(r != 0),等待它们完成
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
// 所有读者已完成 → 写者持有锁
}
2
3
4
5
6
7
8
9
10
11
12
13
14
RUnlock(读者释放):
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// readerCount < 0 → 有写者在等待
rw.rUnlockSlow(r)
}
}
func (rw *RWMutex) rUnlockSlow(r int32) {
// readerWait-- → 如果为 0 → 唤醒写者
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 4.3 写饥饿与读者的让步
读者优先的代价——因为 RLock 只需要原子加 readerCount(不需要互斥锁),新读者在旧读者未释放时可以持续加入。如果读者源源不断,readerCount 永不归零 → 写者永远等不到 readerWait=0 → 写者饥饿。
RWMutex 的缓解策略:当 readerCount < 0(有写者在等),新读者会在 RLock 中的 atomic.AddInt32(&rw.readerCount, 1) 后检测到结果为负 → 阻塞在 readerSem 上。但已经持有读锁的读者不会被中断——它们需要执行完临界区才能 RUnlock。
结论:RWMutex 不能完全防止写者饥饿,只能防止"新读者在写者等待后继续加入"。如果你的场景写者数量不可忽略——考虑用 Mutex 代替 RWMutex。
# 5. WaitGroup:64 位原子分身
# 5.1 counter + waiter 的原子封装
// sync/waitgroup.go
type WaitGroup struct {
noCopy noCopy // 防拷贝检查(vet 工具)
// 64 位原子值:高 32 位 = counter,低 32 位 = waiter
state1 [12]byte
// 根据 8 字节对齐,semap 嵌入在 state1 的前后
}
2
3
4
5
6
7
8
为什么 state1 是 [12]byte 而不是两个 uint32——因为 32 位系统上的 64 位原子操作要求 8 字节对齐。Go 的 hack:用 12 字节数组,运行时根据地址对齐选择 state 和 sema 的偏移。详情见 sync/waitgroup.go 的 state() 方法。
Add 和 Done 是对 state 的原子操作:
func (wg *WaitGroup) Add(delta int) {
// 1. 原子加 delta 到 counter(高 32 位)
state := atomic.AddUint64(statep, uint64(delta)<<32)
v := int32(state >> 32) // counter
w := uint32(state) // waiter
if v < 0 { panic("sync: negative WaitGroup counter") }
if v > 0 || w == 0 { return }
// 2. counter == 0 且有 waiter → 唤醒所有 waiter
*statep = 0 // 重置 state
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
}
func (wg *WaitGroup) Wait() {
for {
// CAS: waiter++(低 32 位)
state := atomic.LoadUint64(statep)
v := int32(state >> 32)
if v == 0 { return } // counter=0 → 不需要等待
if atomic.CompareAndSwapUint64(statep, state, state+1) {
runtime_Semacquire(semap) // 阻塞
return
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 5.2 Add 与 Wait 的时序保证
WaitGroup 的关键保证:Add 必须在 Wait 之前调用(至少"最后一次 Add 在 Wait 之前"不能保证时会导致 race):
// ❌ 错误:Wait 可能在 Add 之前执行
var wg sync.WaitGroup
go func() { wg.Wait() }() // Wait 可能先于 Add 执行 → counter=0 → 直接返回
wg.Add(1) // 太晚了——Wait 已经返回了
// ✅ 正确:Add 在 go 之前
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); ... }()
wg.Wait()
2
3
4
5
6
7
8
9
10
# 5.3 WaitGroup 常见的三种误用
- Wait 后重用——Wait 返回后 counter 不是 0(新的 Add 还没生效),不能立即再次 Wait
- Add 负数溢出——
wg.Add(-1)时 counter 已经为 0 → panic: negative counter - 复制 WaitGroup——
newWg := wg会复制内部 state——两个 WaitGroup 各自管理各自的 counter
# 6. Once:最快的双重检查锁
# 6.1 fast path → slow path 的双层设计
// sync/once.go
type Once struct {
done uint32 // 原子标志 0→1
m Mutex // 慢路径锁
}
func (o *Once) Do(f func()) {
// 快速路径:原子读 done——无锁
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 双重检查——可能另一个 goroutine 已经执行了 f
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
性能分析:
Do的最常见路径(已初始化):一次原子 Load(~5ns)+ 一次条件跳转- 未初始化路径:
m.Lock()(~20ns)+ 函数执行 +defer - 这就是经典的双重检查锁(Double-Checked Locking)模式——Go 版本是无 bug 的(因为
atomic保证了内存序)
和 Java 的对比:Java 的 DCL 在 C++03 时代因为内存模型问题需要 volatile 修正,Go 的 atomic.LoadUint32 自带 acquire 语义——无需额外修饰。
# 6.2 Once 死锁的根因
回到第 1 章的案例——Once 死锁的根因是 f() 内部尝试获取调用者已持有的锁:
s.mu.Lock() // Goroutine A 持有锁
s.Load() // Load 内部: once.Do(func() { s.mu.Lock() }) ← 死锁
2
Once.Do 中的 f() 是同步执行的——不能是异步函数。如果 f 内部需要获取调用者已持有的锁——死锁。
修复:将锁的获取放在 Once 的回调之外:
func (s *ConfigService) Get(key string) string {
// 先确保初始化完成
s.Load() // 这一行不持锁
s.mu.RLock()
defer s.mu.RUnlock()
return s.config[key]
}
2
3
4
5
6
7
8
# 7. Pool:与 GC 共舞的对象缓存
# 7.1 per-P 本地池 + victim cache
// sync/pool.go
type Pool struct {
noCopy noCopy
local unsafe.Pointer // [P]poolLocal
localSize uintptr
victim unsafe.Pointer // 上一轮的 local(给 GC 用)
victimSize uintptr
New func() interface{}
}
type poolLocal struct {
poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte // 防止 false sharing
}
type poolLocalInternal struct {
private interface{} // 仅当前 P 访问——无锁(最快)
shared poolChain // 无锁双向链表——当前 P push/pop,其他 P 可 pop
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Get 的三级查找:
private:当前 P 独享——无锁,最快shared头部:当前 P 的无锁队列——快victim cache:上一轮 GC 时保存的对象——慢,但避免重新分配
# 7.2 GC 周期中的 Pool 清理
// sync/pool.go
func poolCleanup() {
// 两轮 GC 周期的对象生命周期:
// GC 1: 所有 Pool 对象 → victim cache
// GC 2: victim cache → 清空
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
victim cache 的精妙:对象不是"一次 GC 就清除"——而是"给一次机会"。在第一个 GC 周期被移到 victim,如果 Get 在下一个 GC 之前命中了 victim,对象继续存活——这样可以度过"GC 期间的对象空窗期"。
# 7.3 Pool 的正确使用姿势
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}
func process(data []byte) {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf)
// 使用 buf
copy(buf, data)
// ...
}
// ❌ 错误:Put 后继续使用
buf := pool.Get().(*MyStruct)
pool.Put(buf)
buf.DoSomething() // 其他 G 可能已经拿到了这个 buf——data race!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 8. Cond:条件变量的广播与信号
# 8.1 Cond 的结构与原理
// sync/cond.go
type Cond struct {
noCopy noCopy
L Locker // 关联的锁(通常是 *Mutex 或 *RWMutex)
notify notifyList // 等待者列表
}
2
3
4
5
6
notifyList 是 runtime 维护的等待队列:
// runtime/sema.go
type notifyList struct {
wait uint32 // 当前等待计数
notify uint32 // 已通知计数
lock mutex // 保护列表的锁
head *sudog // 等待者链表头
tail *sudog // 等待者链表尾
}
2
3
4
5
6
7
8
# 8.2 Wait/Signal/Broadcast 的实现
func (c *Cond) Wait() {
t := runtime_notifyListAdd(&c.notify) // 加入等待列表,获取 ticket 号
c.L.Unlock() // 释放锁
runtime_notifyListWait(&c.notify, t) // 挂起——等待 ticket 被通知
c.L.Lock() // 被唤醒后重新拿锁
}
func (c *Cond) Signal() {
runtime_notifyListNotifyOne(&c.notify) // 唤醒一个 waiter
}
func (c *Cond) Broadcast() {
runtime_notifyListNotifyAll(&c.notify) // 唤醒所有 waiter
}
2
3
4
5
6
7
8
9
10
11
12
13
14
Wait 为什么要释放锁——不释放锁的话,Signal 的调用者无法拿到锁来改变条件变量对应状态。这是条件变量的标准语义:Wait 原子地释放锁并挂起当前 goroutine。
为什么 Wait 返回后不需要重新检查条件——Go 的 Wait 不保证"spurious wakeup"不会发生——被唤醒不等价于条件已满足。所以标准模式永远用 for 而不是 if:
// ✅ 正确——用 for 循环防止假唤醒
for condition == false {
cond.Wait()
}
// ❌ 错误——假唤醒会导致逻辑错误
if condition == false {
cond.Wait()
}
2
3
4
5
6
7
8
9
# 8.3 Cond 的典型应用场景
场景 1:生产者唤醒消费者:
var mu sync.Mutex
cond := sync.NewCond(&mu)
items := []string{}
// 消费者
go func() {
cond.L.Lock()
for len(items) == 0 {
cond.Wait() // 等待通知——自动释放锁并重新获取
}
item := items[0]
items = items[1:]
cond.L.Unlock()
}()
// 生产者
cond.L.Lock()
items = append(items, "new item")
cond.L.Unlock()
cond.Signal() // 唤醒一个消费者
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
场景 2:Broadcast 广播——优雅关闭:
var done bool
cond := sync.NewCond(&sync.Mutex{})
// 多个 worker goroutine
for i := 0; i < 10; i++ {
go func() {
cond.L.Lock()
for !done {
cond.Wait() // 等待关闭信号
}
cond.L.Unlock()
// 收到关闭信号 → 退出
}()
}
// 关闭所有 worker
cond.L.Lock()
done = true
cond.L.Unlock()
cond.Broadcast() // 唤醒所有 worker
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 9. Mutex 常见陷阱 Top 5
- Mutex 非递归锁——同一 goroutine 重复 Lock 会死锁
- Mutex 结构体值复制——
newMutex := oldMutex复制了内部 state——两个独立的锁 - Lock 后忘记 Unlock——特别是在
if/return路径中 - RWMutex 的写者饥饿——读密集场景下写者可能永远等不到
- Mutex 的零值可用——但也意味着"任何时候都可以做 Lock 操作"——容易遗漏初始化
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章配置中心死锁——8 个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① Mutex state 位域? | 第 3.1:31 位 waiter 计数 + 3 个控制位(Locked/Woken/Starving) |
| ② 自旋的条件? | 第 3.3:多核 + P 本地队列空 + 最多 4 次 × 30 PAUSE |
| ③ 饥饿模式触发条件? | 第 3.4:等待时间 > 1ms → 强制排队,防止尾延迟 |
| ④ RWMutex 读者优先? | 第 4 章:RLock 只原子加 readerCount,新读者可抢在写者之前 |
| ⑤ WaitGroup 原子封装? | 第 5.1:64 位原子操作——高 32 位 counter,低 32 位 waiter |
| ⑥ Once 双重检查? | 第 6.1:fast path 无锁原子读 done,slow path Mutex + 双重检查 |
| ⑦ Pool victim cache? | 第 7.2:对象在 GC 中先移到 victim,下一个 GC 才清除 |
| ⑧ Mutex 为什么不递归? | 第 9 章:递归锁掩盖设计问题——Go 选择暴露而非隐藏 |
案例完整修复:
// 修复 1:Load 不在 Get 持锁时调用
func (s *ConfigService) Get(key string) string {
s.Load() // 确保初始化完成(不在 mu 锁内!)
s.mu.RLock()
defer s.mu.RUnlock()
return s.config[key]
}
// 修复 2:一次加载完成(Once 语义正确)
func (s *ConfigService) Load() error {
s.once.Do(func() {
// 在 Once 回调内完成初始化——不需要额外的锁
s.config = map[string]string{
"host": "localhost",
"port": "8080",
}
})
return nil
}
// 修复 3:WaitGroup 正确配对
func main() {
svc := &ConfigService{}
var wg sync.WaitGroup
// Add 必须在 go 之前
wg.Add(5)
for i := 0; i < 5; i++ {
go func(id int) {
defer wg.Done()
svc.Get(fmt.Sprintf("key_%d", id))
}(i)
}
wg.Wait()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 10.2 一组锁的完整生命周期
var mu sync.Mutex // state=0 (unlocked, no waiters)
mu.Lock()
───────────────────────────────────────────────────────────
│ Goroutine A: mu.Lock()
│ CAS(0, 1) → 成功!state=1 (Locked=1)
│ → 进入临界区
│
│ Goroutine B: mu.Lock()
│ CAS(1, 2) → 失败(state 中的 Locked 已经为 1)
│ → 进入 lockSlow
│ → 尝试自旋(4 次 PAUSE)
│ → 自旋失败 → waiterCount++ → state 变为 0b...01001
│ → waiterCount=1 (bit 3-31), Woken=0, Starving=0, Locked=1
│ → runtime_Semacquire(&mu.sema)
│ → gopark(G_B) ← G_B 挂起
│
│ Goroutine C: mu.Lock()
│ → 同样自旋失败 → waiterCount++ → state=0b...10001
│ → waiterCount=2, Locked=1
│ → runtime_Semacquire → gopark(G_C)
│
│ Goroutine A: mu.Unlock()
│ → atomic.AddInt32(&state, -mutexLocked) → state=0b...10000
│ → Locked=0!检查是否有 waiter:
│ → waiterCount=2 > 0
│ → 如果 Woken=0 → runtime_Semrelease(&mu.sema)
│ → 唤醒 G_B (不是 G_C——只唤醒一个)
│ → goready(G_B)
│
│ G_B 被唤醒:
│ → 从 gopark 后继续执行
│ → CAS(state, Locked) → 成功!state=0b...01001
│ → waiterCount=1, Locked=1
│ → 进入临界区
│
│ G_B: mu.Unlock()
│ → 同样释放锁 → 唤醒 G_C
│ → goready(G_C)
│
│ 如果 B 等待超过 1ms → Starving=1
│ → 后续 Lock 不再自旋 → 直接排到队尾
│ → Unlock 时直接交给队首 waiter
│
│ 最后一个 waiter 拿到锁后:
│ → Starving=0(退出饥饿模式)
│ → waiterCount=0 → state=1 (仅 Locked)
└───────────────────────────────────────────────────────────
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 10.3 设计哲学回扣
哲学 1:自旋是"鲁莽的乐观"——用 CPU 换延迟
Go 的 Mutex 自旋设计是一种"乐观锁"思维——先假设锁很快会释放,用 120ns 的 CPU 空转换 1000ns 的上下文切换。但乐观有上限:4 次自旋后放弃,走信号量挂起。这种"乐观但有节制"的设计,在短临界区场景下性能提升 30%+,在长临界区场景下不浪费 CPU。
哲学 2:饥饿模式是"公平的强制"——牺牲吞吐量换尾延迟
正常模式下 CAS 竞争让 fast path 总是赢——这带来了高吞吐量,但可能让某个 waiter 永远拿不到锁(尾部延迟不可控)。饥饿模式强制排队——牺牲吞吐量,但保证每个 waiter 最多等 1ms 就能拿到。这是 Go 对"性能公平性"的注解。
哲学 3:不提供递归锁——暴露设计问题而非隐藏
几乎所有需要递归锁的场景,都可以通过重新设计"锁的边界"来解决。递归锁是创可贴——它让设计缺陷继续潜伏。Go 的选择是:让重复 Lock 死锁——强迫开发者重新思考临界区的大小。一旦你调整好锁的边界,代码逻辑反而更清晰。
哲学 4:GC 协作——让对象池和运行时共生
sync.Pool 的 victim cache 机制让对象在 GC 中活到第二个周期——这不是"抵抗 GC",而是"和 GC 协作"。第一个 GC 把对象移到 victim,告诉运行时"这些可能还在用";如果第二个 GC 前确实无人取走,再真正回收。这种两层缓冲让 Pool 的缓存命中率在 GC 期间几乎不降。
# 10.4 速查表
Mutex state 位:
| 位 | 含义 | 何时设置 | 何时清除 |
|---|---|---|---|
| 0 (Locked) | 已加锁 | Lock CAS 成功 | Unlock |
| 1 (Woken) | 有 G 在唤醒 | Semrelease 前 | 被唤醒的 G 拿到锁 |
| 2 (Starving) | 饥饿模式 | 等待 > 1ms | 最后一个 waiter 拿到锁 |
| 3-31 | waiterCount | 自旋失败/进入信号量 | 被唤醒 |
Mutex vs RWMutex:
| 特性 | Mutex | RWMutex |
|---|---|---|
| 写锁 | Lock/Unlock | Lock/Unlock |
| 读锁 | 无 | RLock/RUnlock |
| 内部实现 | state + sema | Mutex + readerCount + readerSem + writerSem |
| 适用场景 | 读写都频繁 | 读远多于写 |
| 写者饥饿风险 | 无(自旋/饥饿机制) | 有(读者持续加入) |
| 内存占用 | 8 字节 | 24 字节 |
WaitGroup/Once/Pool:
| 类型 | 核心机制 | 零值可用? | 注意事项 |
|---|---|---|---|
| WaitGroup | 64 位原子: counter + waiter | ✅ | Add 必须在 Wait 前 |
| Once | 原子 done + Mutex 双重检查 | ✅ | f() 内不能获取调用者持有的锁 |
| Pool | per-P 本地池 + victim cache | ✅ | GC 会清空;Put 后不再使用 |
| Cond | notify 列表 + 锁协作 | ❌ (需 NewCond) | Wait 必须在 Lock/Unlock 之间 |
诊断命令:
# Mutex 竞争分析
go test -race ./... # 检测 data race
go test -mutexprofile=mutex.out ./... # 生成 mutex profile
go tool pprof mutex.out # 查看锁竞争
# Goroutine 阻塞分析
curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep "sync"
# 查看卡在 sync.Mutex.Lock / sync.WaitGroup.Wait / sync.Once.Do 的 goroutine
# 调度器跟踪
GODEBUG=schedtrace=1000 ./app # 查看 G 状态——_Gwaiting 过多 → 锁竞争
2
3
4
5
6
7
8
9
10
11
下一篇:我们已经看清了 sync 包的全部分——Mutex 的自旋饥饿、Once 的双重检查、Pool 的 victim cache,下一步进入 11.map并发安全与哈希——把 map 的并发安全、
sync.Map的读写分离、分段锁的自建方案剖开。