编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • Go 专栏博客
      • 内存模型与栈堆布局
      • 指针与逃逸分析
      • 结构体内存布局对齐
      • 字符串与切片底层
      • 接口与类型系统
      • map哈希表底层实现
      • 零值初始化设计哲学
      • GMP协程调度器机制
      • 通道channel源码剖析
      • sync同步原语剖析
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 sync 包全景
          • 2.2 为什么 Go 需要自己的同步原语
        • 3. Mutex:自旋→信号量→饥饿
          • 3.1 state 字段的 4 个 bit 位
          • 3.2 Lock 的完整状态机
          • 3.3 自旋的成本与收益
          • 3.4 饥饿模式防止尾部延迟
        • 4. RWMutex:读偏向的代价
          • 4.1 读写锁的状态结构
          • 4.2 读锁与写锁的互斥博弈
          • 4.3 写饥饿与读者的让步
        • 5. WaitGroup:64 位原子分身
          • 5.1 counter + waiter 的原子封装
          • 5.2 Add 与 Wait 的时序保证
          • 5.3 WaitGroup 常见的三种误用
        • 6. Once:最快的双重检查锁
          • 6.1 fast path → slow path 的双层设计
          • 6.2 Once 死锁的根因
        • 7. Pool:与 GC 共舞的对象缓存
          • 7.1 per-P 本地池 + victim cache
          • 7.2 GC 周期中的 Pool 清理
          • 7.3 Pool 的正确使用姿势
        • 8. Cond:条件变量的广播与信号
          • 8.1 Cond 的结构与原理
          • 8.2 Wait/Signal/Broadcast 的实现
          • 8.3 Cond 的典型应用场景
        • 9. Mutex 常见陷阱 Top 5
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一组锁的完整生命周期
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • map并发安全与哈希
      • Go内存模型一致性
      • 加权信号量与限流
      • errgroup并行控制
      • 协程泄漏排查与修复
      • 并发设计模式详解
      • GC三色标记与屏障
      • 内存分配器深挖
      • defer延迟执行机制
      • 定时器四叉堆实现
      • 抢占式调度器原理
      • 协程栈扩容与缩容
      • 上下文取消与传播
      • 泛型与类型约束
      • 反射机制与unsafe
      • 迭代器与rangefunc
      • 错误处理与panic
      • 网络轮询器netpoller
      • HTTP服务端源码分析
      • JSON序列化与编解码
      • 数据库SQL连接池
      • 文件IO与零拷贝
      • 结构化日志与配置
      • 单元测试与基准
      • cgo与系统调用切换
      • 编译链接与PGO优化
      • 写作模板
    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 专栏博客
杨充
2026-05-23
目录

sync同步原语剖析

# 10.sync同步原语剖析

卷三第十篇——Go 的 sync 包只有 6 个核心类型,但 Mutex 的 state 字段里藏着自旋、饥饿、信号量三层机制。RWMutex 的读偏向会让写者饿死?Once 的双重检查锁真的安全吗?Pool 怎么利用 victim cache 躲过 GC?读完本篇,你会把 sync/mutex.go 的每个 bit 位都刻进脑子里。关键词:Mutex 状态机、自旋、饥饿模式、RWMutex 读偏向、Once double-check、Pool victim cache。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 sync 包全景
    • 2.2 为什么 Go 需要自己的同步原语
  • 3. Mutex:自旋→信号量→饥饿
    • 3.1 state 字段的 4 个 bit 位
    • 3.2 Lock 的完整状态机
    • 3.3 自旋的成本与收益
    • 3.4 饥饿模式防止尾部延迟
  • 4. RWMutex:读偏向的代价
    • 4.1 读写锁的状态结构
    • 4.2 读锁与写锁的互斥博弈
    • 4.3 写饥饿与读者的让步
  • 5. WaitGroup:64 位原子分身
    • 5.1 counter + waiter 的原子封装
    • 5.2 Add 与 Wait 的时序保证
    • 5.3 WaitGroup 常见的三种误用
  • 6. Once:最快的双重检查锁
    • 6.1 fast path → slow path 的双层设计
    • 6.2 Once 死锁的根因
  • 7. Pool:与 GC 共舞的对象缓存
    • 7.1 per-P 本地池 + victim cache
    • 7.2 GC 周期中的 Pool 清理
    • 7.3 Pool 的正确使用姿势
  • 8. Cond:条件变量的广播与信号
  • 9. Mutex 常见陷阱 Top 5
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一组锁的完整生命周期
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段崩在哪

看一个配置中心微服务——用 sync.Once 做配置懒加载 + sync.WaitGroup 协调启动任务:

package main

import (
    "fmt"
    "sync"
    "time"
)

type ConfigService struct {
    once   sync.Once
    config map[string]string
    mu     sync.Mutex
}

func (s *ConfigService) Load() error {
    s.once.Do(func() {
        fmt.Println("loading config...")

        // 模拟:从远程加载配置需要 3 秒
        time.Sleep(3 * time.Second)

        // BUG:加载配置时也需要加锁写入
        // 但 s.mu 可能已被另一个 goroutine 持有!
        s.mu.Lock()
        s.config = map[string]string{
            "host": "localhost",
            "port": "8080",
        }
        s.mu.Unlock()
    })
    return nil
}

