在多线程程序中,互斥锁可以保护共享数据,但如果程序同时使用多把锁,加锁顺序设计不合理,就可能出现一个非常典型的问题:
死锁 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负责自动释放锁,但不能自动解决错误的锁依赖关系。