【C++】多线程死锁:产生原因、复现与解决方法

在多线程程序中,互斥锁可以保护共享数据,但如果程序同时使用多把锁,加锁顺序设计不合理,就可能出现一个非常典型的问题:

复制代码
死锁 Deadlock

所谓死锁,可以简单理解为:

两个或多个线程互相等待对方释放资源,最终所有线程都无法继续执行。

例如:

复制代码
线程A拿着锁1,等待锁2
线程B拿着锁2,等待锁1

此时:

复制代码
线程A不会释放锁1,因为它还在等锁2
线程B不会释放锁2,因为它还在等锁1

程序就会一直卡住。

一、最经典的双锁死锁是怎么产生的

先准备两个互斥锁:

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

std::mutex mutex1;
std::mutex mutex2;

线程 A:

复制代码
void ThreadA()
{
    std::lock_guard<std::mutex> lock1(mutex1);

    std::cout << "ThreadA 获取 mutex1\n";

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    std::lock_guard<std::mutex> lock2(mutex2);

    std::cout << "ThreadA 获取 mutex2\n";
}

线程 B:

复制代码
void ThreadB()
{
    std::lock_guard<std::mutex> lock2(mutex2);

    std::cout << "ThreadB 获取 mutex2\n";

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    std::lock_guard<std::mutex> lock1(mutex1);

    std::cout << "ThreadB 获取 mutex1\n";
}

主函数:

复制代码
int main()
{
    std::thread t1(ThreadA);
    std::thread t2(ThreadB);

    t1.join();
    t2.join();

    return 0;
}

程序可能执行到:

复制代码
ThreadA 获取 mutex1
ThreadB 获取 mutex2

然后就不再继续。

原因是执行过程可能变成:

复制代码
线程A
获得mutex1
    ↓
等待mutex2


线程B
获得mutex2
    ↓
等待mutex1

此时:

复制代码
mutex1属于线程A
mutex2属于线程B

线程 A 想继续运行,需要线程 B 释放 mutex2

但线程 B 想继续运行,又需要线程 A 释放 mutex1

于是形成:

复制代码
线程A → 等待线程B
线程B → 等待线程A

最终两个线程都无法继续执行。

这就是典型的:

复制代码
循环等待

需要注意,lock_guard 本身没有任何错误。

下面这种代码:

复制代码
std::lock_guard<std::mutex> lock1(mutex1);
std::lock_guard<std::mutex> lock2(mutex2);

单独看完全正确。

真正的问题是:

不同线程获取多把锁的顺序不一致。

线程 A:

复制代码
mutex1 → mutex2

线程 B:

复制代码
mutex2 → mutex1

正是这种相反顺序导致了死锁。


二、死锁产生的四个必要条件

操作系统理论中,死锁通常需要同时满足四个条件。

1. 互斥条件

一个资源同一时间只能由一个线程使用。

例如:

复制代码
std::mutex mutex;

当线程 A 成功执行:

复制代码
mutex.lock();

以后,线程 B 无法同时获得这把锁。

这本身正是互斥锁存在的意义。


2. 请求并保持

线程已经持有一部分资源,同时还在等待其他资源。

例如线程 A:

复制代码
mutex1.lock();
mutex2.lock();

执行到第二句时,如果 mutex2 已经被别人持有,那么线程 A 的状态就是:

复制代码
已经持有mutex1
同时等待mutex2

也就是:

复制代码
边拿着资源
边等待其他资源

3. 不可剥夺

线程已经获得的资源,不能被其他线程强行抢走。

例如线程 A 已经:

复制代码
mutex1.lock();

其他线程不能直接把 mutex1 从线程 A 手里拿走。

只有线程 A 自己执行:

复制代码
mutex1.unlock();

锁才会被释放。


4. 循环等待

多个线程之间形成一个等待环。

例如:

复制代码
线程A等待线程B的资源
        ↓
线程B等待线程C的资源
        ↓
线程C等待线程A的资源

或者最简单的两个线程:

复制代码
A等待B
↑     ↓
└─────┘
B等待A

因此,经典双锁死锁就是:

复制代码
线程A:

持有mutex1
等待mutex2


线程B:

持有mutex2
等待mutex1

四个条件同时满足,最终形成死锁。

实际编程时,我们不一定需要把四个条件全部背下来,更重要的是记住:

