mutex与条件变量
# 43.mutex与条件变量
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. mutex 的 futex 底层原理
- 4. 自旋锁 vs 阻塞锁的场合选择
- 5. recursive_mutex 的设计与陷阱
- 6. shared_mutex 读写锁的原理
- 7. std::try_lock 与 std::scoped_lock 的组合技
- 8. condition_variable 的完整机制
- 9. spurious wakeup 的根因与正确范式
- 10. 综合案例串讲
# 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——永远不会来
}
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 打破了这条假设
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 章
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(上下文切换) │
└────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2.2 为何这么切
疑惑:为什么不直接全部用 futex(全内核锁)?Fast path 的用户态原子操作真的必要吗?
论证:
- 绝大多数锁持有时长 < 50ns——在生产系统中,临界区通常只有几条指令。如果每次 lock 都进内核——上下文切换的开销(~2μs)比临界区执行时间大 40-100×。
- 原子操作的延迟在无竞争时 ≈ 15ns(lock cmpxchg)——比系统调用(~200ns 最小)快 10+× 。Fast path 用原子操作是工程必然——不是优化癖——是正确抽象。
- 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();
}
};
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
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 → 立即返回(锁可能已被释放——省去睡眠开销)
}
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 的「互斥」语义)
}
}
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
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 个周期——完全是浪费
}
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 负载和调度策略)
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
}
}
};
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;
}
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); /* ... */ } // 重入 — 不会死锁
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
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.4 从 recursive 改为非递归的重构指南
- 找出所有「在已持有锁时调用的嵌套路径」——用 clang-tidy 的
-Wthread-safety-analysis - 把内部函数拆成「无锁版本」——
_locked()后缀——供已持有锁的调用方用 - 替换递归锁为普通 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();
}
2
3
4
5
6
7
8
9
10
11
12
13
# 6.2 内部状态机三种状态
// shared_mutex 内部状态:一个 atomic<int> 编码所有状态
// > 0: N 个读者持有读锁
// = 0: unlocked
// < 0 (如 -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); // 同时原子锁定——无死锁
}
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(); // ④ 退避——让另一个线程先运行
// ⑤ 下一轮再试
}
}
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);
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 {
// 超时——走降级路径
}
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 内部需要:
- 释放锁(
lock.unlock()) - 进入睡眠(
futex_wait) - 被唤醒后重新获取(
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 — 锁被重新获取,可以安全地访问数据
}
2
3
4
5
6
7
8
9
10
为什么必须配合 mutex:条件变量 wait 的内部:
- 原子地释放锁 + 进入等待队列
- 被唤醒(notify 或假唤醒)后——原子地重新获取锁
- 检查条件——如果条件仍不满足——回到步骤 1
没有 mutex → 检查和睡眠之间有竞态窗口(lost wakeup)。
# 8.2 等待者的完整状态机
消费者线程:
[运行]
│ lock(mtx) — 获取互斥锁
▼
[持锁] ── cv.wait() ──► [等待中]
│ ① unlock(mtx) — 原子地释放锁
│ ② 进入 futex_wait — 挂起线程
│
(生产者 notify)
│
▼
[被唤醒]
│ ① 从 futex_wait 返回
│ ② lock(mtx) — 重新获取锁
▼
[持锁] ── check data_ready ── true → 继续
│ │
│ │ false → 回到 [等待中]
▼
[运行]
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(); // 用于「所有等待者都可能满足条件」的场景
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 直接通过!
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 9. spurious wakeup 的根因与正确范式
# 9.1 假唤醒的硬件来源
假唤醒 = cv.wait() 返回了,但没有线程调用过 notify。来源包括:
- 信号中断:POSIX 的
pthread_cond_wait可能被信号中断(如 SIGCHLD、SIGALRM) - 调度器竞态:操作系统可能因为调度优先级而临时唤醒一个线程——尽管条件不满足
- 多核竞态:核之间的缓存一致性延迟可能导致 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); }
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);
}
}
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 成功 — 获锁
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); }
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 协作式取消的正确姿势。