8.7 KiB
tags, create time, update time
| tags | create time | update time | |||||
|---|---|---|---|---|---|---|---|
|
2026-08-08 19:00 | 2026-08-08 19:00 |
Sync 包核心源码
概述
sync 包提供了 Go 中最基础的并发原语:Mutex、RWMutex、WaitGroup、Once、Map 和 Pool。这些看似简单的组件背后隐藏着大量系统级优化——futex 机制、写饿死模式、反溢出设计、双检锁等。理解它们的源码实现,有助于在实际编码中做出正确的选择,避免不必要的性能陷阱。
[!NOTE] 使用原则 sync 包的每个组件都针对特定场景做了极致优化。不要自己发明新的同步原语——用现成的、经过充分测试的工具,出了问题有人背锅。
核心原理
sync.Mutex — futex 机制下的互斥锁
Go 的 mutex 不是简单的自旋锁,而是结合 futex(fast userspace mutex)的自适应锁。状态由 state 字段三个位控制:
state[31]: locked 位 (1 = 持有中)
state[30]: waiter 位 (1 = 有等待者)
state[0-29]: 等待者数量
加锁流程采用两阶段策略:
flowchart TD
A["atomic CAS state 0"] --> B{"成功?"}
B -->|是| C["获得锁, return"]
B -->|否| D["slowLock调用"]
D --> E["设置 locked+waiter 位"]
E --> F["gopark 进入睡眠"]
F --> G["被唤醒: 解锁者调用了 unlock"]
G --> H["重新竞争或直接获得"]
style C fill:#c8e6c9
style F fill:#fff3e0
快路径:通过 atomic CAS 尝试将 state 设为 locked。如果竞争不激烈,几乎零开销就拿到锁。
慢路径:当 CAS 失败时调用 slowLock,将自己包装成 sudog 放入等待队列并休眠。这种设计让高竞争下不会浪费 CPU 周期去自旋。
[!TIP] 面试常考点 Go 1.9 引入的 Mutex 实现了自适应自旋:在低竞争时会短暂地自旋(几微秒),在高竞争时立即休眠。这个阈值 runtime 自动调节。
sync.RWMutex — 写饿死 starving 模式
RWMutex 支持多读单写,但有一个特殊模式:写饥饿(write starvation)。
正常模式下,新来的读者会和已持有的读者共享锁,这可能导致_writer_ 永远等不到写权限(reader starvation)。写饿死模式解决了这个问题:
// RWMutex state 布局
state[0]: locked (mutex 是否被 writer 持有)
state[1-8]: reader count (当前读者数)
state[9-24]: waiter count (等待写锁的数量)
state[25]: expired (writer semaphore 信号量标志)
state[26-30]: reserved
state[31]: starving mode (1 = 饥饿模式)
饥饿模式的触发和解除:
| 条件 | 操作 |
|---|---|
| waiter >= 1 且等待时间 > 1ms | 切换到饥饿模式 |
| 锁转让给等待队列最前面的 writer | 在饥饿模式下直接移交,不给后续读者 |
| 所有等待者一次性全部满足 | 解除饥饿模式,回到正常模式 |
flowchart LR
A["普通模式"] -->|"writer等太久"| B["饥饿模式"]
B -->|"锁移交给队首writer"| C["其他reader被挡在外面"]
C -->|"等待者全部唤醒"| A
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#ffe0b2
[!WARNING] 常见误区 RWMutex 的 ReadRlock / RUnlock 不是可重入的。一个 goroutine 已经持有读锁后再次请求会死锁。这与 Java 的 ReentrantReadWriteLock 不同。
sync.WaitGroup — 反 overflow 设计
WaitGroup 内部用 int64 的两个半段分别表示 counter 和 waiters:
int64:
high 32 bits: counter (goroutine数量)
low 32 bits: waiters (阻塞在Wait()的数量)
为什么要分两段?为了防止 counter overflow。如果只用单一 int32 来表示 counter:
- 当 counter 从 1 减到 0 时调用 Wait()
- 另一个 goroutine 立刻 Add(1) 再 Done()
- counter 绕回 0,导致 Wait() 误认为计数为 0 而提前返回
分高低位后,Add 只能减少高位,Wait 只能增加低位,两者不会互相干扰。
func main() {
var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
doWork()
wg.Done() // counter--, 可能产生负值? 不会!
}()
}
wg.Wait() // 阻塞直到 counter == 0
}
[!TIP] 注意事项 WaitGroup 不可复制!Copy 后的 WaitGroup 与原 WaitGroup 共享同一个计数器副本,行为不可预测。正确做法是用指针传递或在 goroutine 创建前定义 WaitGroup。
sync.Once — 双检锁(Double-Check Locking)
Do 方法确保传入的函数只执行一次:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // 二次检查
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
两层检查的精妙之处:
- 第一层无锁快速路径:大部分情况下 done 已经是 1,直接 return,零锁开销
- 第二层防重复:即使多个 goroutine 同时通过了第一层检查,只有第一个能进入 f()
[!NOTE] panic 的处理 如果 f() panic,Once 认为执行已完成(done=1),不会再重试。这意味着 panic 是一次性的,后续调用直接跳过 f()。如果需要重试语义,应自行包装。
sync.Map — 读写分离架构
sync.Map 的内部结构体现了典型的"读多用 map、写多用 dirty map"分离思想:
sync.Map 包含:
├── readOnly: {elem: map[K]V, amended: bool} ← 读专用,线程安全只读
├── dirty: {elem: map[K]V, missed: int} ← 写专用,需要互斥锁保护
└── deleted: sentinel value in readOnly ← 标记已删除
amended flag 的作用是关键:
amended=false时,读取只查 readOnly(无锁快速路径)amended=true时,读取先查 readOnly,miss 再去 dirty 查找,并把 missing key 预热到 readOnly- 当
dirty == nil或missed >= 2^th时,将 dirty 提升为 readOnly
flowchart TD
A["Load key"] --> B{"key in readOnly?"}
B -->|是| C["命中! return val"]
B -->|否| D{"amended == true?"}
D -->|否| E["未找到, 返回 not-found"]
D -->|是| F["查 dirty map"]
F --> G{"key in dirty?"}
G -->|是| H["预热到readOnly<br/>return val"]
G -->|否| E
style C fill:#c8e6c9
style H fill:#e3f2fd
style E fill:#ffcdd2
这种设计使得高频读取的场景下完全不需要持锁,性能远超 map + RWMutex。
代码示例
Once 的安全延迟初始化
var once sync.Once
var db *sql.DB
func GetDB() *sql.DB {
once.Do(func() {
db, _ = sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)
})
return db
}
Once 保证初始化代码在多 goroutine 环境下只执行一次,且后续调用零锁开销。
WaitGroup + Channel 协作模式
func fetchAll(urls []string) <-chan Result {
out := make(chan Result, len(urls))
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
out <- fetch(url)
}(u)
}
go func() {
wg.Wait() // 等待所有请求完成
close(out) // 关闭 channel,通知接收方
}()
return out
}
这是一个经典的并发模式:WaitGroup 统计工作完成情况,Channel 收集结果。二者缺一不可。
实践场景
面试高频问题
Q: Mutex 和 RWMutex 怎么选?
- 读多写少 → RWMutex
- 读写比例接近 → Mutex
- 临界区代码极短 → Mutex(省了 RWMutex 额外的位运算开销)
- 不确定 → Mutex(更安全的选择)
Q: WaitGroup 的 Add 可以在 Wait 之后调用吗? 可以,但不推荐。这会导致 Wait 直接返回(counter=0),而后续的 Add 会引发 panic。正确模式是在启动 goroutine 前调用 Add。
Q: sync.Once 能保证 f() 的执行顺序吗? 不能保证哪个 goroutine 执行的 f(),只保证只有一个。在极少数高竞争场景下,可能多个 goroutine 同时进入 doSlow,但只有第一个真正执行 f()。
实战建议
- 优先用 select + ctx.Done() 代替 sync 原语:现代 Go 编程中,context 驱动的取消模式通常比 manual waitgroup 更优雅
- Pool 适合高频分配释放的场景:如 HTTP request/response 复用
- 不要用 WaitGroup 做信号量:那是 semaphore pattern,应该用 channel buffered size=1
扩展阅读
- Goroutine 调度模型 — sync 原语的底层阻塞由 GMP 调度器驱动
- Select 多路复用机制 — select-case 可与 WaitGroup 配合实现复杂的并发协调
- Context 包详解 — context 与 sync 原语组合使用是标准的并发模式