C++ 多线程实战 14|如何系统性避免死锁

目录

一、什么是死锁?

二、死锁发生的四个条件(教科书级)

三、死锁的典型场景

四、避免死锁的系统性方法

[1. 保持固定的加锁顺序(最重要的规则)](#1. 保持固定的加锁顺序(最重要的规则))

[2. 使用 std::lock() 一次性锁定多个互斥量](#2. 使用 std::lock() 一次性锁定多个互斥量)

[3. 使用 std::scoped_lock(C++17 推荐)](#3. 使用 std::scoped_lock(C++17 推荐))

[4. 尽量缩小锁的作用域](#4. 尽量缩小锁的作用域)

[5. 避免在持锁时调用外部函数](#5. 避免在持锁时调用外部函数)

[6. 使用尝试锁(try_lock / try_lock_for)](#6. 使用尝试锁(try_lock / try_lock_for))

[7. 保证锁的释放(RAII 原则)](#7. 保证锁的释放(RAII 原则))

五、进阶:检测死锁的方法

六、总结:死锁的终极避坑法则

七、结语:活着比"锁死"更重要


多线程就像一群人合伙开公司,大家都很能干。

但如果沟通不畅,一个人等另一个人签字,另一个人又等前一个人批文件------

恭喜你,你的程序"卡死"了。

这就是死锁(Deadlock)。

今天,我们不讲理论公式,而是教你如何「系统性地」避免这种尴尬。


一、什么是死锁?

死锁(Deadlock)是指两个或多个线程互相等待对方释放资源,导致都无法继续执行的情况。

用生活打个比方:

  • 线程 A 拿了筷子,等碗;

  • 线程 B 拿了碗,等筷子;

    结果两人对视十秒,饭都凉了。

代码版的"吃饭问题"如下:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex chopstick;
std::mutex bowl;

void threadA() {
    std::lock_guard<std::mutex> lock1(chopstick);
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟操作
    std::lock_guard<std::mutex> lock2(bowl);
    std::cout << "A 吃上饭了!\n";
}

void threadB() {
    std::lock_guard<std::mutex> lock1(bowl);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::lock_guard<std::mutex> lock2(chopstick);
    std::cout << "B 吃上饭了!\n";
}

int main() {
    std::thread t1(threadA);
    std::thread t2(threadB);
    t1.join();
    t2.join();
}

运行后------程序卡死 了。

两个线程都拿着对方需要的锁,一直等到地老天荒。


二、死锁发生的四个条件(教科书级)

死锁的产生,必须满足以下四个条件:

条件 说明
互斥 资源一次只能被一个线程使用
请求与保持 线程持有资源的同时又请求新的资源
不剥夺 已获得的资源不能被强制剥夺
循环等待 多个线程形成一个循环等待链

破解死锁的关键

打破其中任意一个条件,死锁就无法成立!


三、死锁的典型场景

  1. 双重锁定顺序不一致

    两个线程获取锁的顺序不同,是最常见的死锁原因。

  2. 递归锁滥用

    自己锁自己多次而不解锁。

  3. 错误的条件等待

    锁没释放却调用了 wait()sleep()

  4. 多资源嵌套锁定

    一次性锁多个资源时没处理好顺序。


四、避免死锁的系统性方法

我们来从实践角度出发,讲如何「系统性」地避免死锁,而不是靠"经验"瞎猜。


1. 保持固定的加锁顺序(最重要的规则)

给所有资源定义「加锁顺序」,所有线程严格遵守相同顺序加锁

举个例子:

  • 对象 A 的锁 ID = 1

  • 对象 B 的锁 ID = 2

    那么所有线程都必须「先锁 ID 小的,再锁 ID 大的」。

示例:

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>

std::mutex m1, m2;

void safe_thread(int id) {
    if (id == 1) {
        std::lock_guard<std::mutex> lock1(m1);
        std::lock_guard<std::mutex> lock2(m2);
        std::cout << "线程 1 正常运行\n";
    } else {
        std::lock_guard<std::mutex> lock1(m1);  // 注意顺序一致
        std::lock_guard<std::mutex> lock2(m2);
        std::cout << "线程 2 正常运行\n";
    }
}

int main() {
    std::thread t1(safe_thread, 1);
    std::thread t2(safe_thread, 2);
    t1.join();
    t2.join();
}

结果:

✅ 没有死锁。

因为所有线程都按相同顺序获取锁。


2. 使用 std::lock() 一次性锁定多个互斥量

std::lock() 是 C++ 提供的"死锁安全锁定"工具。

它内部保证了不会因为锁顺序不同而死锁

cpp 复制代码
std::mutex m1, m2;

void worker() {
    std::lock(m1, m2);  // 一次性锁两个
    std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
    std::cout << "安全地锁定多个资源\n";
}

std::adopt_lock 的意思是告诉 lock_guard

"这些锁我已经拿到了,不需要再去 lock() 一次。"

这种写法优雅又安全,非常推荐。


3. 使用 std::scoped_lock(C++17 推荐)

从 C++17 开始,标准库引入了 std::scoped_lock,这是一个"自动同时锁多个 mutex"的神器。

cpp 复制代码
std::mutex m1, m2;

void safe_func() {
    std::scoped_lock lock(m1, m2); // 同时加锁,无死锁风险
    std::cout << "安全执行中...\n";
}

std::scoped_lockstd::lock() + adopt_lock 的语法糖,写法简洁,不容易出错。


4. 尽量缩小锁的作用域

锁用得越多,风险越高。
锁住的代码块越小越好。

坏例子:

cpp 复制代码
std::lock_guard<std::mutex> lock(m);
do_something_slow();  // 锁期间做大量计算

好例子:

cpp 复制代码
{
    std::lock_guard<std::mutex> lock(m);
    quick_update();    // 锁期间只做必要操作
}
do_something_slow();   // 锁外执行

5. 避免在持锁时调用外部函数

外部函数里可能也会加锁或等待,引发隐性死锁。

所以尽量不要在持锁状态下调用未知代码


6. 使用尝试锁(try_lock / try_lock_for

如果获取不到锁,立刻返回或等待一段时间。

不会死等,也就不会死锁。

cpp 复制代码
if (m.try_lock_for(std::chrono::milliseconds(100))) {
    // 获取成功
    m.unlock();
} else {
    std::cout << "超时放弃,避免死锁。\n";
}

7. 保证锁的释放(RAII 原则)

std::lock_guardstd::unique_lock 包装锁,自动释放,防止忘记 unlock()

不要再写这种代码了:

cpp 复制代码
m.lock();
do_stuff();
m.unlock();

一旦 do_stuff() 抛异常,程序会崩。

lock_guard

cpp 复制代码
{
    std::lock_guard<std::mutex> guard(m);
    do_stuff();
} // 自动 unlock

五、进阶:检测死锁的方法

  1. 调试器卡住时,检查线程堆栈

    看是否有多个线程在等待 lock()

  2. 使用工具

    • Clang ThreadSanitizer (-fsanitize=thread)

    • Visual Studio Concurrency Visualizer

    • Valgrind 的 helgrind

这些工具能检测潜在的锁冲突与死锁。


六、总结:死锁的终极避坑法则

方法 说明
固定加锁顺序 保证所有线程顺序一致
使用 std::lockscoped_lock 一次性安全加锁
减少锁粒度 缩小锁定区域
避免嵌套加锁 一层锁一件事
避免持锁调用外部函数 外部代码不可控
使用尝试锁 超时放弃
RAII 管理锁 自动释放,不怕异常

七、结语:活着比"锁死"更重要

多线程的世界里,死锁像个狡猾的陷阱。

它不报错、不闪退,只是------静静地卡住,像时间凝固。

而真正的高手,不是写出复杂的多线程代码,

而是写出再复杂也不会死锁的代码

别让线程互相等待到天荒地老,

毕竟,程序要跑,生活还得继续。

相关推荐
Yupureki4 小时前
从零开始的C++学习生活 9:stack_queue的入门使用和模板进阶
c语言·数据结构·c++·学习·visual studio
远远远远子4 小时前
C++-- 内存管理
c++·算法
小年糕是糕手4 小时前
【数据结构】单链表“0”基础知识讲解 + 实战演练
c语言·开发语言·数据结构·c++·学习·算法·链表
Dobby_055 小时前
【Go】C++ 转 Go 第(一)天:环境搭建 Windows + VSCode 远程连接 Linux
linux·运维·c++·vscode·golang
咸鱼爱学习5 小时前
【题解】B2613【深基1.习5】打字速度
数据结构·c++·算法
一匹电信狗5 小时前
【C++】C++风格的类型转换
服务器·开发语言·c++·leetcode·小程序·stl·visual studio
syt_biancheng5 小时前
C++ 多态(1)
jvm·c++·学习
std78796 小时前
用C++ 实现屏幕保护程序
开发语言·c++
tumu_C6 小时前
无用知识研究:在trailing return type利用decltype,comma operator在对函数进行sfinae原创 [二]
开发语言·c++·算法