Files

261 lines
8.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 原语组合使用是标准的并发模式