Files
autumn-recruitment/00.Go/concurrency/Sync 包核心源码.md
T

261 lines
8.7 KiB
Markdown
Raw Normal View History

2026-08-08 19:01:04 +08:00
---
tags: [go/lang, sync-primitives, futex, double-check-locking, rwmutex-starvation]
create time: 2026-08-08 19:00
update time: 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]: 等待者数量
```
加锁流程采用两阶段策略:
```mermaid
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)。写饿死模式解决了这个问题:
```go
// 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 | 在饥饿模式下直接移交,不给后续读者 |
| 所有等待者一次性全部满足 | 解除饥饿模式,回到正常模式 |
```mermaid
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 只能增加低位,两者不会互相干扰。
```go
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 方法确保传入的函数只执行一次:
```go
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()
}
}
```
两层检查的精妙之处:
1. **第一层无锁快速路径**:大部分情况下 done 已经是 1,直接 return,零锁开销
2. **第二层防重复**:即使多个 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
```mermaid
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 的安全延迟初始化
```go
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 协作模式
```go
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 原语组合使用是标准的并发模式