编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
        • 1. 案例引入
          • 1.1 多线程日志的死锁迷案
          • 1.2 条件变量的假唤醒陷阱
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 用户态与内核态锁
          • 2.2 为何这么切
        • 3. mutex 的 futex 底层原理
          • 3.1 futex 的两个状态:无竞争 fast path
          • 3.2 有竞争时的内核等待——futex_wait
          • 3.3 解锁与唤醒——futex_wake
          • 3.4 汇编层的完整 un/lock 证据
          • 3.5 futex 与 spinlock 的切换阈值
        • 4. 自旋锁 vs 阻塞锁的场合选择
          • 4.1 自旋的物理代价——CPU pipeline 空转
          • 4.2 阻塞的物理代价——上下文切换
          • 4.3 决策表:什么场景用哪种锁
          • 4.4 自适应锁——glibc 的实际选择
        • 5. recursive_mutex 的设计与陷阱
          • 5.1 可重入语义与计数器
          • 5.2 可重入同时意味着「所有操作都必须在锁保护下」
          • 5.3 绝对不会出现的死锁
          • 5.3 性能代价与逻辑错误
          • 5.4 从 recursive 改为非递归的重构指南
        • 6. shared_mutex 读写锁的原理
          • 6.1 读共享、写独占的语义
          • 6.2 内部状态机三种状态
          • 6.3 写者饥饿与读者优先
          • 6.4 实际性能——仅读多 > 80% 才值得
        • 7. std::trylock 与 std::scopedlock 的组合技
          • 7.1 多锁场景的死锁避免
          • 7.2 std::lock 的尝试-回退算法
          • 7.3 scoped_lock 的 RAII 多锁包装
        • 8. 定时锁与 RAII 守卫的完整家族
          • 8.1 timedmutex 的 trylockfor 与 trylock_until
          • 8.2 lockguard vs uniquelock vs shared_lock 的选择
          • 8.3 为什么条件变量要求 uniquelock 而非 lockguard
        • 9. condition_variable 的完整机制
          • 8.1 条件变量为何配合mutex
          • 8.2 等待者的完整状态机
          • 8.3 notifyone vs notifyall 的等待唤醒语义
          • 8.4 为什么丢失唤醒致命
        • 9. spurious wakeup 的根因与正确范式
          • 9.1 假唤醒的硬件来源
          • 9.2 标准为何允许假唤醒
          • 9.3 唯一正确的使用范式
          • 9.4 生产者-消费者的正确写法
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 互斥锁的完整生命周期
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

mutex与条件变量

# 43.mutex与条件变量

# 目录介绍

  • 1. 案例引入
    • 1.1 多线程日志的死锁迷案
    • 1.2 条件变量的假唤醒陷阱
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 用户态锁与内核态锁的协作模型
    • 2.2 为何这么切
  • 3. mutex 的 futex 底层原理
    • 3.1 futex 的两个状态:无竞争 fast path
    • 3.2 有竞争时的内核等待——futex_wait
    • 3.3 解锁与唤醒——futex_wake
    • 3.4 汇编层的完整 un/lock 证据
    • 3.5 futex 与 spinlock 的切换阈值
  • 4. 自旋锁 vs 阻塞锁的场合选择
    • 4.1 自旋的物理代价——CPU pipeline 空转
    • 4.2 阻塞的物理代价——上下文切换
    • 4.3 决策表:什么场景用哪种锁
    • 4.4 自适应锁——glibc 的实际选择
  • 5. recursive_mutex 的设计与陷阱
    • 5.1 可重入的语义与内部计数器
    • 5.2 绝不会出现的意外死锁
    • 5.3 性能代价与隐蔽的逻辑错误
    • 5.4 从 recursive 改为非递归的重构指南
  • 6. shared_mutex 读写锁的原理
    • 6.1 读共享、写独占的语义
    • 6.2 内部状态机的三种状态
    • 6.3 写者饥饿与读者优先的权衡
    • 6.4 实际性能——仅读多 > 80% 才值得
  • 7. std::try_lock 与 std::scoped_lock 的组合技
    • 7.1 多锁场景的死锁避免
    • 7.2 std::lock 的尝试-回退算法
    • 7.3 scoped_lock 的 RAII 多锁包装
  • 8. condition_variable 的完整机制
    • 8.1 为什么条件变量必须配合 mutex
    • 8.2 等待者的完整状态机
    • 8.3 notify_one vs notify_all 的等待唤醒语义
    • 8.4 为什么丢失唤醒是并发 bug 之源
  • 9. spurious wakeup 的根因与正确范式
    • 9.1 假唤醒的硬件来源
    • 9.2 为什么标准明确允许假唤醒
    • 9.3 条件变量的唯一正确范式
    • 9.4 生产者-消费者的正确写法
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次互斥锁的完整生命周期
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 1. 案例引入

