Files

8.7 KiB
Raw Permalink Blame History

tags, create time, update time
tags create time update time
go/lang
sync-primitives
futex
double-check-locking
rwmutex-starvation
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()
    }
}

两层检查的精妙之处:

  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
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

扩展阅读