--- 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
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 原语组合使用是标准的并发模式