# 1.1 多线程日志的死锁迷案

某交易系统的日志模块——用全局 std::mutex 保护日志输出。某天同事在 log() 内部加了一行 dump_state()——而 dump_state 里也调用了 log():

// ====== 事故代码 V1:同一线程重复锁定 ======
std::mutex g_log_mutex;

void log(const std::string& msg) {
    std::lock_guard<std::mutex> g(g_log_mutex);  // ① 锁定
    std::cout << msg << '\n';

    if (msg.find("ERROR") != std::string::npos) {
        dump_state();            // ② dump_state 里调 log("state: ...")
    }
}  // ③ 解锁

void dump_state() {
    log("state: active_users=" + std::to_string(active_users));
    // ④ 第二次调 log → 再次 lock(g_log_mutex)
    // → 同一线程重复锁定非递归 mutex → 死锁!
    // 线程阻塞在第二次 lock 上——等自己 unlock——永远不会来
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

根因:std::mutex 是非递归的——同一线程不能重复锁定。第二次 lock 调用把线程送进了 futex_wait——它在等「自己」解锁——永远等不到。

# 1.2 条件变量的假唤醒陷阱

同个团队的生产者-消费者队列。线上偶发「队列已空但消费者读到随机数据」:

// ====== 事故代码 V2:假唤醒 + 不检查条件 ======
std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;

// 消费者
void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock);                    // ⚠️ 没有检查条件!
    int val = q.front(); q.pop();     // 可能在 q 为空的情况下执行!
}

// 消费者以为 wait 返回 = 有数据——但 spurious wakeup 打破了这条假设
1
2
3
4
5
6
7
8
9
10
11
12
13

根因:condition_variable::wait 可能假唤醒(没有 notify 时返回)。C++ 标准明确允许这种行为——因为操作系统实现(POSIX)允许。正确的 wait 必须用「带谓词的版本」或在循环中检查条件。

# 1.3 七个待解疑问

① futex 是什么? mutex::lock 在内核态和用户态之间怎么切换?              → 第 3 章
② 自旋锁和阻塞锁有什么区别? 什么场景用自旋、什么场景用阻塞?             → 第 4 章
③ recursive_mutex 怎么做到可重入? 为什么应该避免?                       → 第 5 章
④ shared_mutex 读写锁的内部状态机是什么? 什么时候该用?                   → 第 6 章
⑤ 多个锁如何避免死锁? std::lock 和 scoped_lock 的区别?                  → 第 7 章
⑥ 定时锁与 RAII 守卫家族有哪些? lock_guard/unique_lock/shared_lock 怎么选? → 第 8 章
⑦ condition_variable 为什么必须配合 mutex? 假唤醒是什么? 正确范式?       → 第 9 / 第 10 章
1
2
3
4
5
6
7

# 2. 架构概览

# 2.1 用户态与内核态锁

C++ 的 std::mutex 不是纯内核锁——是用户态+内核态的混合锁:

┌────────────────────────────────────────────────────────────┐
│                  std::mutex 的两层模型                       │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ ① 用户态 fast path (无竞争)                           │  │
│  │    atomic compare_exchange 自旋几次                    │  │
│  │    → 自旋几次(自适应)然后回退                          │  │
│  ├──────────────────────────────────────────────────────┤  │
│  │ ② 内核态 slow path (有竞争或等待时间过长)              │  │
│  │    futex(FUTEX_WAIT) → 挂起线程                        │  │
│  │    futex(FUTEX_WAKE) → 唤醒1或多线程                    │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
│  核心思想:大多数锁「绝大多数时候没有竞争」                    │
│  → fast path 用原子操作零系统调度 — ~15ns                    │
│  → 有竞争时降级到 futex — ~2μs(上下文切换)                  │
└────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 2.2 为何这么切

疑惑:为什么不直接全部用 futex(全内核锁)?Fast path 的用户态原子操作真的必要吗?

