【C++ 面试真题】30. 聊聊 C++ 的互斥锁与读写锁

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

相关推荐
沙盘客1 小时前
AFSIM 示例解读(03)· 六自由度飞行仿真 six_dof(下):机场运作与弹道导弹投送
java·javascript·经验分享
ShineWinsu2 小时前
对于 C++:C++14中从变量模板、泛型 Lambda 到并发与字面量的解析
c++·算法
CHANCE V2 小时前
集合排序和流排序
java
16月6日-晴2 小时前
Java面向对象进阶—多态
java·开发语言
qq_185198693 小时前
SpringBoot-五-AOT
java·spring boot·后端
王大大的刀3 小时前
Spring AI 重试引起的 LLM 重复调用
java·人工智能
Heo3 小时前
怎么保证缓存和数据库一致性?
前端·后端·面试
风流 少年3 小时前
hutool
java·服务器·开发语言
H_oRIZoN_4 小时前
Linux入门DAY27(文件IO(系统调用)详解|open/read/write/lseek)
java·linux·服务器