死锁如何形成?如何解决?

一、引言

当一个多线程程序在测试环境中偶尔出现“偶发卡死”时,很多人的第一反应是怀疑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,其实本质上是设计问题,你让多个线程在多把锁上形成了环形依赖,调度器只是把这条路走了出来而已。因此,在写多线程代码时,应该养成一种“锁依赖意识”,一旦代码中出现跨模块、跨对象的多锁持有,就要停下来设计清楚锁顺序和边界。

--------------

本文标题为:

死锁如何形成?如何解决?

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