C++ 锁与死锁
更新时间:2026-08-27。本文是
languages/cpp/主题高手层第 10 篇。锁是最常用的同步手段,但"会用锁"和"锁不出问题"是两回事。死锁让程序"看起来活着、其实死了"——两个线程互相等对方手里的锁,谁都不放手。这一篇讲清锁的种类、死锁的成因和预防,以及锁和性能的关系。
本文要回答的问题
mutex、lock_guard、unique_lock、scoped_lock各是什么关系?- 死锁是怎么发生的?"死锁四条件"是哪四条?
- 如何避免死锁?加锁顺序为什么这么重要?
- 锁是性能杀手吗?什么时候该用自旋锁?
锁的家族谱
C++ 标准库的锁从"裸锁"到"RAII 封装"共四层:
| 类型 | 作用 | 特点 |
|---|---|---|
std::mutex | 裸锁 | 必须手动 lock() / unlock(),易漏 unlock |
std::lock_guard | RAII 独占锁 | 构造加锁、析构解锁,不能手动解锁,最常用 |
std::unique_lock | 灵活锁 | 可延迟加锁、可手动解锁、可与条件变量配合 |
std::scoped_lock(C++17) | 多锁 RAII | 一次锁多个 mutex,且按顺序加锁防死锁 |
cpp
#include <mutex>
std::mutex m;
// 裸锁:手动解锁,异常/早 return 就泄漏锁
void bad() {
m.lock();
// ... 如果这里抛异常,unlock 不会执行
m.unlock();
}
// lock_guard:RAII,作用域结束自动解锁
void good() {
std::lock_guard<std::mutex> guard(m);
// ... 抛异常也会自动解锁
}
// unique_lock:需要手动解锁或条件变量时用
void flexible() {
std::unique_lock<std::mutex> lock(m, std::defer_lock);
// 稍后手动 lock / unlock
lock.lock();
// ...
lock.unlock();
}记住这条经验:裸 mutex 只出现在极少数需要手搓锁的场景,99% 的情况用 lock_guard。 它和上一篇的 RAII 思想一脉相承——资源(锁)随对象生命周期自动释放。
死锁:两个线程互相"等死"
2.1 一个经典场景
cpp
std::mutex a, b;
void thread1() {
std::lock_guard<std::mutex> l1(a);
std::this_thread::sleep_for(10ms);
std::lock_guard<std::mutex> l2(b); // 等线程 2 释放 b
}
void thread2() {
std::lock_guard<std::mutex> l1(b);
std::this_thread::sleep_for(10ms);
std::lock_guard<std::mutex> l2(a); // 等线程 1 释放 a
}线程 1 持有 a 等 b,线程 2 持有 b 等 a——互相等对方,谁也等不到。程序挂死,但没有崩溃、没有报错,只有 top 里两个线程长期占用 CPU 或休眠,这是最阴险的故障形态之一。

2.2 死锁四条件(缺一不可)
| 条件 | 含义 | 例子 |
|---|---|---|
| 互斥 | 资源同时只能被一个线程用 | 锁本身就是互斥 |
| 持有并等待 | 拿着一个锁还去等另一个 | 持 a 等 b |
| 不可剥夺 | 锁不能被强制拿走 | mutex 只能主动释放 |
| 循环等待 | 等待关系成环 | 1 等 2 的锁,2 等 1 的锁 |
打破任意一条就能防死锁。实际工程最常用的手段是打破循环等待:
- 统一加锁顺序:所有线程按同一顺序加锁(先 a 后 b),就不会成环。
std::lock一次拿多把锁:它内部保证按统一顺序加锁,且失败时回退重试。
cpp
// 正确:一次锁多个,顺序由标准库保证
void safe() {
std::scoped_lock lock(a, b); // C++17,等价于 std::lock(a,b) + guard
}锁与性能:锁竞争是隐形瓶颈
3.1 锁的开销从哪来
加锁/解锁不是免费的:
| 开销项 | 说明 |
|---|---|
| 原子操作 | 锁内部用原子指令(如 x86 的 lock cmpxchg) |
| 缓存失效 | 锁变量被多个核竞争,缓存行在核间震荡 |
| 系统调用 | 拿不到锁时线程睡眠,唤醒要进内核 |
| 上下文切换 | 睡眠/唤醒伴随线程切换 |
锁竞争(contention)严重时,多核反而比单核慢——线程全在排队等锁,还多了切换开销。
3.2 自旋锁 vs 互斥锁
| 对比项 | 互斥锁(mutex) | 自旋锁(spinlock) |
|---|---|---|
| 拿不到锁时 | 睡眠,让出 CPU | 原地循环等待(spin) |
| 适用场景 | 临界区长、锁竞争激烈 | 临界区极短、锁竞争小 |
| 缺点 | 上下文切换开销大 | 空转浪费 CPU |
| 典型实现 | std::mutex | std::atomic_flag 手搓 |
cpp
#include <atomic>
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等:不断尝试
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};自旋锁适合"临界区只有几条指令"的场景(如无锁队列的入队操作);临界区稍长就用互斥锁,否则 CPU 空转是浪费。工程经验:别自己写锁,std::mutex 在多数场景已经够好。
3.3 减小锁粒度
- 缩小临界区:只把真正需要保护的几行放锁里,不要整个函数都锁。
- 读写锁:读多写少用
std::shared_mutex,多读并发、写独占。 - 无锁化:计数器用
std::atomic,见 原子操作入门。
cpp
#include <shared_mutex>
std::shared_mutex rw;
std::vector<int> data;
int read() {
std::shared_lock<std::shared_mutex> lock(rw); // 多读并发
return data.size();
}
void write(int v) {
std::unique_lock<std::shared_mutex> lock(rw); // 写独占
data.push_back(v);
}与本站性能主线衔接
- 多核性能:锁竞争是 并发与线程安全 的隐形杀手,火焰图上锁等待会显示为
futex或自旋热点。 - 原子操作:锁内部依赖原子指令,见 原子操作入门。
- 剖析工具:
perf看futex系统调用占比、top看线程状态(S/D 状态可能是锁等待),衔接 性能剖析。 - 低延迟设计:无锁队列、RCU 是消灭锁竞争的进阶路线,衔接 lockfree 模式。
一句话总结
锁的正确姿势:lock_guard/scoped_lock 走 RAII,shared_mutex 处理读多写少,std::lock 一次拿多锁;死锁靠"统一加锁顺序 + 打破循环等待"预防,锁竞争靠"缩小临界区 + 原子化"缓解。