Table of contents
Open Table of contents
TL;DR
sync 包的每个原语都围绕同一个核心模式:fast path 用 atomic 无锁操作,slow path 用 runtime semaphore 休眠/唤醒。Mutex 通过 normal/starvation 双模式平衡吞吐与尾延迟;sync.Map 在 Go 1.24 重写为 HashTrieMap(并发哈希字典树);sync.Pool 利用 per-P 本地存储 + victim cache 实现两轮 GC 缓冲。理解这些内部结构,才能在正确的场景选择正确的原语。
1. sync.Mutex 内部机制
1.1 state 字段位布局
Mutex 的核心是一个 int32 类型的 state 字段,用位运算打包了四种信息:
┌─────────────────────────────────────────────────┬───────────┬────────┬────────┐
│ waiter count (29 bits) │ starvation│ woken │ locked │
│ bits [31:3] │ bit [2] │ bit [1]│ bit [0]│
└─────────────────────────────────────────────────┴───────────┴────────┴────────┘
源码常量定义(sync/mutex.go):
const (
mutexLocked = 1 << iota // 1 — bit 0: 是否被锁定
mutexWoken // 2 — bit 1: 是否有 goroutine 被唤醒
mutexStarving // 4 — bit 2: 是否处于饥饿模式
mutexWaiterShift = iota // 3 — waiter 计数从 bit 3 开始
)
state & mutexLocked:锁是否被持有state & mutexWoken:是否有 goroutine 从 sleep 中被唤醒(用于抑制不必要的唤醒)state & mutexStarving:是否处于饥饿模式state >> mutexWaiterShift:等待者数量(最多 ~5 亿个 goroutine,远超实际场景)
1.2 Normal 模式 vs Starvation 模式
这是 Mutex 最关键的设计决策——双模式切换算法:
Normal 模式(高吞吐,不公平)
- 新到达的 goroutine 在 CPU 上自旋(spin),同时与等待队列中被唤醒的 goroutine 竞争锁
- 新 goroutine 有天然优势:它们已经在 CPU 上运行,且可能有很多个
- 被唤醒的 waiter 竞争失败后,被放回等待队列的队首(不是队尾)
- 这种设计在低竞争场景下吞吐量很高——大部分锁操作不需要经过 runtime semaphore
Starvation 模式(公平,防尾延迟)
触发条件:某个 waiter 等待超过 1ms
- 锁的所有权直接从 unlock 的 goroutine FIFO 移交给队首 waiter
- 新到达的 goroutine 不自旋,直接排到队尾
- 新到达的 goroutine 不尝试获取锁,即使看到 unlocked 状态
退出条件(满足任一):
- 当前获得锁的 waiter 是队列中最后一个
- 当前获得锁的 waiter 等待时间 < 1ms
为什么需要双模式
| 维度 | 仅 Normal | 仅 Starvation | 双模式 |
|---|---|---|---|
| 吞吐量 | 高 | 低(每次都走 semaphore) | 高(大部分走 Normal) |
| 尾延迟 | 差(waiter 可能被饿死) | 好(FIFO 保证) | 好(1ms 切换兜底) |
| 典型延迟 | 低 | 高 | 低 |
来源:
sync/mutex.go源码注释,Go 1.9 引入 starvation mode(CL 34310)
1.3 自旋逻辑
goroutine 尝试自旋而非立即 park 的条件(runtime_canSpin 检查):
- 多核机器:GOMAXPROCS > 1 且至少有一个其他正在运行的 P
- 自旋次数 ≤ 4(
active_spin = 4) - 不在 starvation 模式
- 当前 P 的本地 runq 为空(不阻塞其他 goroutine)
自旋操作本身是执行 PAUSE 指令(x86)若干次(active_spin_cnt = 30),让 CPU 在 busy-wait 的同时暗示超线程让出资源。
1.4 Lock/Unlock 完整流程
Lock fast path(一次 CAS 成功即返回):
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // 无竞争,直接获得锁
}
Lock slow path(lockSlow):
- 判断是否可以自旋 → 自旋等待
- 计算 new state(根据当前模式设置 locked/woken/starving 位 + waiter count)
- CAS 尝试更新 state
- 失败则回到 1;成功则:
- 如果获得了锁 → 返回
- 否则 →
runtime_SemacquireMutex睡眠在信号量上(starvation 模式下排队首,normal 模式下排队尾)
Unlock fast path(一次 atomic add):
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
Unlock slow path(unlockSlow):
- Normal 模式:如果有 waiter 且没有其他 goroutine 被唤醒/获取锁 →
runtime_Semrelease唤醒一个 waiter - Starvation 模式:直接
runtime_Semrelease把锁交给队首 waiter(handoff = true)
2. sync.RWMutex 内部机制
2.1 结构体字段
type RWMutex struct {
w Mutex // 写锁(互斥写者之间的竞争)
writerSem uint32 // writer 等待的信号量
readerSem uint32 // reader 等待的信号量
readerCount atomic.Int32 // 当前 reader 数量(可为负数)
readerWait atomic.Int32 // writer 等待的未完成 reader 数量
}
2.2 readerCount 的符号翻转技巧
核心常量:rwmutexMaxReaders = 1 << 30(约 10 亿)
关键设计:readerCount 的正负号用来标记”是否有 writer 在等待”。
readerCount > 0 → 没有 writer 等待,值 = 当前活跃 reader 数量
readerCount < 0 → 有 writer 等待,实际 reader 数 = readerCount + rwmutexMaxReaders
2.3 四个操作的完整流程
RLock(加读锁):
readerCount.Add(1)— 原子加 1- 如果结果 < 0 → 有 writer 在等待 → 在
readerSem上睡眠 - 如果结果 ≥ 0 → 直接返回(fast path,无锁)
RUnlock(解读锁):
readerCount.Add(-1)— 原子减 1- 如果结果 < 0 → 有 writer 在等待 →
readerWait.Add(-1),如果 readerWait 变为 0 → 释放writerSem唤醒 writer - 如果结果 ≥ 0 → 直接返回
Lock(加写锁):
- 先获取内部的
wMutex(排斥其他 writer) readerCount.Add(-rwmutexMaxReaders)— 翻转为负数,向所有后续 RLock 宣告”writer 来了”- 读取当前活跃 reader 数量,存入
readerWait - 如果
readerWait> 0 → 在writerSem上睡眠,等待最后一个 reader 唤醒自己 - 如果
readerWait== 0 → 直接返回(没有活跃 reader)
Unlock(解写锁):
readerCount.Add(rwmutexMaxReaders)— 翻转回正数- 逐个释放
readerSem,唤醒所有阻塞的 reader - 释放内部的
wMutex
2.4 Writer 饥饿防护
一旦 writer 调用 Lock(readerCount 变为负数),所有后续的 RLock 都会阻塞。这意味着 writer 不会被无限延迟的新 reader 饿死。只有在 writer Lock 之前已经持有读锁的 reader 需要完成。
3. sync.WaitGroup 内部机制
3.1 状态打包
type WaitGroup struct {
noCopy noCopy // go vet 检查防复制
state atomic.Uint64 // 高 32 位 = counter, 低 32 位 = waiter count
sema uint32 // 信号量
}
┌─────────────────────────────┬─────────────────────────────┐
│ counter (高 32 bits) │ waiter count (低 32 bits) │
│ goroutine 计数 │ Wait() 调用者数量 │
└─────────────────────────────┴─────────────────────────────┘
state (uint64)
为什么用一个 uint64 打包两个值?因为 Add/Done 需要在修改 counter 的同时读取 waiter count,打包后一次 atomic 操作即可完成,不需要额外的锁。
3.2 Add/Done/Wait 流程
Add(delta):
state := wg.state.Add(uint64(delta) << 32) // delta 左移 32 位加到高 32 位
counter := int32(state >> 32)
waiters := uint32(state)
if counter == 0 && waiters > 0 {
// counter 归零,唤醒所有 waiter
wg.state.Store(0)
for ; waiters > 0; waiters-- {
runtime_Semrelease(&wg.sema)
}
}
Done() = Add(-1)
Wait():
for {
state := wg.state.Load()
counter := int32(state >> 32)
if counter == 0 { return } // fast path: 没有需要等待的
// CAS 增加 waiter count(低 32 位 +1)
if wg.state.CompareAndSwap(state, state+1) {
runtime_Semacquire(&wg.sema) // 睡眠
return
}
}
3.3 Go 1.25 新增:WaitGroup.Go
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
}
这是个语法糖,但消除了 Add/Done 不匹配的 bug 来源。Go 1.25 的 go vet 同时新增了 waitgroup 分析器,能检测到 Add 调用位置不正确的情况。
3.4 关键约束
Add 必须在对应的 goroutine 启动之前调用(或在 Wait 之前的同一 goroutine 中调用)。如果 Add 在 Wait 之后并发调用,存在 race condition:counter 可能先归零触发唤醒,然后又被 Add 拉起来。
4. sync.Once 内部机制
4.1 结构与双路径
type Once struct {
done atomic.Uint32 // fast path 用
m Mutex // slow path 用
}
Fast path(热路径,绝大多数调用走这里):
func (o *Once) Do(f func()) {
if o.done.Load() == 0 { // 单次 atomic read
o.doSlow(f)
}
}
Slow path(仅第一次调用):
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 { // double-check
defer o.done.Store(1)
f() // 执行完成后才标记 done
}
}
4.2 为什么不用 CAS
一个常见的疑问:为什么不用 CompareAndSwap(0, 1) 来实现 Once?
// 错误实现!
func (o *Once) Do(f func()) {
if o.done.CompareAndSwap(0, 1) {
f() // 问题:f() 还没执行完,其他 goroutine 就看到 done=1 直接返回了
}
}
CAS 的问题:其他 goroutine 会在 f() 完成之前就观察到 done=1,导致它们在 f() 还没初始化完成时就认为”已完成”而继续执行。Mutex + double-check 保证所有 goroutine 要么执行 f(),要么等待 f() 执行完毕。
4.3 Go 1.21+ 新增的便捷包装
// OnceFunc: 将 func() 包装为只执行一次的函数
initOnce := sync.OnceFunc(func() {
// 只执行一次的初始化逻辑
})
initOnce() // 第一次:执行
initOnce() // 后续:no-op
// OnceValue: 缓存返回值
getConfig := sync.OnceValue(func() *Config {
return loadConfig()
})
cfg := getConfig() // 第一次:执行并缓存
cfg = getConfig() // 后续:直接返回缓存
// OnceValues: 缓存多返回值(含 error)
getDB := sync.OnceValues(func() (*sql.DB, error) {
return sql.Open("postgres", dsn)
})
db, err := getDB()
这些包装函数的内部实现也做了优化:inner closure 只构建一次,后续调用的 fast path 开销约 1-2ns(仅一次 atomic load)。
如果 f() panic,OnceFunc/OnceValue/OnceValues 会在后续调用时 re-panic 同一个值,而不是静默吞掉。
5. sync.Pool 内部机制
5.1 Per-P 结构设计
sync.Pool
├── local [GOMAXPROCS]poolLocal // 每个 P 一个本地池
│ ├── private any // 当前 P 独占(无锁)
│ └── shared poolChain // lock-free 双端队列链
└── victim [GOMAXPROCS]poolLocal // 上一轮 GC 的缓存
Get 流程:
- Pin 当前 goroutine 到 P(
runtime_procPin,防止被抢占) - 取
private→ 命中则返回 - 从自己的
shared队列尾部 pop → 命中则返回 - 从其他 P 的
shared队列头部 steal → 命中则返回 - 从
victimcache 重复上述步骤 → 命中则返回 - 调用
New()创建新对象
Put 流程:
- Pin 到 P
- 如果
private为空 → 直接放入private - 否则 push 到
shared队列尾部
5.2 GC 交互与 victim cache
每次 GC 时,runtime 调用 poolCleanup:
GC 第 N 轮:
victim = nil // 清除上上轮的
victim = local // 当前本地池降级为 victim
local = empty // 清空当前本地池
GC 第 N+1 轮:
victim = nil // 清除第 N 轮的 victim
victim = local // ...
对象最多存活两个 GC 周期。这是设计上的权衡:直接清空会导致 GC 后瞬间大量分配(性能抖动),victim cache 提供了一轮缓冲。
来源:Go issue #22950,victim cache 于 Go 1.13 引入
5.3 适用场景
| 场景 | 是否适合 | 原因 |
|---|---|---|
高频短生命周期临时对象(bytes.Buffer、编解码 buffer) | 适合 | 减少 GC 压力,对象生命周期与 Pool 语义匹配 |
| 连接池(DB/HTTP) | 不适合 | 连接是长生命周期资源,GC 清除会导致连接丢失 |
| 大对象(>32KB) | 看情况 | 分配代价高时值得 pool,但注意 Pool 中积累大对象占内存 |
| 创建代价极低的对象 | 不适合 | Pool 本身的 pin/unpin 开销可能超过直接 new |
5.4 noCopy 机制
Pool 内嵌了 noCopy 字段。这不是运行时检查,而是 go vet 的静态分析约定——任何包含 noCopy 字段的 struct 被复制时,go vet 会报错。Mutex、WaitGroup、Cond 也都用了这个技巧。
6. sync.Map 内部机制
6.1 Go 1.24 之前:双 map 设计
Go 1.24 之前的经典实现使用 read map + dirty map 的双层结构:
sync.Map (Go < 1.24)
├── mu Mutex
├── read atomic.Pointer[readOnly] // 无锁读取
│ └── m map[any]*entry // read map
│ └── amended bool // dirty 中是否有 read 没有的 key
├── dirty map[any]*entry // 需要 mu 保护
└── misses int // read miss 计数
entry:
├── p unsafe.Pointer // 指向实际值
│ ├── 正常值:指向存储的值
│ ├── nil:已删除,dirty 中也有此 key
│ └── expunged:已删除,dirty 中没有此 key
读操作 fast path:
┌──────────┐ atomic Load ┌──────────┐
│ caller │ ──────────────── │ read map │ ── 命中 → 返回
└──────────┘ └──────────┘
│ │
│ miss │ miss
▼ ▼
┌──────────┐ Lock + lookup ┌──────────┐
│ caller │ ──────────────── │ dirty map│ ── misses++
└──────────┘ └──────────┘
│
misses >= len(dirty)?
│ yes
▼
dirty 提升为 read
dirty = nil
misses = 0
entry 的三种状态:
- 正常(
p指向实际值):key-value 有效 - nil(
p == nil):已删除,但 dirty 中仍有此 key 的条目 - expunged(
p == expunged):已删除,且 dirty 中不存在此 key。当 dirty 从 read 重建时,expunged 条目会被跳过
dirty 提升时机:当 read miss 计数 misses >= len(dirty) 时,整个 dirty map 原子替换为 read map。这是 O(1) 的指针替换,不是复制。
6.2 Go 1.24+:HashTrieMap 重写
Go 1.24 彻底重写了 sync.Map,底层使用 HashTrieMap(并发哈希字典树,16-way branching):
- 读操作:lock-free,通过 atomic pointer 遍历 trie 节点
- 写操作:获取 per-node mutex,只影响一小棵子树
- Trie 按需增长:插入时懒惰扩展节点
关键改进:
- 不相交 key 集合的修改不再互相竞争(旧实现的 dirty map 有全局锁)
- 无需预热(旧实现需要 dirty → read 的提升周期才能达到低竞争状态)
- Range 操作性能提升 ~36%,并发删除提升 ~78% [需验证]
如遇兼容性问题,可通过 GOEXPERIMENT=nosynchashtriemap 回退。
6.3 Go 1.23:新增 Map.Clear()
Map.Clear() 删除所有条目,等价于内置 clear() 函数。
6.4 sync.Map vs map+RWMutex 选型
| 维度 | sync.Map | map + RWMutex |
|---|---|---|
| 读多写少 | 优势明显(lock-free 读) | 尚可(读锁开销低但非零) |
| 读写均衡 | Go 1.24+ 改善很多 | 通常更好 |
| key 集合稳定(写一次读多次) | 最佳场景 | 可以但不必要 |
| key 频繁增删 | Go 1.24 前较差,1.24+ 可接受 | 通常更好 |
| 不相交 key 并发写 | Go 1.24+ 优秀(per-node lock) | 差(全局锁) |
| 类型安全 | 差(any 接口) | 好(泛型 map) |
| 遍历 | Range 回调,不能 break [需验证] | 直接 for-range |
| 内存占用 | 较高 | 较低 |
经验法则:如果不确定,先用 map + sync.RWMutex。只在 profiling 证明锁是瓶颈,且访问模式匹配 sync.Map 优势场景时才切换。
7. sync.Cond
7.1 基本语义
cond := sync.NewCond(&sync.Mutex{})
// 等待方(必须在循环中调用 Wait)
cond.L.Lock()
for !condition() {
cond.Wait() // 释放锁 → 睡眠 → 被唤醒后重新获取锁
}
// condition 为 true,继续执行
cond.L.Unlock()
// 通知方
cond.L.Lock()
updateCondition()
cond.Signal() // 唤醒一个 waiter
// 或
cond.Broadcast() // 唤醒所有 waiter
cond.L.Unlock()
7.2 为什么 Cond 很少被使用
channel 在大多数场景下更好:
Signal≈ 向 channel 发一个值Broadcast≈ close(channel)
但 channel 的 close 只能用一次,而 Broadcast 可以反复调用。
7.3 Cond 的独特价值场景
Cond 在以下场景比 channel 更合适:
-
多消费者等待同一个可重复变化的条件:比如”buffer 非空”这种条件会反复变化,每次变化都需要 Broadcast 唤醒所有 waiter。channel 做不到”多次 close”。
-
等待复杂条件表达式:多个条件的组合(
len(queue) > 0 && !shutdown),用 channel 很难表达。 -
需要与现有 Mutex 配合:条件变量天然绑定 Mutex,检查条件和修改状态在同一把锁的保护下。
实践中,如果你发现自己在用 Cond,先想想能不能用 channel 或 context 替代。如果涉及”多次广播唤醒所有等待者”的模式,Cond 是正确选择。
8. sync/atomic 包
8.1 类型化原子操作(Go 1.19+)
Go 1.19 引入了类型安全的原子包装:
var counter atomic.Int64
counter.Add(1)
v := counter.Load()
var flag atomic.Bool
flag.Store(true)
var ptr atomic.Pointer[Config]
ptr.Store(&Config{...})
cfg := ptr.Load()
| 类型 | 方法 | 用途 |
|---|---|---|
atomic.Int32 / Int64 | Load, Store, Add, Swap, CompareAndSwap | 计数器、状态码 |
atomic.Uint32 / Uint64 | 同上 | 无符号计数 |
atomic.Bool | Load, Store, Swap, CompareAndSwap | 标志位 |
atomic.Pointer[T] | Load, Store, Swap, CompareAndSwap | lock-free 数据结构 |
atomic.Value | Load, Store | 存储任意类型(但不能混合类型) |
8.2 Go 1.23 新增:And/Or 原子位操作
atomic.And(&flags, ^maskBit) // 原子清除位,返回旧值
atomic.Or(&flags, maskBit) // 原子设置位,返回旧值
用于并发 flag 操作,避免 CAS 循环。
8.3 atomic.Value 约束
var v atomic.Value
v.Store(42) // 第一次 Store 确定类型
v.Store("hello") // panic: sync/atomic: store of inconsistently typed value
atomic.Value 要求所有 Store 的值必须是同一具体类型。第一次 Store 的类型会被记录,后续不一致则 panic。
8.4 CAS 模式
// Lock-free 计数器
var counter atomic.Int64
for {
old := counter.Load()
if counter.CompareAndSwap(old, old+1) {
break
}
// CAS 失败说明有竞争,重试
}
// 注意:上面的例子用 Add 更好,CAS 循环适用于更复杂的状态转换
CAS 循环的核心模式:read → compute → CAS → retry if failed。适用于无法用单一 Add/And/Or 表达的状态转换。
8.5 Atomic vs Mutex 选型
| 维度 | atomic | Mutex |
|---|---|---|
| 保护范围 | 单个变量 | 任意代码块 |
| 操作复杂度 | 单步操作(load/store/add/cas) | 任意多步操作 |
| 性能 | ~1-10ns | ~20-50ns(无竞争),竞争时更高 |
| 适用场景 | 计数器、标志位、指针替换 | 多字段一致性、复杂状态转换 |
| 可组合性 | 差(多个 atomic 操作之间没有原子性保证) | 好(锁内所有操作原子) |
| 认知负载 | 高(需理解 memory ordering) | 低(锁内即安全) |
原则:能用 Mutex 就用 Mutex,只在 profiling 证明 Mutex 是瓶颈时才考虑 atomic。Atomic 用错了产生的 bug 极其难调试。
9. Go Memory Model(实践要点)
9.1 Happens-before 关系
Go memory model 定义了以下 happens-before 保证:
| 操作 A | Happens-before | 操作 B |
|---|---|---|
go f() 语句 | → | f() 开始执行 |
ch <- v | → | <-ch 完成 |
| close(ch) | → | <-ch 收到零值 |
mu.Unlock() 第 N 次 | → | mu.Lock() 第 N+1 次 |
once.Do(f) 中 f 返回 | → | 其他 once.Do 返回 |
atomic.Store | → | 后续 atomic.Load(同一变量) |
Go 1.19 明确规定:atomic 操作具有顺序一致性(sequentially consistent),语义等同于 C++ 的 memory_order_seq_cst 和 Java 的 volatile。
9.2 Data Race = Undefined Behavior
Go 中存在 data race 的程序行为是未定义的——不是”结果不确定”,而是程序可能做任何事(编译器可以基于”无 data race”的假设做优化)。
这比大多数人直觉的”最多读到旧值”严重得多。Go race detector 基于 ThreadSanitizer (TSan),通过编译时插桩检测运行时实际发生的 race(不能检测未执行的路径)。
go test -race ./... # 测试时启用
go run -race main.go # 运行时启用
go build -race # 构建带检测的二进制(性能开销 2-10x,内存 5-10x)
生产环境:不建议常开 -race(开销太大),但建议在 CI 中始终开启。
10. Go 1.22-1.26 sync 相关变更汇总
| 版本 | 变更 | 类型 |
|---|---|---|
| Go 1.22 | mutex profile 计入 runtime 内部锁竞争;按阻塞 goroutine 数缩放竞争指标 | 可观测性增强 |
| Go 1.22 | for 循环每次迭代创建新变量(消除常见并发 bug) | 语言变更 |
| Go 1.23 | sync.Map.Clear() | 新 API |
| Go 1.23 | atomic.And() / atomic.Or() 原子位操作 | 新 API |
| Go 1.24 | sync.Map 重写为 HashTrieMap | 实现重写 |
| Go 1.24 | runtime 内部 mutex 优化(spinbit mutex),整体 CPU 开销降低 2-3% | 性能优化 |
| Go 1.24 | testing/synctest 实验性包 | 新包(实验) |
| Go 1.25 | WaitGroup.Go(f func()) | 新 API |
| Go 1.25 | go vet 新增 waitgroup 分析器(检测 Add 位置不正确) | 工具增强 |
| Go 1.25 | testing/synctest 正式毕业(Run → Test) | API 稳定 |
| Go 1.25 | GOMAXPROCS 自动感知容器 cgroup CPU 限制 | 运行时改进 |
| Go 1.26 | 实验性 goroutine 泄漏检测 profile(GOEXPERIMENT=goroutineleakprofile) | 可观测性增强 |
11. Pitfalls(至少知道这些坑)
Pitfall 1: 复制 Mutex
type Service struct {
mu sync.Mutex
// ...
}
func process(s Service) { // 值传递!Mutex 被复制!
s.mu.Lock() // 这把锁和原来的不是同一个
defer s.mu.Unlock()
}
go vet 能检测到大部分情况(通过 noCopy 机制),但如果通过 unsafe 或反射复制则检测不到。WaitGroup、Cond、Pool 同理。
Pitfall 2: 锁顺序不一致 → 死锁
// Goroutine 1 // Goroutine 2
mu1.Lock() mu2.Lock()
mu2.Lock() // 等 G2 mu1.Lock() // 等 G1 → 死锁
解决方案:所有 goroutine 始终以相同顺序获取多把锁。可以给锁分配 ID,总是先获取 ID 小的。
Pitfall 3: 误以为 RWMutex writer 会被饿死
有人担心”读多写少场景下 writer 永远拿不到锁”——实际上 RWMutex 已经处理了这个问题:一旦 writer 调用 Lock,后续所有新的 RLock 都会阻塞。writer 只需等待已有的 reader 完成。
真正的问题反而是:reader 持有锁时间太长会阻塞 writer,而被阻塞的 writer 又会阻塞后续所有 reader,造成级联延迟。
Pitfall 4: WaitGroup.Add 在 Wait 之后并发调用
var wg sync.WaitGroup
go func() {
wg.Add(1) // 可能在 Wait 之后执行!Race condition!
defer wg.Done()
work()
}()
wg.Wait()
Add 必须在 Wait 之前的确定性代码路径中调用,或者用 Go 1.25 的 wg.Go(f) 彻底规避。
Pitfall 5: 假设 sync.Pool 对象持久存在
pool := &sync.Pool{New: func() any { return &Buffer{} }}
buf := pool.Get().(*Buffer)
pool.Put(buf) // 放回
// ... GC 发生 ...
buf2 := pool.Get().(*Buffer) // 可能是新对象,旧的已被清除
Pool 不是缓存。对象在 GC 时会被清除(最多存活两个 GC 周期)。如果需要持久的对象池,自己实现一个基于 channel 或 ring buffer 的池。
Pitfall 6: sync.Map 用于频繁更新的 key
sync.Map 的优势场景是写一次读多次或不相交 key 并发写。如果同一组 key 被频繁 Store,在 Go 1.24 之前性能可能不如 map + RWMutex(Go 1.24 的 HashTrieMap 改善了这个问题,但在高竞争热点 key 场景下 map + Mutex 仍可能更优)。
Pitfall 7: 忘记 defer Unlock → panic 导致永久死锁
func process() {
mu.Lock()
// mu.Unlock() 放在函数末尾,没用 defer
result := riskyOperation() // 如果 panic,Unlock 永远不会执行
mu.Unlock()
}
始终用 defer mu.Unlock()。defer 的性能开销在 Go 1.14+ 几乎为零(open-coded defer)。
Pitfall 8: atomic.Value 存储不同具体类型
var v atomic.Value
v.Store((*Config)(nil)) // 存 *Config 类型的 nil
v.Store((*Server)(nil)) // panic! 类型不一致
即使都是 nil,具体类型不同也会 panic。而且 不能 Store(nil)(interface nil),必须存具体类型。
Pitfall 9: 在 Cond.Wait 外不加循环
// 错误!
cond.L.Lock()
if !ready { // 应该用 for,不是 if
cond.Wait() // 唤醒后 ready 可能仍为 false(spurious wakeup 或其他 goroutine 先消费)
}
Wait 返回后条件不一定为真——可能是 Broadcast 唤醒了所有人但只有一个能消费,或者(理论上)存在虚假唤醒。必须用 for 循环重新检查。
12. 生产环境 Checklist
代码阶段
- CI 始终开启
go test -race ./... - 所有 Mutex/RWMutex 使用
defer Unlock() - WaitGroup.Add 在 goroutine 启动前调用(或用 Go 1.25
wg.Go) - 多把锁按固定顺序获取,有文档说明锁的层级关系
- sync.Pool 只用于临时对象,不存储需要持久化的资源
-
go vet无警告(检测 Mutex 复制、WaitGroup 误用等)
选型阶段
- 默认用
map + sync.RWMutex,只在 profiling 证明后才换 sync.Map - 默认用 Mutex,只在 profiling 证明后才换 atomic
- 优先 channel/context,Cond 仅在需要多次 Broadcast 时使用
- sync.Pool 的 New 函数不应有副作用(因为调用时机不确定)
调试阶段
- mutex profile 开启:
runtime.SetMutexProfileFraction(5)+/debug/pprof/mutex - block profile 开启:
runtime.SetBlockProfileRate(1)+/debug/pprof/block - Go 1.26:考虑 goroutine leak profile(
GOEXPERIMENT=goroutineleakprofile) - Go 1.25:
testing/synctest.Test用于并发逻辑的确定性测试 - 使用
go build -gcflags='-m'检查逃逸分析,确认 Pool 对象是否真的分配在堆上
性能观测
-
/sync/mutex/wait/total:seconds(Go 1.22+ 含 runtime 内部锁) -
trace.FlightRecorder(Go 1.25+)低开销 runtime trace -
pprofgoroutine profile 检查阻塞在 sync 原语上的 goroutine 数量