func (s *ConfigService) Get(key string) string {
    // 先获取读锁——确保 Load 完成
    s.mu.Lock()
    defer s.mu.Unlock()

    if s.config == nil {
        // 配置尚未加载 → 触发加载
        s.Load()
    }
    return s.config[key]
}

func main() {
    svc := &ConfigService{}
    var wg sync.WaitGroup

    // 启动时多个 goroutine 同时请求配置
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            val := svc.Get(fmt.Sprintf("key_%d", id))
            fmt.Printf("goroutine %d got: %s\n", id, val)
        }(i)
    }

    // 主 goroutine 也来获取
    wg.Add(1)
    go func() {
        defer wg.Done()
        val := svc.Get("host")
        fmt.Printf("main got: %s\n", val)
    }()

    wg.Wait()
    fmt.Println("all done")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

现象:服务启动后卡死——所有 goroutine 阻塞,进程不退出:

goroutine 6 [sync.Mutex.Lock]:
main.(*ConfigService).Get(...)
    /app/config.go:28 +0x68

goroutine 7 [sync.Once.Do]:
main.(*ConfigService).Load.func1(...)
    /app/config.go:20 +0xa5
1
2
3
4
5
6
7

死锁! 第一个 goroutine 在 Get 中持有 s.mu.Lock(),发现 s.config == nil,然后调用 s.Load()。Load 中 once.Do 的回调里也需要 s.mu.Lock()——但此时 s.mu 已经被当前 goroutine 持有了——同一个 goroutine 对同一把 Mutex 重复 Lock 导致死锁。

更糟的是:第二个 goroutine 也到达 Get,试图 s.mu.Lock()——但锁被第一个持有——它也阻塞了。所有 6 个 goroutine 全部堵死。

# 1.2 顺藤摸到根因

逐步排查:

假设 1:Mutex 是不是递归锁(reentrant)?——Go 的 sync.Mutex 不是递归锁。同一个 goroutine 重复 Lock 会死锁。Java 的 ReentrantLock 允许同一线程多次 lock,但 Go 选择不提供——因为递归锁是"设计缺陷的创可贴"(Russ Cox 语)。

假设 2:为什么不在 Load 的回调中不加锁?——因为 Load 负责填充 s.config,这是写操作,需要互斥保护。但设计缺陷在于:Once.Do 的回调不应该持有调用者已经持有的锁。

假设 3:WaitGroup 的使用正确吗?——wg.Add(1) 和 go func() { defer wg.Done() } 的配对没有问题。WaitGroup 本身不是 bug 原因。

死锁链条:

  1. Goroutine A 调用 Get() → mu.Lock() 成功 → 发现 config==nil → 调用 Load()
  2. Load() → once.Do(f) → 执行函数 f → f 中 mu.Lock() → 死锁(同一 G 重复 Lock)
  3. Goroutine B~F 调用 Get() → mu.Lock() → 阻塞(等待 Goroutine A 释放)
  4. 所有 goroutine 阻塞 → 进程挂起

# 1.3 我们要回答什么

这个事故藏着至少 8 个原理点:

① sync.Mutex 的 state 字段怎么用 3 个 bit + 1 个 int 管理所有状态?   → 第 3 章
② Mutex 的自旋是什么?在什么条件下自旋?什么时候放弃自旋走信号量?       → 第 3.3
③ 饥饿模式解决什么问题?Go 怎么判断"该切换到饥饿模式了"?               → 第 3.4
④ RWMutex 为什么偏爱读者?怎么写者会被饿死?                           → 第 4 章
⑤ WaitGroup 怎么用 64 位原子操作无锁管理 counter + waiter?             → 第 5 章
⑥ Once 的双重检查锁(fast path + slow path)怎么做到毫秒级延迟?        → 第 6 章
⑦ sync.Pool 的 victim cache 怎么帮对象"躲过 GC"?                     → 第 7 章
⑧ Go 的 Mutex 为什么不设计成递归锁?                                  → 第 9 章
1
2
3
4
5
6
7
8

本篇路线:

架构总图 (第 2 章) ── sync 包六大原语全景
   ↓
Mutex (第 3 章) ── state 位域 + 自旋 + 饥饿 + 信号量
   ↓
RWMutex (第 4 章) ── 读偏向 + 写饥饿防御
   ↓
WaitGroup (第 5 章) ── 64 位原子分身 + 时序保证
   ↓
Once (第 6 章) ── fast/slow 双重检查锁
   ↓
Pool (第 7 章) ── per-P 本地池 + victim cache
   ↓
Cond + 陷阱 (第 8-9 章) ── 条件变量 + Mutex 五大陷阱
   ↓
综合案例 (第 10 章) ── 完整复原 + 修复 + 设计哲学
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:这是 Go「并发编程」主题的第二篇。上接 channel 的 CSP 模型,本篇进入共享内存的世界——用锁来保护临界区。虽然 Go 的口号是"用通信共享内存",但现实世界中 sync.Mutex 的使用量远超 channel。理解它的内部实现是排查锁竞争、死锁、饥饿的前提。

