【C++ 面试真题】30. 聊聊 C++ 的互斥锁与读写锁
2026/8/24 15:03:30 网站建设 项目流程

【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 为什么是一对,敬请关注 👋

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询