【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"的定时炸弹:
mutex m;voidf(){m.lock();if(err)return;// ❌ 锁永远没了if(bad)throw0;// ❌ 同上m.unlock();// 唯一活路}lock_guard 用作用域兜底一切路径:
voidf(){lock_guard<mutex>g(m);if(err)return;// ✅ 析构自动解锁if(bad)throw0;// ✅ 异常也解锁}💡 这正是 RAII 思想在锁上的翻版(内存管理篇的智能指针是同一招):把"必须成对出现的操作"绑定到对象生命周期,路径再多也漏不掉。
三、lock_guard vs unique_lock:怎么选
❓ 什么时候非用 unique_lock 不可?
✅ lock_guard 只会"出生加锁、析构解锁";unique_lock 多四样本事:
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。
四、死锁:双锁互等的僵局
❓ 死锁怎么发生、怎么破?
✅ 经典场景——两个线程反向拿两把锁:
// 线程 1:先 A 后 Block(A);lock(B);// 线程 2:先 B 后 Alock(B);lock(A);// 各拿一把、互等对方 → 永久卡死破法两招:
① 全局锁序——约定所有代码都按固定顺序拿锁(如按地址、按 id):
// 永远先锁小的,再锁大的voidtransfer(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]——用"试探 + 回退"算法避免僵持:
// [C++17] 一步到位,内部死锁免疫scoped_locklk(m1,m2);⚠️ 死锁四条件(互斥/持有等待/不可剥夺/循环等待)是理论框架,工程上破掉"循环等待"一条就够——锁序统一或一把梭 scoped_lock都是干这个的。
五、shared_mutex:读写锁
❓ 读写锁是什么?什么时候用?
✅ 把"读"和"写"分开对待——多个读者可同时进,写者独占:
shared_mutex rw;map<string,int>data;// 读路径:共享锁shared_lock<shared_mutex>r(rw);autoit=data.find(k);// 写路径:独占锁unique_lock<shared_mutex>w(rw);data[k]=v;适用前提很苛刻:读操作远多于写、临界区又有一定长度(配置表、元数据缓存是标准场景)。
⚠️读写锁不是银弹:写者到达时读者要排队、读者写者来回切换有成本——读多但临界区极短时,plain mutex 反而更快(一次原子 CAS 就进去了,读写锁的状态切换比数据本身还贵)。先测再换。
六、recursive_mutex:能重入,但别用
❓ 同一个线程把一把锁锁两次会怎样?
✅ 普通 mutex 直接死锁——自己等自己释放。recursive_mutex 允许同线程重复加锁(计数 +1,解锁同样要配对次数):
recursive_mutex m;voida(){lock_guard<recursive_mutex>g(m);b();// b 里又锁 m}voidb(){lock_guard<recursive_mutex>g(m);// recursive:重入 OK}⚠️ 但它基本是设计坏味道:需要重入,说明函数职责不清(公开接口和内部函数共享一把锁)。正确解法是拆出"不加锁的私有实现",公开方法锁住后调用它——recursive_mutex 只配给改不动的遗留代码续命。
// ✅ 更好的结构:锁一次,干活的全在里头voida(){lock_guard<mutex>g(m);bImpl();}voidbImpl(){/* 无锁内部版 */}七、加锁的纪律:粒度与嵌套
❓ 锁的粒度怎么拿捏?
✅ 三条纪律:
- 粒度最小:只包住真正共享的操作,IO、日志、耗时计算挪出临界区——锁的是数据,不是整段代码;
- 嵌套最少:临界区内尽量不再拿锁;确需多锁,走第四节的锁序/scoped_lock;
- 共享什么锁什么:多把小锁各自守各自的数据(分段锁),优于一把大锁守全世界。
// ❌ 锁住了整个业务lock_guardg(m);log();compute();save();// ✅ 只锁共享的瞬间{lock_guardg(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 为什么是一对,敬请关注 👋