# 2. 架构概览

# 2.1 sync 包全景

sync 包的核心类型:

┌──────────────────────────────────────────────────────────────┐
│  Mutex                互斥锁                                     │
│    state: int32       ← 4 个 bit 位 + 28 位 waiter 计数         │
│    sema:  uint32       ← runtime 信号量                         │
│                                                                   │
│  RWMutex              读写锁                                     │
│    w:      Mutex       ← 写锁(底层就是 Mutex)                    │
│    writerSem:  uint32  ← 写者信号量                              │
│    readerSem:  uint32  ← 读者信号量                              │
│    readerCount: int32  ← 当前读者数 + 写者等待标志                 │
│    readerWait:  int32  ← 写者等待读者完成的数量                    │
│                                                                   │
│  WaitGroup            等待组                                     │
│    state: [12]byte     ← 8 字节 counter + 4 字节 waiter 原子       │
│                                                                   │
│  Once                 一次性执行器                                │
│    done: uint32        ← 原子标志位                               │
│    m:    Mutex         ← 慢路径锁                                │
│                                                                   │
│  Pool                 对象池                                     │
│    local:  [P]poolLocal ← per-P 本地池                            │
│    victim: [P]poolLocal ← victim cache(跨 GC 周期存活)           │
│    New:    func() interface{} ← 对象工厂                         │
│                                                                   │
│  Cond                 条件变量                                    │
│    L: Locker           ← 关联的锁                                │
│    notifyList           ← 等待通知的 G 列表                       │
└──────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 2.2 为什么 Go 需要自己的同步原语

疑惑:Linux 已经有 futex(fast userspace mutex),Go 为什么不直接用?

论证:

  1. futex 和 goroutine 不兼容——futex 阻塞的是 OS 线程(M),不是 goroutine(G)。Go 的 Mutex.Lock 阻塞时应该让 G 挂起,让 M 去执行其他 G——如果阻塞了 M,P 上的其他 G 都被饿死。

  2. G 粒度的休眠与唤醒——Go 的 Mutex 用 runtime_Semacquire(runtime 的信号量)而不是 OS 的 futex。runtime_Semacquire 调用 gopark 挂起 G——不是阻塞线程——让 P 可以调度其他 G。

  3. 零值可用——Go 的 sync.Mutex 零值就是 unlocked 状态(state=0),不需要 pthread_mutex_init。这在 §7 篇已经论证过——Go 的类型零值哲学。

  4. 自旋优化——Go 的 Mutex 在特定条件下自旋(空转 CPU 等待),而不立即休眠。自旋可以避免昂贵的 gopark+上下文切换,但只适用于"锁很快会释放"的场景。futex 没有这个智能。

  5. 反向验证——如果在 Go 中使用 C 的 pthread_mutex,每次锁竞争都会阻塞 OS 线程 → 需要创建新线程来处理其他 G → GMP 调度的线程池模型被破坏 → 线程数暴涨。

结论:Go 的 sync.Mutex 表面看是"重新发明轮子",实则是 GMP 调度的必然要求——锁必须作用在 G 上而非线程上。

# 3. Mutex:自旋→信号量→饥饿

# 3.1 state 字段的 4 个 bit 位

// sync/mutex.go
type Mutex struct {
    state int32   // 状态位 + 等待者计数
    sema  uint32  // runtime 信号量
}
1
2
3
4
5

state 的位布局(32 位):

  31                                    3   2   1   0
  ┌────────────────────────────────────┬───┬───┬───┬───┐
  │          waiterCount (29 bits)      │ S │ W │ S │ L │
  │         等待者的数量                 │ t │ o │ t │ o │
  │                                    │ a │ k │ a │ c │
  │                                    │ r │ e │ r │ k │
  │                                    │ v │ n │ v │ e │
  │                                    │ i │   │ i │ d │
  │                                    │ n │   │ n │   │
  │                                    │ g │   │ g │   │
  └────────────────────────────────────┴───┴───┴───┴───┘

  bit 0: Locked       ← 1 = 已加锁
  bit 1: Starving     ← 1 = 饥饿模式(后续 Lock 请求直接排到队尾)
  bit 2: Woken        ← 1 = 有 goroutine 正在被唤醒
  bit 3: 已废弃(Go 1.9 前用于其他用途)
  bit 4-31: waiterCount ← 自旋 + 信号量等待的 goroutine 总数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

四种操作的原子性:

const (
    mutexLocked      = 1 << iota // 1  → bit 0
    mutexWoken                   // 2  → bit 1
    mutexStarving                // 4  → bit 2
    mutexWaiterShift = iota      // 3  → waiterCount 从 bit 3 开始
)

// 原子操作
func (m *Mutex) Lock() {
    // 快速路径:CAS state: 0 → mutexLocked
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return  // 拿到了!不需要进入慢路径
    }
    m.lockSlow()  // 慢路径
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 3.2 Lock 的完整状态机

