一、引言
当一个多线程程序在测试环境中偶尔出现“偶发卡死”时,很多人的第一反应是怀疑CPU、怀疑库、怀疑系统调用,实际上最常见的罪魁祸首是死锁。死锁的症状是几个线程互相等待,谁也走不下去,整个进程看起来像是挂起来了,但CPU占用可能不高。要理解死锁,就要把“线程-锁-资源”看成一个图。线程拿着一些资源,又在等待另一些资源,如果这张图里出现了一个环,那么某种执行顺序下你就可能卡住。C++的std::mutex和多把锁组合在一起时,很容易不小心走进这个坑。
二、死锁案例
先看一段看似无害但实则危险的代码。有两把互斥锁m1和m2,两个线程分别执行func1和func2
#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>
std::mutex m1, m2;
void func1() {
std::lock_guard<std::mutex> lk1(m1);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lk2(m2);
std::cout << "func1 done\n";
}
void func2() {
std::lock_guard<std::mutex> lk2(m2);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lk1(m1);
std::cout << "func2 done\n";
}
int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
return 0;
}
这里,func1的锁顺序是先m1再m2,而func2的锁顺序是先m2再m1。如果时间上的交错刚好是线程t1先拿到了m1,线程t2先拿到m2,然后它们各自睡一会儿醒来,t1去拿m2,t2去拿m1。此时t1手里已经有了m1,在等待m2,t2手里有m2,在等待m1。两者互相等待对方释放,谁也等不到,于是程序就死锁了。如下图所示:

这就是死锁最经典,最容易在真实项目中出现的一种形态。多把锁,错乱的加锁顺序,再加上恰到好处的线程调度,就可以把程序送进永恒的等待。
三、死锁的四个条件
操作系统课里常说,死锁往往需要同时满足互斥、占有且等待、不可剥夺和环路等待四个条件。在刚才那段C++代码里,它们一一对应。互斥条件来自std::mutex,同一时刻只能被一个线程持有。占有且等待体现在每个线程在持有一把锁的同时,继续尝试获取另一把锁。不可剥夺意味着C++标准库不会有任何API强制从一个线程手上夺走mutex,你必须自愿的调用unlock()。环路等待则是上图箭头所构成的闭环,T1等m2,T2等m1,形成了等待环。理解这一点的意义在于,你可以从设计层面去打破这些条件的一部分,比如统一锁顺序从而避免环路等待,或者使用尝试加锁(如try_lock),让线程在无法获得锁时直接放弃等待,从而打破占有且等待。
四、如何从代码设计上避免死锁
避免死锁最实用的原则之一就是统一锁顺序,找到所有可能被同时持有的那几把mutex,在整个代码库里约定一个固定的加锁顺序,例如总是先锁m1再锁m2,任何人都不允许反过来写。这样,即使多个线程再不同地方同时需要这两把锁,它们也都会按照同样的顺序去获取,从而不可能形成环路。在C++11之后,有了更方便的工具来帮你遵循这个原则。std::lock和std::scoped_lock。std::lock(m1,m2)会采用一个不会死锁的算法尝试一次性获得多把锁,要么全部成功,要么全部释放并重试,成功后你可以用std::adopt_lock构造std::lock_guard。
void safe_func1() {
std::lock(m1, m2);
std::lock_guard<std::mutex> lk1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lk2(m2, std::adopt_lock);
std::cout << "safe_func1 done\n";
}
void safe_func2() {
std::lock(m1, m2);
std::lock_guard<std::mutex> lk1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lk2(m2, std::adopt_lock);
std::cout << "safe_func2 done\n";
}
从C++17开始,又有了更简洁的std::scoped_lock,可以直接写成std::scoped_lock lk(m1,m2);,内部也是调用类似std::lock的逻辑来一次性拿下多把锁。只要你坚持在任何需要多把锁的地方都是用这样的函数,死锁的概率会大大降低。
五、避免死锁的其他习惯
除了锁顺序之外,还有两类常见的死锁来源需要格外小心。第一类是在持锁时调用外部回调或第三方库函数,因为你无法控制它们内部是否也尝试获取同一把锁,或者试图获取你当前没有意识到的另一把锁,从而形成隐形的等待环。解决办法是尽量在进入临界区完成耗时或复杂的操作。第二类是锁的重入和递归使用问题。普通的std::mutex不支持统一线程重复加锁,如果你在一个持锁函数里又调用了另一个尝试获取同一把锁的函数,就会把自己锁死。这时要么重构代码,拆除不需要锁的部分,要么明确使用std::recursive_mutex,但后者在工程中并不鼓励使用,因为它很容易掩盖设计上的问题。
六、小结
死锁看起来像是线程调度不巧碰上的偶发bug,其实本质上是设计问题,你让多个线程在多把锁上形成了环形依赖,调度器只是把这条路走了出来而已。因此,在写多线程代码时,应该养成一种“锁依赖意识”,一旦代码中出现跨模块、跨对象的多锁持有,就要停下来设计清楚锁顺序和边界。