⚡C++ 并发编程

死锁与锁顺序

面试回答

常见问法

  • 什么是死锁?典型场景有哪些?
  • 工程里通常怎么避免死锁?
  • 锁顺序为什么重要?
  • std::lock / std::scoped_lock 为什么更安全?
  • 死锁、活锁、饥饿分别有什么区别?
  • 死锁的四个必要条件是什么?如何破坏它们?

回答

死锁是指多个线程或执行单元相互等待对方持有的资源,导致所有相关线程都无法继续推进。 在 C++ 里最典型的死锁场景,就是多个线程以不同顺序获取多把互斥锁,最终形成循环等待。

比如线程 A 先锁 m1 再等 m2,线程 B 先锁 m2 再等 m1,这时两边都不释放,程序就卡住了。

工程上避免死锁,核心不是“碰运气”,而是让加锁规则可预测。常见做法有三类:

  1. 固定加锁顺序 所有代码路径都按同样顺序拿锁,比如永远先拿 m1 再拿 m2。这样可以直接破坏“循环等待”。

  2. 一次性获取多把锁 使用 std::lockstd::scoped_lock 同时处理多把互斥量,避免手写交错加锁逻辑。

  3. 缩短持锁时间 不要在持锁区做 IO、网络、复杂计算、回调、日志等耗时或不可控操作,减少锁竞争和嵌套加锁机会。

典型安全写法:

std::mutex m1, m2;

void safe() {
    std::scoped_lock lock(m1, m2);
    // 已安全持有两把锁
}

如果面试官继续追问,我会补一句: 死锁本质上是资源分配顺序失控问题。工程上最有效的方案是:统一锁顺序 + RAII + 尽量减少多锁场景。

追问

  • 死锁的四个必要条件是什么?
  • 为什么统一锁顺序能避免死锁?
  • std::lock 和手写两次 lock() 的区别是什么?
  • std::scoped_lockstd::lock_guard / std::unique_lock 怎么选?
  • 活锁和死锁的区别是什么?
  • 持锁期间写日志、调用回调为什么危险?
  • 只用了一个 mutex,还可能死锁吗?

原理展开

1. 什么是死锁,本质是什么

死锁本质上是:多个执行单元形成资源等待环。 每个线程都在等别人释放资源,但别人也在等它,于是整个系统进入永久阻塞状态。

最经典的两锁交叉示例:

std::mutex m1, m2;

void t1() {
    std::lock_guard<std::mutex> lk1(m1);
    std::lock_guard<std::mutex> lk2(m2);
    // ...
}

void t2() {
    std::lock_guard<std::mutex> lk2(m2);
    std::lock_guard<std::mutex> lk1(m1);
    // ...
}

如果:

  • t1 先拿到 m1
  • t2 先拿到 m2

那么:

  • t1m2
  • t2m1

形成闭环等待,死锁出现。


2. 死锁的四个必要条件

经典答案一定要会背,但更重要的是会解释:

  1. 互斥(Mutual Exclusion) 资源一次只能被一个线程占有。 互斥锁天然满足这一点。

  2. 持有并等待(Hold and Wait) 线程已经持有部分资源,同时继续申请新资源。

  3. 不可剥夺(No Preemption) 已获得的资源不能被别人强行拿走,只能由持有者主动释放。

  4. 循环等待(Circular Wait) 存在一个线程等待环:A 等 B,B 等 C,C 又等 A。

面试里的高分说法

避免死锁,本质就是破坏这四个条件中的任意一个。 而工程中最常破坏的是:

  • 循环等待:统一锁顺序
  • 持有并等待:一次性拿齐多把锁
  • 不可剥夺:用超时、失败回退、重试机制降低永久等待风险

3. 为什么“锁顺序”是最常用的工程方案

因为它简单、稳定、可审查。