Lock() 的慢路径 lockSlow():

G 想要 Lock:
  ├─ state 的 Locked=0 且 Starving=0(正常模式未加锁)
  │    └─ 直接 CAS(0, Locked) → 返回
  │
  ├─ state 的 Locked=1(已加锁)
  │    │
  │    ├─ 是否允许自旋?
  │    │  条件:1) 运行在多核机器上
  │    │        2) GOMAXPROCS > 1
  │    │        3) 当前 P 的本地队列为空
  │    │        4) 自旋次数未超过上限(active_spin=4)
  │    │
  │    ├─ 允许自旋 → 自旋等待(空转 CPU 30 个 PAUSE 指令)
  │    │    └─ 每次自旋后重新检查 state→Locked 是否变 0
  │    │    └─ 自旋 4 次后仍未拿到 → 停止自旋
  │    │
  │    └─ 不自旋或自旋用完 → waiterCount++
  │         │
  │         ├─ 正常模式 → runtime_SemacquireMutex(&m.sema, ...)
  │         │    └─ G 挂起(gopark)→ 等待被唤醒
  │         │
  │         └─ 饥饿模式 → 直接入队
  │
  │  被唤醒后:
  │    ├─ Starving=1(饥饿模式)→ 一定拿到锁
  │    │    └─ if waiterCount==1 → 退出饥饿模式(Starving=0)
  │    └─ Starving=0(正常模式)→ 和新的 G 竞争 CAS
  │         ├─ 竞争成功 → 拿到锁
  │         └─ 竞争失败 → 再次循环(waiterCount 已增加)
  │
  └─ 进入饥饿模式的条件:
       waiter 等待时间 > 1ms
       → Starving=1 → 后续 Lock 不再自旋,直接排到队尾
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# 3.3 自旋的成本与收益

疑惑:自旋就是 CPU 空转——浪费 CPU 但可能减少延迟。Go 怎么决定"该不该自旋"?

论证:

  1. 自旋的条件非常苛刻——必须是多核机器 + GOMAXPROCS>1 + 当前 P 的本地队列空 + 最多自旋 4 次。如果 P 的本地队列不空,说明有 G 等着执行——自旋不如让出自己的 CPU 去执行其他 G。

  2. 单次自旋 = 30 个 PAUSE 指令——PAUSE 指令告诉 CPU "我在自旋",CPU 会优化功耗并提示超线程的兄弟逻辑核。每次自旋大约 ~10ns。

  3. 自旋的最大收益——临界区极短(几个指令)的场景下,自旋避免了 gopark + goready 的上下文切换(~1μs)。4 次自旋 ≈ 40ns < 上下文切换的 1000ns → 值得。

  4. 自旋必须配合 Woken 标志——当某个自旋的 G 发现锁释放时,它只唤醒一个等待者而不是全部(通过 Woken 标志协调)。如果锁释放时 Woken=1,就不再做 runtime_Semrelease——避免惊群效应。

  5. 反向验证——如果去掉自旋,高并发下的短临界区性能会下降 30%~50%。sync.Mutex 的基准测试(benchmark)明确显示了自旋对吞吐量的提升。

结论:自旋是"用 CPU 换延迟"的经典权衡,Go 的 30 个 PAUSE × 4 次 = ~120ns 上限保证自旋不会太贪。

# 3.4 饥饿模式防止尾部延迟

饥饿问题的产生——正常模式下,刚醒来的 waiter 要和新来的 goroutine 竞争 CAS。如果新来的 goroutine 持续到达(因为它们在运行中,已经持有 CPU),waiter 可能一直竞争失败——永远拿不到锁。

饥饿模式的触发(sync/mutex.go):

// 等待时间超过 1ms → 切换到饥饿模式
starving = starving || runtime_nanotime()-waitStartTime > 1e6  // 1ms
1
2

饥饿模式的行为:

  • Starving=1 时,新来的 Lock 请求不尝试自旋,不尝试 CAS——直接排队(waiterCount++,挂到信号量上)
  • 锁释放时,直接将锁交给队首的 waiter——不需要 CAS 竞争
  • 最后一个 waiter 拿到锁 + waiterCount==1 → Starving=0,退出饥饿模式

设计意图:饥饿模式不是"性能优化"——是公平性的保证。它牺牲吞吐量(强制排队),换取尾延迟的可预测性。

# 4. RWMutex:读偏向的代价

# 4.1 读写锁的状态结构

// sync/rwmutex.go
type RWMutex struct {
    w           Mutex    // 写锁——保护 readerCount 和 readerWait
    writerSem   uint32   // 写者等待信号量
    readerSem   uint32   // 读者等待信号量
    readerCount int32    // 正数=当前读者数;负数=有写者在等待
    readerWait  int32    // 写者需要等待的读者数
}
1
2
3
4
5
6
7
8

关键字段解读:

readerCount int32——正数时表示活跃读者数。当写者尝试获取写锁时,将 readerCount 减去 rwmutexMaxReaders(一个很大的负数),使其变为负数。后续 RLock 看到 readerCount < 0 → 知道有写者在等待 → 阻塞。