论证:

  1. 绝大多数锁持有时长 < 50ns——在生产系统中,临界区通常只有几条指令。如果每次 lock 都进内核——上下文切换的开销(~2μs)比临界区执行时间大 40-100×。
  2. 原子操作的延迟在无竞争时 ≈ 15ns(lock cmpxchg)——比系统调用(~200ns 最小)快 10+× 。Fast path 用原子操作是工程必然——不是优化癖——是正确抽象。
  3. futex 是「fast userspace mutex」的缩写——它天生就是为两阶段设计的。 用户态改变锁的状态(从 0 变为 1),如果失败——知道自己需要睡眠——才进内核。futex 把「睡眠」和「锁检查」原子化——避免在「检查到锁还占着」和「进入睡眠」之间出现唤醒丢失的竞态窗口。

结论:两层模型不是「先试试用户态不行再进内核」的 hack——是「无竞争时零开销、有竞争时正确等待」的最优策略。 futex 是 C++ 并发基础设施的最底层物理机制——理解它就理解了 mutex 的一切。


# 3. mutex 的 futex 底层原理

# 3.1 futex 的两个状态:无竞争 fast path

// std::mutex 内部——简化版(基于 futex)
class mutex {
    std::atomic<int> state_{0};  // 0 = unlocked, 1 = locked (no waiters)
                                  // 2 = locked (with waiters)
public:
    void lock() {
        // ① Fast path:尝试用原子 CAS 获取锁
        int expected = 0;
        if (state_.compare_exchange_strong(expected, 1,
                std::memory_order_acquire, std::memory_order_relaxed)) {
            return;  // < 20ns —— 无系统调用、无上下文切换
        }
        // ② Slow path:有竞争 → 自旋几次 or 直接进 futex
        lock_slow();
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

汇编的 fast path:

; lock() fast path —— 只有三条指令
    xor   eax, eax             ; expected = 0
    mov   ecx, 1               ; desired = 1
    lock  cmpxchg [rdi], ecx   ; CAS——原子地「如果 state==0, state=1」
    je    .Ldone               ; 成功 → 获取锁 → 返回
    ; 失败 → 进 slow path
1
2
3
4
5
6

# 3.2 有竞争时的内核等待——futex_wait

void mutex::lock_slow() {
    int expected = 1;
    // ③ 自旋自适应尝试(glibc 的 PTHREAD_MUTEX_ADAPTIVE_NP)
    for (int spin = 0; spin < 100; ++spin) {
        if (state_.compare_exchange_weak(expected, 1, acquire, relaxed))
            return;                     // 自旋成功——锁刚被释放
        expected = 1;
        __builtin_ia32_pause();         // PAUSE 指令——减少功耗、让 HT 兄弟跑
    }

    // ④ 自旋失败 → 进 futex 睡眠
    if (state_.exchange(2) == 0) return;  // 如果碰巧是 0——获锁(无 waiters 但拿到锁)

    // ⑤ futex(FUTEX_WAIT)——挂起当前线程
    syscall(SYS_futex, &state_, FUTEX_WAIT_PRIVATE, 2, nullptr);
    // 参数:地址、操作、期望值、超时
    // FUTEX_WAIT_PRIVATE = 进程内等待(不跨进程——更快)
    // 如果 state_ == 2 → 挂起线程(内核态睡眠)
    // 如果 state_ != 2 → 立即返回(锁可能已被释放——省去睡眠开销)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

关键:FUTEX_WAIT 是原子的「比较 + 睡眠」——避免了「检查锁没释放→进入睡眠→但在睡眠前锁刚好释放了→唤醒信号丢失」的竞态窗口。

# 3.3 解锁与唤醒——futex_wake

void mutex::unlock() {
    // ⑥ 用原子 exchange 把 state 从 1 或 2 改为 0
    if (state_.exchange(0) == 2) {
        // ⑦ 如果有 waiters(state 之前是 2)→ 唤醒一个
        syscall(SYS_futex, &state_, FUTEX_WAKE_PRIVATE, 1, nullptr);
        // 参数中的 1 = 唤醒 1 个等待者(不是全部——这就是 mutex 的「互斥」语义)
    }
}
1
2
3
4
5
6
7
8

为什么不是 FUTEX_WAKE_ALL:互斥锁只需要一个线程获取——唤醒所有等待者是浪费。条件变量的 notify_all 才会唤醒全部。

# 3.4 汇编层的完整 un/lock 证据

GCC 13.2 -O2 对 std::mutex 生成的近似代码:

; m.lock() —— 简化
    mov    rax, rdi
.Llock_fast:
    xor    edx, edx
    mov    esi, 1
    lock   cmpxchg [rax], esi       ; CAS: [state]==0 → [state]=1
    je     .Ldone                    ; 成功 → 返回
    call   __pthread_mutex_lock_slow ; 失败 → glibc slow path → 自旋+futex_wait
.Ldone:

; m.unlock()
    mov    eax, 1
    xchg   [rdi], eax               ; 原子交换:state = 0,同时读旧值
    cmp    eax, 2
    je     .Lwake                    ; 旧值==2 → 有 waiter → 需要唤醒
    ret
.Lwake:
    jmp    __pthread_mutex_unlock_slow ; → futex_wake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 3.5 futex 与 spinlock 的切换阈值

glibc 的自适应策略:先自旋 100 次(PAUSE 指令每次约 14ns)→ 共约 1.4μs 自旋。如果锁在被释放 → 自旋成功、零上下文切换。如果自旋 100 次仍失败 → 进 futex——线程挂起的上下文切换约 2-5μs。

场景 锁持有时长 最佳策略 原因
临界区 < 50ns < 50ns 自旋 自旋 100 次 1.4μs < 上下文切换 2μs
临界区 1-10μs 1-10μs 自旋再等待 先试试自旋——不行再 sleep
临界区 > 50μs > 50μs 直接 futex_wait 自旋太长浪费 CPU——直接睡眠等

# 4. 自旋锁 vs 阻塞锁的场合选择

# 4.1 自旋的物理代价——CPU pipeline 空转

// 自旋锁的 while 循环
while (flag.test_and_set(std::memory_order_acquire)) {
    // CPU 每 14ns 执行一次 PAUSE 指令——总线的缓存一致性消息是唯一的工作
    // 在 3GHz CPU 上——自旋 100 次 = 约 42 个周期——完全是浪费
}
1
2
3
4
5

自旋期间 CPU 的 L1 cache 在不停地「问」总线——消耗的是缓存一致性协议的带宽。如果有 8 个线程在 8 个核上自旋等同一把锁——总线带宽被大量 RFO 浪费。

# 4.2 阻塞的物理代价——上下文切换

线程调用 futex_wait:
  ① 用户态 → 内核态(保存所有寄存器 — ~200 ns)
  ② 内核把线程加入等待队列——切出 CPU
  ③ CPU 调度另一个线程(新线程的寄存器恢复——~2-5 μs)
  ④ 锁释放 → futex_wake → 等待的线程被加入就绪队列
  ⑤ 调度器选中该线程 → 恢复上下文 → 用户态

总代价:约 3-10 μs(取决于 CPU 负载和调度策略)
1
2
3
4
5
6
7
8

# 4.3 决策表:什么场景用哪种锁

临界区长度 等待时间 推荐 理由
< 100 ns < 1 μs std::atomic + spin 自旋 3×PAUSE 就拿到——远小于上下文切换
100ns-10μs < 10 μs std::mutex(自适应) glibc 会先自旋再 futex——自适应最优
> 10 μs > 10 μs std::mutex → 直接 futex 自旋太久浪费 CPU——不如直接睡眠
I/O 临界区 ms 级 阻塞锁 绝对不能自旋——等 I/O 完成 = 几百万个周期

# 4.4 自适应锁——glibc 的实际选择

glibc 的 PTHREAD_MUTEX_ADAPTIVE_NP——先自旋、不成功后睡眠。这个策略被证明是「对大多数工作负载最优」——因为真实的临界区通常 < 1μs,而上下文切换 ≥ 2μs。自旋一两次通常就能等到锁释放。


# 5. recursive_mutex 的设计与陷阱

# 5.1 可重入语义与计数器

class recursive_mutex {
    std::atomic<std::thread::id> owner_{};
    unsigned count_{0};
    // ... (futex state)
public:
    void lock() {
        if (owner_.load() == std::this_thread::get_id()) {
            ++count_;                      // 重入——只增加计数
            return;
        }
        // 不是自己的——和普通 mutex 一样等待
        while (/* 尝试获取锁 */) { futex_wait(...); }
        owner_ = std::this_thread::get_id();
        count_ = 1;
    }

    void unlock() {
        if (--count_ == 0) {
            owner_ = std::thread::id{};
            futex_wake(&state_, 1);        // release
        }
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

内部有一个计数器和所有者 ID——这两项让 sizeof(recursive_mutex) 比 sizeof(mutex) 大 16-24 字节。

# 5.2 可重入同时意味着「所有操作都必须在锁保护下」

递归锁的一个微妙性质:如果同一个线程已经持有锁——它调用另一个也拿锁的函数时,锁不会再次阻塞。这给了错误的安心——以为所有子函数都安全了。但实际上:

std::recursive_mutex mtx;
int shared_data;

void read_data() {
    std::lock_guard g(mtx);             // 如果外面已经锁了——这里只是计数器+1
    use(shared_data);                   // 这里读到的 shared_data 不一定是最新值
}

void write_data(int v) {
    std::lock_guard g(mtx);             // 如果外面已经锁了——计数器+1
    shared_data = v;
}
1
2
3
4
5
6
7
8
9
10
11
12

问题:递归锁让任何函数都可以被「在锁内」安全调用——但锁语义掩盖了「是否需要同步」的真实情况。

# 5.3 绝对不会出现的死锁

// 递归 mutex 不会因为同一线程重复锁定而死锁
std::recursive_mutex mtx;
void f1() { std::lock_guard g(mtx); f2(); }  // lock, 再 lock — ✅
void f2() { std::lock_guard g(mtx); /* ... */ } // 重入 — 不会死锁
1
2
3
4

# 5.3 性能代价与逻辑错误

① 性能:每次 lock 都要检查 owner_——额外一次原子读 + 分支。计数器增减——额外开销 20-30%。

② 隐蔽的逻辑错误:递归锁允许「已经持有的锁再获取」——这同时掩盖了设计缺陷:

// ❌ 坏设计——用 recursive_mutex 掩盖了 API 层级混乱
class OrderBook {
    std::recursive_mutex mtx_;
    void update_price(int id) {
        std::lock_guard g(mtx_);
        prices_[id] = fetch_price(id);
        recalc_index();           // ① recalc_index 也 lock mtx_ —— 可重入但不应该
    }
    void recalc_index() {
        std::lock_guard g(mtx_);  // ② 掩盖了「update_price 没有完全锁住 invariant」
        // 这里修改 index_ —— 同时 update_price 的锁还在
    }
};
// 正确的设计:update_price 在锁外准备数据、锁内只写——不调 recalc_index
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 5.4 从 recursive 改为非递归的重构指南

  1. 找出所有「在已持有锁时调用的嵌套路径」——用 clang-tidy 的 -Wthread-safety-analysis
  2. 把内部函数拆成「无锁版本」——_locked() 后缀——供已持有锁的调用方用
  3. 替换递归锁为普通 mutex——编译期会报出「重复锁定」——逐个修复

# 6. shared_mutex 读写锁的原理

# 6.1 读共享、写独占的语义

std::shared_mutex smtx;

// 多个读者可以同时持有
void reader() {
    std::shared_lock lock(smtx);  // 读锁——不阻塞其他读者
    read_data();
}

// 写者独占——所有读者和写者都被阻塞
void writer() {
    std::unique_lock lock(smtx);  // 写锁——独占
    write_data();
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 6.2 内部状态机三种状态

// shared_mutex 内部状态:一个 atomic<int> 编码所有状态
//   > 0: N 个读者持有读锁
//   = 0: unlocked
//   < 0 (如 -1): 写者持有写锁
1
2
3
4

当写者尝试获取锁时——必须等待所有读者释放(count 降到 0)。

# 6.3 写者饥饿与读者优先

读者优先:只要还有读者在,新读者可以一直加——写者饥饿。 写者优先:新读者在写者等待时被阻塞——读者饥饿。

C++ 标准没有规定优先级——由实现决定。glibc 倾向于写者优先(有等待写者时新读者被阻塞)。

# 6.4 实际性能——仅读多 > 80% 才值得

疑惑:读写锁一定比互斥锁快吗?

论证——shared_mutex 的 lock 比 mutex::lock 多 2-3× 开销(更多的原子操作和状态位操作)。只有读多写少的场景才值得付这笔开销:

读写比 shared_mutex 加速比 推荐
100:1 12-20× ✅ shared_mutex
10:1 4-8× ✅ shared_mutex
4:1 1.5-2× ⚠️ 边际
1:1 0.6-0.8× ❌ mutex 更快

# 7. std::try_lock 与 std::scoped_lock 的组合技

# 7.1 多锁场景的死锁避免

// ❌ 死锁——两个线程各自等对方的锁
void transfer(Account& a, Account& b) {
    std::lock_guard g1(a.mtx);
    std::lock_guard g2(b.mtx);   // 线程 1: lock A → lock B
                                  // 线程 2: lock B → lock A → 死锁
}

// ✅ std::lock — 一体化原子锁定
void transfer(Account& a, Account& b) {
    std::scoped_lock lock(a.mtx, b.mtx);  // 同时原子锁定——无死锁
}
1
2
3
4
5
6
7
8
9
10
11

# 7.2 std::lock 的尝试-回退算法

// std::lock 的内部实现——伪代码
void lock_all(Mutex& m1, Mutex& m2) {
    while (true) {
        m1.lock();
        if (m2.try_lock()) return;  // ② 尝试第二个——如果能拿 → 成功
        m1.unlock();                 // ③ 拿不到 m2 → 释放 m1
        std::this_thread::yield();   // ④ 退避——让另一个线程先运行
        // ⑤ 下一轮再试
    }
}
1
2
3
4
5
6
7
8
9
10

这就是经典的退避重试(backoff-and-retry)——避免死锁的最简方式。

# 7.3 scoped_lock 的 RAII 多锁包装

// C++17 scoped_lock = 多个 lock_guard 的原子打包
std::scoped_lock lock(mtx1, mtx2, mtx3);  // 三个锁——一语句避免死锁
// 等价于:
//   std::lock(mtx1, mtx2, mtx3);
//   std::lock_guard g1(mtx1, std::adopt_lock);
//   std::lock_guard g2(mtx2, std::adopt_lock);
//   std::lock_guard g3(mtx3, std::adopt_lock);
1
2
3
4
5
6
7

# 8. 定时锁与 RAII 守卫的完整家族

# 8.1 timed_mutex 的 try_lock_for 与 try_lock_until

std::timed_mutex tmtx;

// 尝试获取——最多等 100ms
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
    std::lock_guard<std::timed_mutex> g(tmtx, std::adopt_lock);
    // 已获取——工作
} else {
    // 超时——走降级路径
}
1
2
3
4
5
6
7
8
9

内部流程:try_lock_for 相当于 lock 的 slow path 加了一个超时参数——futex(FUTEX_WAIT, timeout)。内核在超时后自动唤醒线程。

# 8.2 lock_guard vs unique_lock vs shared_lock 的选择

RAII 守卫 支持的锁类型 可手动 unlock 可移动 sizeof
std::lock_guard 任何 BasicLockable ❌ ❌ 1 ptr (8B)
std::unique_lock 任何 BasicLockable ✅ ✅ 2 ptr+bool (16B)
std::shared_lock shared_mutex ✅ ✅ 2 ptr+bool (16B)
std::scoped_lock 可变参数多锁 ❌ ❌ N ptr

选型原则:

  • 只需要作用域锁 → lock_guard(最简单、零额外存储)
  • 需要配合条件变量 → unique_lock(条件变量要求可 unlock 的锁)
  • 需要延迟锁定 / 提前解锁 → unique_lock
  • 读写锁的读端 → shared_lock
  • 多个锁原子获取 → scoped_lock

# 8.3 为什么条件变量要求 unique_lock 而非 lock_guard

condition_variable::wait 需要可手动 unlock 的锁——lock_guard 的 unlock 是 private 的。wait 内部需要:

  1. 释放锁(lock.unlock())
  2. 进入睡眠(futex_wait)
  3. 被唤醒后重新获取(lock.lock())

只有 unique_lock 提供公开的 unlock() 和 lock() —— lock_guard 不能用于 cv。


# 9. condition_variable 的完整机制

# 8.1 条件变量为何配合mutex

std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;

// 消费者
void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return data_ready; });  // ① 原子地:释放锁 → 睡眠 → 被唤醒 → 重新获取锁
    // ② data_ready == true — 锁被重新获取,可以安全地访问数据
}
1
2
3
4
5
6
7
8
9
10

为什么必须配合 mutex:条件变量 wait 的内部:

  1. 原子地释放锁 + 进入等待队列
  2. 被唤醒(notify 或假唤醒)后——原子地重新获取锁
  3. 检查条件——如果条件仍不满足——回到步骤 1

没有 mutex → 检查和睡眠之间有竞态窗口(lost wakeup)。

# 8.2 等待者的完整状态机

消费者线程:

  [运行]
    │ lock(mtx) — 获取互斥锁
    ▼
  [持锁] ── cv.wait() ──► [等待中]
                             │ ① unlock(mtx) — 原子地释放锁
                             │ ② 进入 futex_wait — 挂起线程
                             │
                        (生产者 notify)
                             │
                             ▼
                           [被唤醒]
                             │ ① 从 futex_wait 返回
                             │ ② lock(mtx) — 重新获取锁
                             ▼
                           [持锁] ── check data_ready ── true → 继续
                             │                        │
                             │                        │ false → 回到 [等待中]
                             ▼
                           [运行]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

关键:cv.wait() 保证「释放锁→挂起→被唤醒→重新获取锁」这个整体是原子的——因为如果在「释放锁」和「挂起」之间通知到来——futex 会直接跳过挂起、立即返回。

# 8.3 notify_one vs notify_all 的等待唤醒语义

// notify_one → 唤醒等待队列中的第一个线程(FIFO 或任意——由 OS 决定)
cv.notify_one();  // 性能最优——只唤醒 1 个——减少上下文切换

// notify_all → 唤醒所有等待线程——每个被唤醒的线程重新竞争锁、检查条件
cv.notify_all();  // 用于「所有等待者都可能满足条件」的场景
1
2
3
4
5

# 8.4 为什么丢失唤醒致命

// ❌ 生产者通知在被消费者等待之前发出——唤醒丢失
// 时间线:
//   T1: 生产者加数据 + cv.notify_one()
//   T2: 消费者 cv.wait() —— 永远等不到这次通知
// → 消费者永远睡眠——即使队列有数据

// ✅ 解决方案——条件检查在 mutex 保护下
void producer() {
    std::lock_guard lock(mtx);
    q.push(data);
    cv.notify_one();           // 通知在持有锁时发出——但 wait 被锁挡着
}
void consumer() {
    std::unique_lock lock(mtx);
    cv.wait(lock, []{ return !q.empty(); }); // 检查条件——即使通知在 wait 前发出,
    // 条件为 true → wait 直接通过!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 9. spurious wakeup 的根因与正确范式

# 9.1 假唤醒的硬件来源

假唤醒 = cv.wait() 返回了,但没有线程调用过 notify。来源包括:

  1. 信号中断:POSIX 的 pthread_cond_wait 可能被信号中断(如 SIGCHLD、SIGALRM)
  2. 调度器竞态:操作系统可能因为调度优先级而临时唤醒一个线程——尽管条件不满足
  3. 多核竞态:核之间的缓存一致性延迟可能导致 wait 误返回

# 9.2 标准为何允许假唤醒

POSIX 标准明确允许 pthread_cond_wait 假唤醒——因为消除假唤醒需要比检查条件更复杂的同步机制——代价远大于收益。C++ 继承了这条规则——但给了程序员正确工具:带谓词的 wait。

# 9.3 唯一正确的使用范式

// ❌ 错误——不检查条件
cv.wait(lock);

// ❌ 错误——用 if 而非 while 检查条件
if (!data_ready) cv.wait(lock);  // 假唤醒 — 直接跳过了 if — 继续执行

// ✅ 正确——用 wait 的带谓词版本(内部用 while 循环)
cv.wait(lock, [] { return data_ready; });

// ✅ 等价——手动 while 循环
while (!data_ready) { cv.wait(lock); }
1
2
3
4
5
6
7
8
9
10
11

# 9.4 生产者-消费者的正确写法

std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;
bool done = false;

void producer(int n) {
    for (int i = 0; i < n; ++i) {
        std::lock_guard lock(mtx);
        q.push(i);
        cv.notify_one();
    }
    std::lock_guard lock(mtx);
    done = true;
    cv.notify_all();    // 通知所有等待者——队列永远不会再有新数据
}

void consumer() {
    while (true) {
        std::unique_lock lock(mtx);
        cv.wait(lock, [] { return !q.empty() || done; });  // 正确谓词——检查两个条件
        if (q.empty() && done) break;   // 退出条件
        int val = q.front(); q.pop();
        lock.unlock();                  // 尽早释放锁
        process(val);
    }
}
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

# 10. 综合案例串讲

# 10.1 案例真相揭晓

# 疑问 答案
① futex 是什么? 第 3 章:fast path 原子 CAS + slow path futex_wait——两阶段模型
② 自旋 vs 阻塞? 第 4 章:临界区 < 50ns 自旋, > 50μs 阻塞, 中间自适应
③ recursive_mutex? 第 5 章:内部计数+owner ID——可重入但掩盖设计缺陷——避免使用
④ shared_mutex 读写锁? 第 6 章:读共享写独占——读多 > 80% 才值
⑤ 多锁死锁避免? 第 7 章:std::lock 尝试-回退 + scoped_lock 原子打包
⑥ cv 为什么需要 mutex? 第 8 章:原子释放+等待+重新获取——防止 lost wakeup
⑦ 假唤醒? 第 9 章:用带谓词的 wait——while 替代 if

案例①修复(死锁):log 调用 dump_state → 两个函数用同一把非递归锁 → 拆成「无锁版」。

案例②修复(假唤醒):cv.wait(lock, [] { return !q.empty(); });——用谓词检查条件。

# 10.2 互斥锁的完整生命周期

线程 A: m.lock()

═══════ fast path ═══════
  ① lock cmpxchg [state], 0→1  → CAS 成功 → 零系统调用 → 约 15ns

═══════ slow path(有竞争)═══════
  ② 自旋 100 次 PAUSE → 约 1.4μs
  ③ 自旋失败 → futex(FUTEX_WAIT) → 挂起 → 约 3μs 上下文切换

═══════ unlock ═══════
  ④ xchg [state], 0 → state 从 2→0(有 waiter)
  ⑤ futex(FUTEX_WAKE, 1) → 唤醒 1 个等待线程
  ⑥ 被唤醒的线程重新走 fast path — CAS 成功 — 获锁
1
2
3
4
5
6
7
8
9
10
11
12
13

# 10.3 设计哲学回扣

哲学 1:两层模型是「无竞争时零开销 + 有竞争时正确等待」的最优实现

Fast path 用原子 CAS(无竞争时 ~15ns),slow path 用 futex(有竞争时正确等待)。不是 hack——是 futex(fast userspace mutex)从设计之初就是两阶段模型。99% 的锁获取走 fast path——这就是为什么 99% 的锁没有任何系统调用开销。 这和 C++ 的零开销抽象哲学一脉相承——不为不需要的同步付费。

哲学 2:自旋的收益和代价在同一个尺度上——自适应是唯一正确答案

自旋在临界区 < 50ns 时最优,临界区 > 50μs 时灾难。自适应策略(自旋几次→失败→睡眠)是对「不知道临界区多长」这一现实的工程回应。glibc 的自适应 mutex 就是这哲学的产物——先试自旋再 futex,把两种策略的优点合并。 PAUSE 指令在自旋期间降低功耗(Intel HT 让兄弟线程继续跑)——硬件层也在为自适应锁做配合。

哲学 3:条件变量是 mutex + 等待队列的原子化——缺一则 lost wakeup

为什么 cv.wait 必须配合 mutex?因为「检查条件→睡眠」不是原子的。不原子的关键是 missed wakeup——通知可能在你检查条件和睡眠之间到来。cv.wait 的原子化就是 futex_wait 的「比较+睡眠」一步到位——消除了这个竞态窗口。这是并发编程中最重要的设计模式——所有高级同步原语(semaphore、barrier、future)都建立在这一模式之上。

哲学 4:假唤醒的存在是 POSIX 的遗产——C++ 给了确定性解药

假唤醒不是 bug——是 POSIX 的工程选择(避免更复杂的同步机制)。C++ 在 std::condition_variable::wait 上给了带谓词的重载——把「用 while 替代 if」的范式从推荐变成了默认。错误的存在是工程性的——消灭错误的工具也是工程性的。 理解假唤醒的根源(信号、调度、缓存延迟)才能真正理解为什么这个「看起来是 bug 的行为」其实是操作系统设计的必然结果。

哲学 5:递归锁不是功能——是债务

recursive_mutex 允许同一线程重复锁定——这消除了表层症状(死锁),但保留了深层问题(未定义的不变量边界)。每当你觉得需要递归锁时——退一步问:是不是 API 拆分有问题?是不是有的函数应该分「无锁版」和「有锁版」? 递归锁是在代码重构之前的一个临时补丁——不是最终答案。

# 10.4 速查表合集

锁类型速查:

锁类型 可重入 共享/独占 典型场景
std::mutex ❌ 独占 通用互斥
std::recursive_mutex ✅ 独占 旧代码兼容(避免)
std::shared_mutex ❌ 读写 读多写少 (>80%)
std::timed_mutex ❌ 独占 需要 try_lock_for

cv 正确范式:

// ✅ 永远用这种形式
cv.wait(lock, [&] { return condition; });

// ✅ 等价手动版
while (!condition) { cv.wait(lock); }
1
2
3
4
5

x86 锁指令速查:

C++ 无竞争指令 有竞争路径
m.lock() lock cmpxchg (~15ns) futex WAIT (~3μs)
m.unlock() xchg (~5ns) futex WAKE (~2μs)

下一篇:mutex 和条件变量的同步原语说清了。下一篇进入 44.thread 与 jthread 机制——线程的构造销毁规则、为什么不能拷贝、jthread 自动 join 的设计、stop_token 协作式取消的正确姿势。

上次更新: 2026/06/10, 11:13:41
atomic原子操作原理
thread与jthread机制

← atomic原子操作原理 thread与jthread机制→

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