只要破坏其中任意一个条件,就可以避免死锁。

实际 C++ 开发中,最常见的方法就是破坏:

复制代码
循环等待

也就是统一所有线程的加锁顺序。


三、最简单的方法:所有线程统一加锁顺序

前面的死锁代码中:

线程 A:

复制代码
mutex1.lock();
mutex2.lock();

线程 B:

复制代码
mutex2.lock();
mutex1.lock();

解决办法非常直接:

所有线程都规定先获得 mutex1,再获得 mutex2

修改线程 B:

复制代码
void ThreadB()
{
    std::lock_guard<std::mutex> lock1(mutex1);
    std::lock_guard<std::mutex> lock2(mutex2);

    std::cout << "ThreadB 获取两把锁\n";
}

线程 A 同样保持:

复制代码
void ThreadA()
{
    std::lock_guard<std::mutex> lock1(mutex1);
    std::lock_guard<std::mutex> lock2(mutex2);

    std::cout << "ThreadA 获取两把锁\n";
}

此时顺序统一为:

复制代码
mutex1
   ↓
mutex2

假设线程 A 先拿到 mutex1

复制代码
线程A:

拿到mutex1
拿到mutex2
执行任务
释放mutex2
释放mutex1

线程 B 此时只能在:

复制代码
mutex1.lock();

这里等待。

它不会先持有 mutex2,所以不会形成:

复制代码
A等待B
B又等待A

统一锁顺序是项目中非常重要的一条规则。

例如规定:

复制代码
用户锁
    ↓
订单锁
    ↓
库存锁

那么所有代码都必须遵守:

复制代码
先用户
再订单
最后库存

不能有某个函数突然按照:

复制代码
库存 → 用户

进行加锁。

账户转账案例

假设:

复制代码
struct Account
{
    int money = 0;
    std::mutex mutex;
};

如果简单写:

复制代码
void Transfer(Account& from, Account& to, int money)
{
    std::lock_guard<std::mutex> lock1(from.mutex);
    std::lock_guard<std::mutex> lock2(to.mutex);

    from.money -= money;
    to.money += money;
}

假设同时执行:

复制代码
线程A:账户1 → 账户2
线程B:账户2 → 账户1

那么锁顺序就自动反过来了:

复制代码
线程A:
账户1 → 账户2

线程B:
账户2 → 账户1

仍然可能发生死锁。

所以这种涉及动态对象的场景,单纯人工规定顺序有时候并不方便。

这时就可以使用:

复制代码
std::lock()

或者:

复制代码
std::scoped_lock

四、std::lock 和 scoped_lock 如何解决多锁问题

1. std::lock

C++11 提供:

复制代码
std::lock();

它可以一次处理多把锁,并采用避免简单死锁的方式尝试获取它们。

例如:

复制代码
void Transfer(Account& from, Account& to, int money)
{
    if (&from == &to)
    {
        return;
    }

    std::unique_lock<std::mutex> lock1(from.mutex, std::defer_lock);
    std::unique_lock<std::mutex> lock2(to.mutex, std::defer_lock);

    std::lock(lock1, lock2);

    from.money -= money;
    to.money += money;
}

这里首先创建:

复制代码
std::unique_lock<std::mutex> lock1(from.mutex, std::defer_lock);

std::defer_lock 表示:

复制代码
创建unique_lock对象
但是暂时不要加锁

因此下面两句执行完以后:

复制代码
std::unique_lock<std::mutex> lock1(from.mutex, std::defer_lock);
std::unique_lock<std::mutex> lock2(to.mutex, std::defer_lock);

两把锁实际上都还没有被锁住。

然后:

复制代码
std::lock(lock1, lock2);

统一获取两把锁。

成功以后:

复制代码
lock1持有from.mutex
lock2持有to.mutex

最后两个 unique_lock 离开作用域时自动释放。

2. mutex 配合 adopt_lock

也可以直接:

复制代码
std::lock(from.mutex, to.mutex);

之后使用:

复制代码
std::lock_guard<std::mutex> lock1(from.mutex, std::adopt_lock);
std::lock_guard<std::mutex> lock2(to.mutex, std::adopt_lock);

完整代码:

复制代码
void Transfer(Account& from, Account& to, int money)
{
    if (&from == &to)
    {
        return;
    }

    std::lock(from.mutex, to.mutex);

    std::lock_guard<std::mutex> lock1(from.mutex, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(to.mutex, std::adopt_lock);

    from.money -= money;
    to.money += money;
}

这里:

复制代码
std::adopt_lock

表示:

这把 mutex 已经提前加锁了,请直接接管它,析构时帮我解锁,不要再次执行 lock()

如果不写 adopt_lock

复制代码
std::lock_guard<std::mutex> lock1(from.mutex);

它又会尝试执行一次:

复制代码
from.mutex.lock();

普通 std::mutex 不允许同一线程重复获得自己已经持有的锁,因此反而可能把自己锁死。

3. C++17 更推荐 scoped_lock

如果项目使用 C++17,代码可以直接简化成:

复制代码
void Transfer(Account& from, Account& to, int money)
{
    if (&from == &to)
    {
        return;
    }

    std::scoped_lock lock(from.mutex, to.mutex);

    from.money -= money;
    to.money += money;
}

这一句:

复制代码
std::scoped_lock lock(from.mutex, to.mutex);

就完成了多把锁的管理。

使用过程:

复制代码
创建scoped_lock
        ↓
获取from.mutex和to.mutex
        ↓
执行转账
        ↓
离开作用域
        ↓
自动释放两把锁

相比:

复制代码
std::unique_lock<std::mutex> lock1(from.mutex, std::defer_lock);
std::unique_lock<std::mutex> lock2(to.mutex, std::defer_lock);
std::lock(lock1, lock2);

明显简洁很多。

所以在 C++17 以后,如果只是为了同时获得多把互斥锁,通常优先:

复制代码
std::scoped_lock lock(mutex1, mutex2);

完整测试:

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

struct Account
{
    int money = 0;
    std::mutex mutex;
};

void Transfer(Account& from, Account& to, int money)
{
    if (&from == &to)
    {
        return;
    }

    std::scoped_lock lock(from.mutex, to.mutex);

    if (from.money < money)
    {
        return;
    }

    from.money -= money;
    to.money += money;
}

int main()
{
    Account account1;
    Account account2;

    account1.money = 1000;
    account2.money = 1000;

    std::thread t1(Transfer, std::ref(account1), std::ref(account2), 100);
    std::thread t2(Transfer, std::ref(account2), std::ref(account1), 200);

    t1.join();
    t2.join();

    std::cout << "account1 = " << account1.money << '\n';
    std::cout << "account2 = " << account2.money << '\n';

    return 0;
}

无论两个线程的执行顺序如何,程序最终都可以正常结束,而不会因为相反方向的转账导致两把锁互相等待。


五、try_lock、自己锁自己以及死锁排查

除了统一顺序和一次获取多把锁,还可以通过"获取不到就放弃"的方式减少无限等待。

例如:

复制代码
std::mutex mutex;

void TryWork()
{
    if (mutex.try_lock())
    {
        // 成功获得锁
        std::cout << "获得锁\n";

        mutex.unlock();
    }
    else
    {
        // 获取失败后直接执行其他逻辑
        std::cout << "锁正在被使用\n";
    }
}

try_lock()lock() 最大的区别是:

复制代码
lock:

获得不到锁
    ↓
一直等待


try_lock:

尝试获得锁
    ↓
成功 → true
失败 → false

使用 RAII 时可以写:

复制代码
void TryWork()
{
    std::unique_lock<std::mutex> lock(mutex, std::try_to_lock);

    if (!lock.owns_lock())
    {
        std::cout << "没有获得锁\n";
        return;
    }

    std::cout << "执行任务\n";
}

这样既避免手动 unlock(),又可以判断当前是否成功获得锁。

同一个线程也可能把自己锁死

死锁不一定需要两个线程。

例如:

复制代码
std::mutex mutex;

void Func2()
{
    std::lock_guard<std::mutex> lock(mutex);

    std::cout << "Func2\n";
}

void Func1()
{
    std::lock_guard<std::mutex> lock(mutex);

    Func2();
}

执行:

复制代码
Func1();

流程为:

复制代码
Func1获得mutex
      ↓
调用Func2
      ↓
Func2再次尝试获得mutex
      ↓
但是mutex已经被Func1持有
      ↓
Func1必须等Func2返回
      ↓
Func2又必须等Func1释放mutex
      ↓
死锁

这里两个函数虽然运行在同一个线程中,但普通:

复制代码
std::mutex

不是递归锁。

同一个线程已经持有它以后,再次调用:

复制代码
mutex.lock();

同样可能导致问题。

一种解决方法是重新设计代码,不要让已经持锁的函数再次进入需要同一把锁的函数。

例如:

复制代码
void Func2WithoutLock()
{
    std::cout << "Func2\n";
}

void Func1()
{
    std::lock_guard<std::mutex> lock(mutex);

    Func2WithoutLock();
}

也存在:

复制代码
std::recursive_mutex

允许同一线程重复加锁:

复制代码
std::recursive_mutex mutex;

但是不要因为出现重复加锁就直接全部改成 recursive_mutex

很多时候,重复加锁本身说明函数的锁职责划分不够清晰。

实际开发如何降低死锁风险

可以遵循下面几个原则:

复制代码
1. 多把锁尽量规定统一的获取顺序;

2. C++17多锁场景优先考虑scoped_lock;

3. 持锁期间不要执行长时间操作;

4. 持锁期间尽量不要调用未知的外部函数;

5. 不要在一把锁没有释放时随意进入另一套加锁逻辑;

6. 锁的作用域尽可能小;

7. 使用RAII保证所有执行路径都能释放锁;

8. 注意函数之间的嵌套调用是否会重复获取同一把mutex。

最后看一下几种方式的区别:

方式 作用
统一加锁顺序 从设计上破坏循环等待
std::lock 一次协调获取多把锁
std::scoped_lock C++17 中更简洁地管理多把锁
try_lock 获取不到锁时不无限等待
RAII 防止忘记释放锁
缩短临界区 降低锁竞争和复杂锁依赖

需要特别注意:

RAII 可以解决"忘记解锁",但 RAII 本身不能解决"加锁顺序错误"。

下面的代码虽然全部使用 RAII:

复制代码
// 线程A
std::lock_guard<std::mutex> lock1(mutex1);
std::lock_guard<std::mutex> lock2(mutex2);

另一个线程:

复制代码
// 线程B
std::lock_guard<std::mutex> lock2(mutex2);
std::lock_guard<std::mutex> lock1(mutex1);

依然可能死锁。

所以多线程锁设计不仅要考虑:

复制代码
锁有没有释放

还要考虑:

复制代码
锁按照什么顺序获得
哪些函数会继续获取其他锁
锁持有多长时间
多把锁之间有没有形成依赖环

这一篇的核心可以总结为:

复制代码
死锁本质上是线程之间形成无法打破的资源等待关系;

最经典的情况是两个线程按照相反顺序获取两把锁;

统一加锁顺序可以破坏循环等待;

std::lock可以协调获取多把mutex;

C++17可以使用scoped_lock更加方便地管理多把锁;

try_lock可以在获取失败时立即返回;

同一个线程重复获取普通mutex也可能把自己锁死;

RAII负责自动释放锁,但不能自动解决错误的锁依赖关系。

0voice · GitHub

相关推荐
跨境小彭2 小时前
Python列表去重的6种高效方法(含保留顺序+性能对比)
开发语言·python
今天AI了吗2 小时前
Python 基础语法从入门到使用详解
开发语言·人工智能·python
lucas_AI2 小时前
ConfBench:大模型的「我很有把握」,到底能不能信?
人工智能·算法
oier_Asad.Chen2 小时前
【洛谷题解/AcWing题解/真题】洛谷P2330【SCOI2005】繁忙的都市(Kruskal算法的再探究))
c++·算法·贪心算法·图论·最小生成树·kruskal
雪之下雪乃的代码日记3 小时前
Python快速入门(Java开发者版)
java·开发语言·笔记·python
Y3815326623 小时前
SERP API 请求体 Gzip 压缩优化实战:大数据量降带宽 80%
开发语言·前端·php
weixin199701080163 小时前
☁️《抖店API基础¥0.018/百次·增值¥0.05/百次:云内云外价差架构实战》(附Python源码)
开发语言·python·架构
萌动的小火苗4 小时前
1、python基础面试题
java·开发语言·python
运维行者_4 小时前
网络监控与ITSM集成:从告警到工单,实现运维自动化闭环
开发语言·网络·分布式·后端·架构·flask·php