【C++ 面试真题】聊聊 C++ 的互斥锁与读写锁
有了线程就有共享数据,有了共享数据就有竞态------锁是第一道防线。背得出"lock_guard 自动解锁"只是及格,真考你的是"lock_guard 和 unique_lock 差在哪、双锁死锁怎么破、读写锁什么时候反而更慢、recursive_mutex 为什么是坏味道 "。本文把
<mutex>家族一次讲透。
一、开场:mutex 家族有哪些?
❓ 介绍一下 C++11 的互斥锁?
✅ 四种锁 + 三种 RAII 包装,一张表:
| 锁 | 特点 | 场景 |
|---|---|---|
| mutex | 基本款,不可重入 | 绝大多数 |
| timed_mutex | 带超时的 lock_for/try_lock_for | 不能久等的路径 |
| recursive_mutex | 同线程可重复加锁 | 遗留代码(慎) |
shared_mutex [C++14/17] |
读写锁:多读单写 | 读多写少 |
| 包装 | 开销 | 能力 |
|---|---|---|
| lock_guard | 最轻 | 作用域加解锁,仅此而已 |
| unique_lock | 稍重 | 可延迟/手动/移动,配条件变量 |
scoped_lock [C++17] |
轻 | 一次锁多把,无死锁 |
回答思路:先报"四种锁、三种包装",点一句"日常 90% 的答案是 mutex + lock_guard"------面试官会追包装之间的差别和死锁。
二、为什么必须配 RAII:手挫锁的灾难
❓ 直接调 lock/unlock 不行吗?
✅ 语法上行,工程上死路------每一条提前返回、每一个异常,都是一个"忘了 unlock"的定时炸弹:
cpp
mutex m;
void f() {
m.lock();
if (err) return; // ❌ 锁永远没了
if (bad) throw 0; // ❌ 同上
m.unlock(); // 唯一活路
}
lock_guard 用作用域兜底一切路径:
cpp
void f() {
lock_guard<mutex> g(m);
if (err) return; // ✅ 析构自动解锁
if (bad) throw 0; // ✅ 异常也解锁
}
💡 这正是 RAII 思想在锁上的翻版(内存管理篇的智能指针是同一招):把"必须成对出现的操作"绑定到对象生命周期,路径再多也漏不掉。
三、lock_guard vs unique_lock:怎么选
❓ 什么时候非用 unique_lock 不可?
✅ lock_guard 只会"出生加锁、析构解锁";unique_lock 多四样本事:
cpp
mutex m;
condition_variable cv;
// ① 手动控制时机
unique_lock<mutex> ul(m);
ul.unlock(); // 先放
do_something();
ul.lock(); // 再拿
// ② 延迟加锁:构造时先不锁
unique_lock<mutex> ul2(
m, defer_lock);
// 需要时再锁
ul2.lock();
// ③ 可移动:能转移所有权
// ④ 条件变量只认它
cv.wait(ul); // 必须传 unique_lock
代价:unique_lock 内部多记"当前是否持有"的状态,略慢于 lock_guard。
🎯 选型 :普通临界区一律
lock_guard(或 C++17 单锁时scoped_lock);要配条件变量、要中途解锁、要转移锁所有权------才上unique_lock。
四、死锁:双锁互等的僵局
❓ 死锁怎么发生、怎么破?
✅ 经典场景------两个线程反向拿两把锁:
cpp
// 线程 1:先 A 后 B
lock(A); lock(B);
// 线程 2:先 B 后 A
lock(B); lock(A);
// 各拿一把、互等对方 → 永久卡死
破法两招:
① 全局锁序------约定所有代码都按固定顺序拿锁(如按地址、按 id):
cpp
// 永远先锁小的,再锁大的
void transfer(Acct& a, Acct& b) {
auto& first = a.id < b.id ? a : b;
auto& second = a.id < b.id ? b : a;
first.m.lock();
second.m.lock();
// ... 转账逻辑
}
② std::lock / scoped_lock 一次拿全 [C++11/17]------用"试探 + 回退"算法避免僵持:
cpp
// [C++17] 一步到位,内部死锁免疫
scoped_lock lk(m1, m2);
⚠️ 死锁四条件(互斥/持有等待/不可剥夺/循环等待)是理论框架,工程上破掉"循环等待"一条就够------锁序统一 或一把梭 scoped_lock 都是干这个的。
五、shared_mutex:读写锁
❓ 读写锁是什么?什么时候用?
✅ 把"读"和"写"分开对待------多个读者可同时进,写者独占:
cpp
shared_mutex rw;
map<string, int> data;
// 读路径:共享锁
shared_lock<shared_mutex> r(rw);
auto it = data.find(k);
// 写路径:独占锁
unique_lock<shared_mutex> w(rw);
data[k] = v;
适用前提很苛刻:读操作远多于写、临界区又有一定长度(配置表、元数据缓存是标准场景)。
⚠️ 读写锁不是银弹 :写者到达时读者要排队、读者写者来回切换有成本------读多但临界区极短时,plain mutex 反而更快(一次原子 CAS 就进去了,读写锁的状态切换比数据本身还贵)。先测再换。
六、recursive_mutex:能重入,但别用
❓ 同一个线程把一把锁锁两次会怎样?
✅ 普通 mutex 直接 死锁------自己等自己释放。recursive_mutex 允许同线程重复加锁(计数 +1,解锁同样要配对次数):
cpp
recursive_mutex m;
void a() {
lock_guard<recursive_mutex> g(m);
b(); // b 里又锁 m
}
void b() {
lock_guard<recursive_mutex> g(m);
// recursive:重入 OK
}
⚠️ 但它基本是设计坏味道:需要重入,说明函数职责不清(公开接口和内部函数共享一把锁)。正确解法是拆出"不加锁的私有实现",公开方法锁住后调用它------recursive_mutex 只配给改不动的遗留代码续命。
cpp
// ✅ 更好的结构:锁一次,干活的全在里头
void a() {
lock_guard<mutex> g(m);
bImpl();
}
void bImpl() { /* 无锁内部版 */ }
七、加锁的纪律:粒度与嵌套
❓ 锁的粒度怎么拿捏?
✅ 三条纪律:
- 粒度最小:只包住真正共享的操作,IO、日志、耗时计算挪出临界区------锁的是数据,不是整段代码;
- 嵌套最少:临界区内尽量不再拿锁;确需多锁,走第四节的锁序/scoped_lock;
- 共享什么锁什么:多把小锁各自守各自的数据(分段锁),优于一把大锁守全世界。
cpp
// ❌ 锁住了整个业务
lock_guard g(m);
log(); compute(); save();
// ✅ 只锁共享的瞬间
{ lock_guard g(m); x = y; }
log(); compute(); save();
💡 数据竞争的判定不是"有没有锁",而是"同一数据是否被并发读写且至少一个是写"------先理清"谁共享什么",锁只是最后落下的手段。
八、面试高频追问
❓ Q1:try_lock 和 lock 的区别?
✅ try_lock 立即返回:拿到为 true,拿不到 false 不等待;try_lock_for 限时等待。适合"拿不到就干别的去"的路径(避免阻塞、避免死锁探测)。std::try_lock(m1, m2) 多锁版本拿不全会全部释放返回失败。
❓ Q2:scoped_lock 和 lock_guard 有什么区别?
✅ 单锁场景几乎等价;scoped_lock [C++17] 的本命是一次锁多把且免死锁(内部用 std::lock 算法),还免写模板参数。新代码多锁必 scoped_lock,单锁两者随意。
❓ Q3:mutex 加锁的开销到底有多大?
✅ 无竞争时一次 lock ≈ 一次原子 CAS(几十纳秒级);有竞争时涉及内核等待(微秒级起)。所以"无竞争的快路径"很便宜,真正贵的是竞争本身------优化方向是减少共享、缩小临界区,而不是纠结锁本身。
❓ Q4:condition_variable 为什么必须配 unique_lock?
✅ wait 的机制是"原子地解锁 + 睡眠",醒来后要重新加锁------锁必须支持中途解锁再锁回,lock_guard 做不到(它只在析构时解锁)。所以 wait 接口签名就要 unique_lock。
❓ Q5:spinlock(自旋锁)和 mutex 怎么选?
✅ 自旋 = 拿不到就空转烧 CPU(用 atomic_flag 可手写,下期原子篇有实现);mutex = 拿不到就睡眠让核。临界区极短、竞争低时自旋省了上下文切换;临界区长或竞争高时自旋纯烧电。标准库 mutex 内部常见"先自旋几次再睡眠"的混合策略。
❓ Q6:锁和原子变量是什么关系?
✅ 都解决数据竞争。原子变量适合单个标量 的无锁读写计数(计数器、标志位);锁适合多个变量的复合不变量(转账改两个账户)。原子是下期主角------粒度细、无阻塞,但只守得住单个对象。
九、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 日常答案 | mutex + lock_guard |
| 手挫 lock | 异常/提前返回必漏解锁 |
| unique_lock | 条件变量/中途解锁/可移动 |
| scoped_lock C++17 | 多锁一次拿,免疫死锁 |
| 死锁破法 | 全局锁序 或 scoped_lock |
| 读写锁 | 读多写少且临界区不短 |
| recursive | 能重入但是设计坏味道 |
| 粒度 | 锁数据不锁过程,IO 出去 |
| 无竞争开销 | 一次 CAS,纳秒级 |
| mutex vs atomic | 复合不变量 vs 单标量 |
一句话回顾
锁的答案是 RAII :日常 mutex + lock_guard;配条件变量或中途解锁上 unique_lock;多锁交给 scoped_lock 一次拿全 ,死锁从根上免疫;读写锁只救"读多写少且临界区不短",recursive_mutex 是坏味道的止痛药------锁数据、不锁过程,粒度就是正义。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续多线程篇------聊条件变量:虚假唤醒为什么必须防、丢失唤醒怎么发生的、它和 mutex 为什么是一对,敬请关注 👋