如果系统规定:

  • 永远先锁账户对象 ID 小的,再锁 ID 大的
  • 永远先锁全局锁,再锁局部锁
  • 永远先锁对象内部状态锁,再锁缓存锁

那么所有线程都遵守同一顺序,就不会形成环路,循环等待被破坏,死锁自然消失。

典型例子:按地址或业务 ID 排序加锁

void transfer(Account& a, Account& b, int amount) {
    Account* first = &a;
    Account* second = &b;

    if (first > second) {
        std::swap(first, second);
    }

    std::lock_guard<std::mutex> lk1(first->mtx);
    std::lock_guard<std::mutex> lk2(second->mtx);

    // 执行转账逻辑
}

工程意义

这类规则一旦建立,就可以:

  • 写进编码规范
  • 做 code review 检查
  • 降低新增代码引入死锁的概率

4. std::lock 为什么能降低死锁风险

std::lock(m1, m2, ...) 的作用是:一次性锁住多把互斥量,并且内部采用死锁规避策略。

常见写法:

std::mutex m1, m2;

void safe() {
    std::lock(m1, m2);
    std::lock_guard<std::mutex> lk1(m1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(m2, std::adopt_lock);
    // ...
}

为什么比手写两次 lock() 更安全

手写:

m1.lock();
m2.lock();

问题是线程之间可能交错执行,导致循环等待。

std::lock 会以一种统一算法尝试获取多把锁,如果过程中失败,会释放已拿到的锁并重试,从而避免“每个线程拿着一部分锁互相等”的典型死锁局面。

面试里要注意的表述

  • 不要说 std::lock “绝对不会死锁任何场景” 更准确地说:它能避免由本次参与的这些互斥量在本次联合加锁过程中产生的典型死锁。
  • 如果系统里还有别的锁、别的路径、回调嵌套锁、外部资源等待,仍然可能出现更复杂的死锁。

5. std::scoped_lock:更现代、更推荐

C++17 以后,多把锁场景优先考虑 std::scoped_lock

std::mutex m1, m2;

void safe() {
    std::scoped_lock lock(m1, m2);
    // ...
}

优点

  • 支持同时锁多把 mutex
  • 基于 RAII,异常安全
  • 代码更短,不需要手动 adopt_lock
  • 可读性更好,面试中也更符合现代 C++ 风格

什么时候比 std::lock + lock_guard 更合适

几乎所有“作用域内持有多把锁”的场景,std::scoped_lock 都更直接。


6. 除了锁顺序,还能怎么规避死锁

方法一:缩短临界区

减少持锁时间是通用原则。

不要在持锁期间做这些事:

  • 文件 IO
  • 网络请求
  • 睡眠 / 等待条件
  • 日志打印
  • 调用用户回调
  • 复杂计算
  • 再调用可能加锁的外部模块

错误示例:

std::mutex m;

void bad() {
    std::lock_guard<std::mutex> lk(m);
    do_network_request();   // 风险很高
    write_log();            // 日志模块可能内部也有锁
}

更合理的做法是先拷贝必要数据,释放锁后再做耗时操作。


方法二:尽量减少多锁嵌套

多把锁是死锁的高发区。 如果业务允许,可以:

  • 合并状态,减少锁数量
  • 用更高层的并发模型替代细粒度锁拼装
  • 用消息队列、Actor 模型、任务串行化减少共享状态

方法三:使用 try_lock / 超时机制做失败回退

有些场景不能长期等待,可以使用:

  • try_lock
  • std::timed_mutex
  • std::recursive_timed_mutex

示例:

std::timed_mutex m;

void f() {
    if (m.try_lock_for(std::chrono::milliseconds(50))) {
        std::lock_guard<std::timed_mutex> lk(m, std::adopt_lock);
        // ...
    } else {
        // 降级、重试、返回错误
    }
}

这类方案的意义在于:

  • 防止线程无限阻塞
  • 更容易做服务降级
  • 更适合高可用系统

但要注意: 它不等于从根本上消除了死锁设计问题,只是给了系统逃生通道。


7. 活锁、饥饿和死锁的区别

死锁

线程都卡住,不再推进。 特点是:谁也不动

活锁

线程没有阻塞,但一直在“礼让、重试、失败、再重试”,结果也没有实际推进。 特点是:大家都在动,但事情没做成

例如两个线程都用 try_lock,拿不到就马上释放已有资源并重试,如果重试策略完全对称,可能一直互相谦让。

饥饿

某个线程长期拿不到资源,但别的线程在继续推进。 特点是:系统整体在跑,但某个线程一直没机会

面试简答模板

  • 死锁:互相等待,系统停住
  • 活锁:反复重试,看似忙碌但无进展
  • 饥饿:有线程长期得不到执行或资源机会

8. 单锁场景也可能“死锁”

很多人以为“只有多把锁才会死锁”,这不完整。

情况一:同一线程重复锁非递归 mutex

std::mutex m;

void g();

void f() {
    std::lock_guard<std::mutex> lk(m);
    g();
}

void g() {
    std::lock_guard<std::mutex> lk(m); // 同线程再次加锁,卡住
}

这本质上是自死锁

情况二:锁 + 条件变量 / Future / Join 组合等待

例如线程持锁后等待另一个线程完成,而另一个线程完成又需要拿这把锁,也会死锁。

情况三:析构、回调、日志中的隐式锁

这是工程里最容易漏掉的点:

  • 持锁时打印日志,日志系统内部又有锁
  • 持锁时调用用户回调,回调里再进入当前模块拿锁
  • 析构过程触发清理逻辑,又尝试获取同一把或关联锁

所以真实系统中的死锁,往往不是“明面上的两把锁”,而是隐式锁嵌套


9. recursive_mutex 能不能解决死锁

std::recursive_mutex 允许同一线程重复获取同一把锁,能解决“自死锁”的一部分问题,但它不是死锁通用解法

为什么不推荐滥用

  • 它掩盖了设计问题
  • 让锁边界更难推理
  • 无法解决多线程之间的循环等待
  • 容易让代码结构越来越混乱

工程原则

除非场景确实需要同线程递归进入,并且边界明确,否则优先重构调用关系,而不是用 recursive_mutex 兜底。


10. 工程实践里的选择原则

优先级建议

  1. 能不共享就不共享
  2. 能单锁就不要多锁
  3. 必须多锁时先定义全局顺序
  4. 能用 std::scoped_lock 就不要手写多次 lock()
  5. 锁内只做最小必要工作
  6. 不要在锁内调用不受控代码
  7. 复杂并发问题优先靠设计收敛,而不是靠补丁式加锁

面试高分表达

真正成熟的并发工程不是“见共享就上锁”,而是优先通过数据拆分、职责隔离、减少共享状态来降低锁复杂度。 最好的死锁修复方案,往往不是更聪明地加锁,而是减少需要加锁的地方。


对比总结

概念 / 方案是什么适用场景优点缺点 / 风险面试关键词
死锁多线程互相等待资源,无法推进多锁、嵌套锁、锁与等待混用需要重点识别一旦出现通常很难排查循环等待、资源环
活锁线程不断重试但无实际进展try_lock + 对称退避策略不会永久阻塞CPU 空转、吞吐差忙而不进
饥饿某线程长期拿不到资源高竞争、不公平调度系统其他部分还能跑个别任务长期延迟不公平、长期等待
固定锁顺序所有路径按统一顺序加锁多锁场景最常用简单、稳定、可审查需要全局规范破坏循环等待
std::lock一次性锁多把 mutex需要同时拿多把锁避免典型交叉死锁写法略繁琐联合加锁
std::scoped_lockC++17 的多锁 RAII 封装现代 C++ 多锁场景简洁、异常安全灵活性不如手动控制推荐优先用
lock_guard作用域加锁释放单锁、简单临界区最轻量、最直接不能手动 unlockRAII
unique_lock更灵活的锁管理器需要延迟加锁、解锁、配合条件变量功能丰富开销和复杂度更高条件变量常配套
recursive_mutex同线程可重复加锁特定递归调用场景可避免自死锁容易掩盖设计问题不要滥用
try_lock / 超时锁获取失败就返回或超时高可用、降级、避免长期阻塞可做失败回退不能替代正确设计降级、重试

易错点

  • 只会背“死锁是互相等待”,不会说如何在工程上规避
  • 只记住“两把锁交叉”,忽略单锁自死锁
  • 误以为 std::lock 能解决所有死锁问题
  • 把“避免死锁”和“提升性能”混为一谈 死锁关注正确性,性能是另一个维度
  • 持锁期间做 IO、日志、网络、回调、等待
  • 忽略析构函数、智能指针释放路径中的隐式锁
  • 多模块共同维护同一组锁,但没有统一顺序规范
  • 误用 recursive_mutex 掩盖糟糕设计
  • 认为“测试没复现就没问题” 死锁很多是低概率时序问题,线上更容易暴露
  • 只关注显式 mutex,忽略条件变量、future、join、线程池任务依赖等等待链

记忆技巧

  • 死锁四条件:互斥、持有等待、不可剥夺、循环等待
  • 规避三板斧:统一顺序、同时加锁、尽快释放
  • 一句话记忆死锁不是玄学,本质是资源等待环;解决不是碰运气,而是让加锁顺序可预测。
  • 答题口诀先定义,再举例,再讲规避,再补工程坑点。

面试速答版

死锁是多个线程互相等待对方持有的资源,导致都无法继续执行。C++ 里最典型的场景是多把锁按不同顺序获取,比如线程 A 先锁 m1 再等 m2,线程 B 先锁 m2 再等 m1。 工程上最常见的规避方式有三种:第一,统一加锁顺序,破坏循环等待;第二,用 std::lockstd::scoped_lock 一次性获取多把锁;第三,缩短持锁时间,不要在锁里做 IO、日志、回调和长计算。 如果继续追问,我会补充死锁四个必要条件是互斥、持有并等待、不可剥夺、循环等待,而工程里最有效的通常是破坏循环等待。


面试加分版

死锁是并发里一种典型的资源等待问题,本质是多个线程形成了资源等待环,谁都在等别人释放资源,所以系统无法继续推进。最经典的例子就是两把锁交叉获取:线程 A 先拿 m1 再等 m2,线程 B 先拿 m2 再等 m1。这类问题在代码层面看起来只是锁顺序不一致,但在系统层面它会直接导致线程永久阻塞。

判断死锁通常可以从四个必要条件理解:互斥、持有并等待、不可剥夺、循环等待。工程里最常用的不是去背理论,而是针对性破坏这些条件。最有效的办法通常有三类。第一类是统一锁顺序,比如永远先锁全局对象再锁局部对象,或者按对象地址、业务 ID 排序后加锁,这样直接破坏循环等待。第二类是一次性获取多把锁,例如用 std::lock 或 C++17 的 std::scoped_lock,避免手写多次 lock() 导致交错获取。第三类是缩短临界区,不要在持锁期间做 IO、网络请求、日志打印、用户回调或复杂计算,因为这些操作会显著增加锁持有时间,也容易引入隐式锁嵌套。

工程实践里我会优先遵循几个原则:能不共享就不共享,能单锁就不用多锁;必须多锁时先定义全局顺序;能用 std::scoped_lock 就不要手写;锁内只做最小必要工作;不要在锁里调用不可控代码。很多线上死锁并不是教科书里那种显式两把锁,而是日志、析构、回调、条件变量、future 或线程 join 形成的隐式等待链。所以面试里如果能补充这些真实工程坑点,回答会更完整、更像做过项目的人。