readerWait int32——写者获取写锁前,记录当前活跃的读者数。写者等待这些读者全部 RUnlock 后才真正获得写锁。

# 4.2 读锁与写锁的互斥博弈

RLock(读者加锁):

func (rw *RWMutex) RLock() {
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // 有写者正在等待!→ 本读者阻塞
        runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
    // readerCount >= 0 → 没有写者在等 → 直接进入
}
1
2
3
4
5
6
7

Lock(写者加锁):

func (rw *RWMutex) Lock() {
    // 1. 获取写锁(w.Lock)——阻止其他写者
    rw.w.Lock()

    // 2. 宣布"有写者来了"
    //    readerCount -= rwmutexMaxReaders → 变成负数
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders

    // 3. 如果有活跃读者(r != 0),等待它们完成
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
    // 所有读者已完成 → 写者持有锁
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

RUnlock(读者释放):

func (rw *RWMutex) RUnlock() {
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
        // readerCount < 0 → 有写者在等待
        rw.rUnlockSlow(r)
    }
}

func (rw *RWMutex) rUnlockSlow(r int32) {
    // readerWait-- → 如果为 0 → 唤醒写者
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        runtime_Semrelease(&rw.writerSem, false, 1)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 4.3 写饥饿与读者的让步

读者优先的代价——因为 RLock 只需要原子加 readerCount(不需要互斥锁),新读者在旧读者未释放时可以持续加入。如果读者源源不断,readerCount 永不归零 → 写者永远等不到 readerWait=0 → 写者饥饿。

RWMutex 的缓解策略:当 readerCount < 0(有写者在等),新读者会在 RLock 中的 atomic.AddInt32(&rw.readerCount, 1) 后检测到结果为负 → 阻塞在 readerSem 上。但已经持有读锁的读者不会被中断——它们需要执行完临界区才能 RUnlock。

结论:RWMutex 不能完全防止写者饥饿,只能防止"新读者在写者等待后继续加入"。如果你的场景写者数量不可忽略——考虑用 Mutex 代替 RWMutex。

# 5. WaitGroup:64 位原子分身

# 5.1 counter + waiter 的原子封装

// sync/waitgroup.go
type WaitGroup struct {
    noCopy noCopy   // 防拷贝检查(vet 工具)

    // 64 位原子值:高 32 位 = counter,低 32 位 = waiter
    state1 [12]byte
    // 根据 8 字节对齐,semap 嵌入在 state1 的前后
}
1
2
3
4
5
6
7
8

为什么 state1 是 [12]byte 而不是两个 uint32——因为 32 位系统上的 64 位原子操作要求 8 字节对齐。Go 的 hack:用 12 字节数组,运行时根据地址对齐选择 state 和 sema 的偏移。详情见 sync/waitgroup.go 的 state() 方法。

Add 和 Done 是对 state 的原子操作:

func (wg *WaitGroup) Add(delta int) {
    // 1. 原子加 delta 到 counter(高 32 位)
    state := atomic.AddUint64(statep, uint64(delta)<<32)
    v := int32(state >> 32) // counter
    w := uint32(state)      // waiter

    if v < 0 { panic("sync: negative WaitGroup counter") }
    if v > 0 || w == 0 { return }

    // 2. counter == 0 且有 waiter → 唤醒所有 waiter
    *statep = 0  // 重置 state
    for ; w != 0; w-- {
        runtime_Semrelease(semap, false, 0)
    }
}

func (wg *WaitGroup) Wait() {
    for {
        // CAS: waiter++(低 32 位)
        state := atomic.LoadUint64(statep)
        v := int32(state >> 32)
        if v == 0 { return }  // counter=0 → 不需要等待

        if atomic.CompareAndSwapUint64(statep, state, state+1) {
            runtime_Semacquire(semap)  // 阻塞
            return
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 5.2 Add 与 Wait 的时序保证

WaitGroup 的关键保证:Add 必须在 Wait 之前调用(至少"最后一次 Add 在 Wait 之前"不能保证时会导致 race):

// ❌ 错误:Wait 可能在 Add 之前执行
var wg sync.WaitGroup
go func() { wg.Wait() }()  // Wait 可能先于 Add 执行 → counter=0 → 直接返回
wg.Add(1)                  // 太晚了——Wait 已经返回了

// ✅ 正确:Add 在 go 之前
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); ... }()
wg.Wait()
1
2
3
4
5
6
7
8
9
10

# 5.3 WaitGroup 常见的三种误用

  1. Wait 后重用——Wait 返回后 counter 不是 0(新的 Add 还没生效),不能立即再次 Wait
  2. Add 负数溢出——wg.Add(-1) 时 counter 已经为 0 → panic: negative counter
  3. 复制 WaitGroup——newWg := wg 会复制内部 state——两个 WaitGroup 各自管理各自的 counter

# 6. Once:最快的双重检查锁

# 6.1 fast path → slow path 的双层设计

// sync/once.go
type Once struct {
    done uint32  // 原子标志 0→1
    m    Mutex   // 慢路径锁
}

func (o *Once) Do(f func()) {
    // 快速路径:原子读 done——无锁
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()

    // 双重检查——可能另一个 goroutine 已经执行了 f
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

性能分析:

  • Do 的最常见路径(已初始化):一次原子 Load(~5ns)+ 一次条件跳转
  • 未初始化路径:m.Lock()(~20ns)+ 函数执行 + defer
  • 这就是经典的双重检查锁(Double-Checked Locking)模式——Go 版本是无 bug 的(因为 atomic 保证了内存序)

和 Java 的对比:Java 的 DCL 在 C++03 时代因为内存模型问题需要 volatile 修正,Go 的 atomic.LoadUint32 自带 acquire 语义——无需额外修饰。

# 6.2 Once 死锁的根因

回到第 1 章的案例——Once 死锁的根因是 f() 内部尝试获取调用者已持有的锁:

s.mu.Lock()  // Goroutine A 持有锁
s.Load()     // Load 内部: once.Do(func() { s.mu.Lock() })  ← 死锁
1
2

Once.Do 中的 f() 是同步执行的——不能是异步函数。如果 f 内部需要获取调用者已持有的锁——死锁。

修复:将锁的获取放在 Once 的回调之外:

func (s *ConfigService) Get(key string) string {
    // 先确保初始化完成
    s.Load()  // 这一行不持锁
    
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.config[key]
}
1
2
3
4
5
6
7
8

# 7. Pool:与 GC 共舞的对象缓存

# 7.1 per-P 本地池 + victim cache

// sync/pool.go
type Pool struct {
    noCopy noCopy

    local     unsafe.Pointer // [P]poolLocal
    localSize uintptr

    victim     unsafe.Pointer // 上一轮的 local(给 GC 用)
    victimSize uintptr

    New func() interface{}
}

type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte  // 防止 false sharing
}

type poolLocalInternal struct {
    private interface{}   // 仅当前 P 访问——无锁(最快)
    shared  poolChain     // 无锁双向链表——当前 P push/pop,其他 P 可 pop
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Get 的三级查找:

  1. private:当前 P 独享——无锁,最快
  2. shared 头部:当前 P 的无锁队列——快
  3. victim cache:上一轮 GC 时保存的对象——慢,但避免重新分配

# 7.2 GC 周期中的 Pool 清理

// sync/pool.go
func poolCleanup() {
    // 两轮 GC 周期的对象生命周期:
    //   GC 1: 所有 Pool 对象 → victim cache
    //   GC 2: victim cache → 清空
    for _, p := range oldPools {
        p.victim = nil
        p.victimSize = 0
    }
    for _, p := range allPools {
        p.victim = p.local
        p.victimSize = p.localSize
        p.local = nil
        p.localSize = 0
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

victim cache 的精妙:对象不是"一次 GC 就清除"——而是"给一次机会"。在第一个 GC 周期被移到 victim,如果 Get 在下一个 GC 之前命中了 victim,对象继续存活——这样可以度过"GC 期间的对象空窗期"。

# 7.3 Pool 的正确使用姿势

var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 1024)
    },
}

func process(data []byte) {
    buf := bufferPool.Get().([]byte)
    defer bufferPool.Put(buf)

    // 使用 buf
    copy(buf, data)
    // ...
}

// ❌ 错误:Put 后继续使用
buf := pool.Get().(*MyStruct)
pool.Put(buf)
buf.DoSomething()  // 其他 G 可能已经拿到了这个 buf——data race!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 8. Cond:条件变量的广播与信号

# 8.1 Cond 的结构与原理

// sync/cond.go
type Cond struct {
    noCopy noCopy
    L      Locker     // 关联的锁(通常是 *Mutex 或 *RWMutex)
    notify notifyList  // 等待者列表
}
1
2
3
4
5
6

notifyList 是 runtime 维护的等待队列:

// runtime/sema.go
type notifyList struct {
    wait uint32      // 当前等待计数
    notify uint32    // 已通知计数
    lock mutex       // 保护列表的锁
    head *sudog      // 等待者链表头
    tail *sudog      // 等待者链表尾
}
1
2
3
4
5
6
7
8

# 8.2 Wait/Signal/Broadcast 的实现

func (c *Cond) Wait() {
    t := runtime_notifyListAdd(&c.notify)  // 加入等待列表,获取 ticket 号
    c.L.Unlock()                             // 释放锁
    runtime_notifyListWait(&c.notify, t)    // 挂起——等待 ticket 被通知
    c.L.Lock()                               // 被唤醒后重新拿锁
}

func (c *Cond) Signal() {
    runtime_notifyListNotifyOne(&c.notify)  // 唤醒一个 waiter
}

func (c *Cond) Broadcast() {
    runtime_notifyListNotifyAll(&c.notify)  // 唤醒所有 waiter
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Wait 为什么要释放锁——不释放锁的话,Signal 的调用者无法拿到锁来改变条件变量对应状态。这是条件变量的标准语义:Wait 原子地释放锁并挂起当前 goroutine。

为什么 Wait 返回后不需要重新检查条件——Go 的 Wait 不保证"spurious wakeup"不会发生——被唤醒不等价于条件已满足。所以标准模式永远用 for 而不是 if:

// ✅ 正确——用 for 循环防止假唤醒
for condition == false {
    cond.Wait()
}

// ❌ 错误——假唤醒会导致逻辑错误
if condition == false {
    cond.Wait()
}
1
2
3
4
5
6
7
8
9

# 8.3 Cond 的典型应用场景

场景 1:生产者唤醒消费者:

var mu sync.Mutex
cond := sync.NewCond(&mu)
items := []string{}

// 消费者
go func() {
    cond.L.Lock()
    for len(items) == 0 {
        cond.Wait()  // 等待通知——自动释放锁并重新获取
    }
    item := items[0]
    items = items[1:]
    cond.L.Unlock()
}()

// 生产者
cond.L.Lock()
items = append(items, "new item")
cond.L.Unlock()
cond.Signal()  // 唤醒一个消费者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

场景 2:Broadcast 广播——优雅关闭:

var done bool
cond := sync.NewCond(&sync.Mutex{})

// 多个 worker goroutine
for i := 0; i < 10; i++ {
    go func() {
        cond.L.Lock()
        for !done {
            cond.Wait()  // 等待关闭信号
        }
        cond.L.Unlock()
        // 收到关闭信号 → 退出
    }()
}

// 关闭所有 worker
cond.L.Lock()
done = true
cond.L.Unlock()
cond.Broadcast()  // 唤醒所有 worker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 9. Mutex 常见陷阱 Top 5

  1. Mutex 非递归锁——同一 goroutine 重复 Lock 会死锁
  2. Mutex 结构体值复制——newMutex := oldMutex 复制了内部 state——两个独立的锁
  3. Lock 后忘记 Unlock——特别是在 if/return 路径中
  4. RWMutex 的写者饥饿——读密集场景下写者可能永远等不到
  5. Mutex 的零值可用——但也意味着"任何时候都可以做 Lock 操作"——容易遗漏初始化

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章配置中心死锁——8 个疑问逐条作答:

疑问 答案
① Mutex state 位域? 第 3.1:31 位 waiter 计数 + 3 个控制位(Locked/Woken/Starving)
② 自旋的条件? 第 3.3:多核 + P 本地队列空 + 最多 4 次 × 30 PAUSE
③ 饥饿模式触发条件? 第 3.4:等待时间 > 1ms → 强制排队,防止尾延迟
④ RWMutex 读者优先? 第 4 章:RLock 只原子加 readerCount,新读者可抢在写者之前
⑤ WaitGroup 原子封装? 第 5.1:64 位原子操作——高 32 位 counter,低 32 位 waiter
⑥ Once 双重检查? 第 6.1:fast path 无锁原子读 done,slow path Mutex + 双重检查
⑦ Pool victim cache? 第 7.2:对象在 GC 中先移到 victim,下一个 GC 才清除
⑧ Mutex 为什么不递归? 第 9 章:递归锁掩盖设计问题——Go 选择暴露而非隐藏

案例完整修复:

// 修复 1:Load 不在 Get 持锁时调用
func (s *ConfigService) Get(key string) string {
    s.Load()  // 确保初始化完成(不在 mu 锁内!)

    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.config[key]
}

// 修复 2:一次加载完成(Once 语义正确)
func (s *ConfigService) Load() error {
    s.once.Do(func() {
        // 在 Once 回调内完成初始化——不需要额外的锁
        s.config = map[string]string{
            "host": "localhost",
            "port": "8080",
        }
    })
    return nil
}

// 修复 3:WaitGroup 正确配对
func main() {
    svc := &ConfigService{}
    var wg sync.WaitGroup

    // Add 必须在 go 之前
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(id int) {
            defer wg.Done()
            svc.Get(fmt.Sprintf("key_%d", id))
        }(i)
    }
    wg.Wait()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 10.2 一组锁的完整生命周期

var mu sync.Mutex          // state=0 (unlocked, no waiters)
mu.Lock()
───────────────────────────────────────────────────────────
│  Goroutine A: mu.Lock()
│    CAS(0, 1) → 成功!state=1 (Locked=1)
│    → 进入临界区
│
│  Goroutine B: mu.Lock()
│    CAS(1, 2) → 失败(state 中的 Locked 已经为 1)
│    → 进入 lockSlow
│    → 尝试自旋(4 次 PAUSE)
│    → 自旋失败 → waiterCount++ → state 变为 0b...01001
│    → waiterCount=1 (bit 3-31), Woken=0, Starving=0, Locked=1
│    → runtime_Semacquire(&mu.sema)
│    → gopark(G_B)  ← G_B 挂起
│
│  Goroutine C: mu.Lock()
│    → 同样自旋失败 → waiterCount++ → state=0b...10001
│    → waiterCount=2, Locked=1
│    → runtime_Semacquire → gopark(G_C)
│
│  Goroutine A: mu.Unlock()
│    → atomic.AddInt32(&state, -mutexLocked) → state=0b...10000
│    → Locked=0!检查是否有 waiter:
│    → waiterCount=2 > 0
│    → 如果 Woken=0 → runtime_Semrelease(&mu.sema)
│    → 唤醒 G_B (不是 G_C——只唤醒一个)
│    → goready(G_B)
│
│  G_B 被唤醒:
│    → 从 gopark 后继续执行
│    → CAS(state, Locked) → 成功!state=0b...01001
│    → waiterCount=1, Locked=1
│    → 进入临界区
│
│  G_B: mu.Unlock()
│    → 同样释放锁 → 唤醒 G_C
│    → goready(G_C)
│
│  如果 B 等待超过 1ms → Starving=1
│     → 后续 Lock 不再自旋 → 直接排到队尾
│     → Unlock 时直接交给队首 waiter
│
│  最后一个 waiter 拿到锁后:
│     → Starving=0(退出饥饿模式)
│     → waiterCount=0 → state=1 (仅 Locked)
└───────────────────────────────────────────────────────────
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

# 10.3 设计哲学回扣

哲学 1:自旋是"鲁莽的乐观"——用 CPU 换延迟

Go 的 Mutex 自旋设计是一种"乐观锁"思维——先假设锁很快会释放,用 120ns 的 CPU 空转换 1000ns 的上下文切换。但乐观有上限:4 次自旋后放弃,走信号量挂起。这种"乐观但有节制"的设计,在短临界区场景下性能提升 30%+,在长临界区场景下不浪费 CPU。

哲学 2:饥饿模式是"公平的强制"——牺牲吞吐量换尾延迟

正常模式下 CAS 竞争让 fast path 总是赢——这带来了高吞吐量,但可能让某个 waiter 永远拿不到锁(尾部延迟不可控)。饥饿模式强制排队——牺牲吞吐量,但保证每个 waiter 最多等 1ms 就能拿到。这是 Go 对"性能公平性"的注解。

哲学 3:不提供递归锁——暴露设计问题而非隐藏

几乎所有需要递归锁的场景,都可以通过重新设计"锁的边界"来解决。递归锁是创可贴——它让设计缺陷继续潜伏。Go 的选择是:让重复 Lock 死锁——强迫开发者重新思考临界区的大小。一旦你调整好锁的边界,代码逻辑反而更清晰。

哲学 4:GC 协作——让对象池和运行时共生

sync.Pool 的 victim cache 机制让对象在 GC 中活到第二个周期——这不是"抵抗 GC",而是"和 GC 协作"。第一个 GC 把对象移到 victim,告诉运行时"这些可能还在用";如果第二个 GC 前确实无人取走,再真正回收。这种两层缓冲让 Pool 的缓存命中率在 GC 期间几乎不降。

# 10.4 速查表

Mutex state 位:

位 含义 何时设置 何时清除
0 (Locked) 已加锁 Lock CAS 成功 Unlock
1 (Woken) 有 G 在唤醒 Semrelease 前 被唤醒的 G 拿到锁
2 (Starving) 饥饿模式 等待 > 1ms 最后一个 waiter 拿到锁
3-31 waiterCount 自旋失败/进入信号量 被唤醒

Mutex vs RWMutex:

特性 Mutex RWMutex
写锁 Lock/Unlock Lock/Unlock
读锁 无 RLock/RUnlock
内部实现 state + sema Mutex + readerCount + readerSem + writerSem
适用场景 读写都频繁 读远多于写
写者饥饿风险 无(自旋/饥饿机制) 有(读者持续加入)
内存占用 8 字节 24 字节

WaitGroup/Once/Pool:

类型 核心机制 零值可用? 注意事项
WaitGroup 64 位原子: counter + waiter ✅ Add 必须在 Wait 前
Once 原子 done + Mutex 双重检查 ✅ f() 内不能获取调用者持有的锁
Pool per-P 本地池 + victim cache ✅ GC 会清空;Put 后不再使用
Cond notify 列表 + 锁协作 ❌ (需 NewCond) Wait 必须在 Lock/Unlock 之间

诊断命令:

# Mutex 竞争分析
go test -race ./...                   # 检测 data race
go test -mutexprofile=mutex.out ./... # 生成 mutex profile
go tool pprof mutex.out               # 查看锁竞争

# Goroutine 阻塞分析
curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep "sync"
# 查看卡在 sync.Mutex.Lock / sync.WaitGroup.Wait / sync.Once.Do 的 goroutine

# 调度器跟踪
GODEBUG=schedtrace=1000 ./app     # 查看 G 状态——_Gwaiting 过多 → 锁竞争
1
2
3
4
5
6
7
8
9
10
11

下一篇:我们已经看清了 sync 包的全部分——Mutex 的自旋饥饿、Once 的双重检查、Pool 的 victim cache,下一步进入 11.map并发安全与哈希——把 map 的并发安全、sync.Map 的读写分离、分段锁的自建方案剖开。

上次更新: 2026/06/11, 20:46:46
通道channel源码剖析
map并发安全与哈希

← 通道channel源码剖析 map并发安全与哈希→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式