「C++ 多线程」死锁及 C++ 多线程解决死锁方法
文章大图来源:pixiv_id=125531287
1 死锁
1.1 死锁基本概念
死锁(dead lock)是指两个或多个线程在执行过程中,因争夺资源而造成的一种 互相等待 的现象。若无外力作用,这些线程都将无法向前推进。
1.2 死锁产生条件
-
互斥条件:共享资源只能同一时刻被一个线程占用。
例如,一个文件只能同一时刻被一个线程进行写的操作。
-
请求与保持条件:线程已经持有并保持至少一个资源,同时又提出新的资源请求。
当新请求的资源被其他线程占有,那么当前请求的线程会被阻塞,同时保持自己占有的资源。
-
不可剥夺条件:线程所获得的资源在未使用完之前,不能被其他线程强行夺走,只能由自己释放。
-
循环等待条件:多线程情况下,存在一个线程序列,其中每个线程都在等待下一个线程占有的资源被释放。
例如,线程
t1
等待线程t2
占有的资源,线程t2
等待线程t3
的资源,而线程t3
又在等待线程t1
的资源。这些线程保持自己占有的资源,又提出新的资源请求,形成了一个环路,也就是 循环等待。
产生死锁这 4 个条件缺一不可。如果其中一个条件被破坏,那么就可以避免死锁。
2 C++ 多线程死锁示例
以下是一个简单的死锁的示例:
1 |
|
在上面的代码中,定义了两个互斥量 mtx1
和 mtx2
,线程 t1
执行函数 foo1()
,线程 t2
执行函数 foo2()
。
线程 t1
先对 mtx1
进行加锁,然后在持有 mtx1
的锁的情况下等待获取 mtx2
的锁;线程 t2
先对 mtx2
进行加锁,然后在持有 mtx2
的锁的情况下等待获取 mtx1
的锁。此时,两个线程就形成了循环等待,导致了 死锁 的发生。
可能的运行结果如下:
可以看出,程序运行到两个线程各自获得所需的第一个锁时就停止不动了,表明发生了死锁。
我们也可以用 std::lock_guard
写一个死锁示例:
1 |
|
可能的运行结果如下:
可以发现程序运行停止不动,表明出现了死锁。
3 C++ 多线程减少死锁方法
我们可以使用 std::unique_lock
结合暂时解锁、重新加锁的操作,使得其他线程可以在请求资源被暂时释放的时间段有机会获得锁,可以一定程度上减少死锁的发生。但是,不能根本上解决死锁的发生。
1 |
|
在上面的代码中,线程 t1
先对 mtx1
进行加锁,(之前假设也做过了一些只需要一个互斥量的互斥操作)然后暂时释放并休息一段时间,在暂时释放之后的一段时间中,线程 t2
有机会获得 mtx1
的锁。
有一种不会出现死锁的情况就是:
此时线程 t2
在线程 t1
暂时释放 mtx1
的一段时间内尝试获得 mtx1
成功(之前已经暂时释放了 mtx2
的锁),然后线程 t2
又重新对 mtx2
加锁,此时线程 t2
获得了两个互斥量,之后执行函数 foo2()
完毕,并释放两个互斥量 mtx1
和 mtx2
。
线程 t1
尝试获得 mtx2
的锁,由尝试重新对 mtx1
进行加锁,此时两个互斥量 mtx1
和 mtx2
都已经被释放了,线程 t1
可以顺利成功获得占有两个互斥量。之后顺利执行函数 foo1()
完毕,并释放两个互斥量。
但这类情况并不会保证发生,还是有可能会出现循环等待,导致死锁的情况。
4 C++ 多线程解决死锁方法
4.1 有序加锁
我们可以通过确保所有线程 按照相同的顺序获取多个锁,这样就可以避免循环等待的情况。例如,如果所有线程都按照 “先尝试获取 mtx1,再尝试获取 mtx2” 的顺序加锁,那么就不会出现死锁。
这种有序加锁的方法破坏的是死锁产生条件中的 循环等待条件。
- 使用 std::mutex::lock
1 |
|
- 使用 std::lock_guard
1 |
|
在上面的两个代码中,我们通过将所有线程都按照相同的顺序进行加锁,避免了循环等待,这样就可以避免死锁的发生。
可能的运行结果如下:
4.2 一次性同时加锁
我们也可以通过一次性分配所有资源(一次性同时对每个所需互斥量加锁),在 C++ 多线程中,通常可以采用 std::lock
函数来对多个互斥量进行同时加锁。
这样一次性同时加锁的方式破坏了死锁产生条件中的 请求并保持条件。一次性地分配所有资源给线程,使得线程不需要再进行新的资源请求,因此避免了死锁的产生。
-
使用 std::lock
std::lock
定义在<mutex>
头文件中,主要功能是可以 同时锁住多个互斥量(mutex),能够自动避免死锁的发生。std::lock
是一个可变参数模板,可以接受两个或多个互斥量作为参数。例如,有两个std::mutex
互斥量mtx1
和mtx2
,那么可以通过std::lock(mtx1, mtx2)
将两个互斥量同时加锁。std::lock
函数能自动避免死锁情况的发生,其内部采用了合适的算法来调整获取锁的顺序,确保无论有多少个线程以何种顺序调用它去获取这两个互斥量,都会按照内部自动处理的顺序获取互斥量,避免出现死锁。如果线程能够通过
std::lock
同时获取多个锁时,那么线程顺利执行;如果线程不能通过std::lock
同时获取多个锁,那么线程会被阻塞,直到能够成功同时获得锁。注意:
std::lock
不能之传入一个互斥量参数。以下是一个简单的使用
std::lock
解决死锁的示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
// 方法2:两个线程对互斥量加锁时,同时对需要的所有互斥量一次性加锁 std::lock
// 最后每个锁都要释放,解锁的顺序可以是任意的
void foo1(){
std::lock(mtx1, mtx2);
std::cout << "thread 1 get mtx1." << "\n";
std::cout << "thread 1 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mtx1.unlock();
mtx2.unlock();
}
void foo2(){
std::lock(mtx1, mtx2);
std::cout << "thread 2 get mtx1." << "\n";
std::cout << "thread 2 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mtx2.unlock();
mtx1.unlock();
}
int main(){
std::thread t1(foo1);
std::thread t2(foo2);
t1.join();
t2.join();
std::cout << "program ends." << "\n";
return 0;
}在上面的代码中,通过每个线程调用
std::lock
函数尝试一次性同时获得mtx1
和mtx2
的锁,破坏了死锁产生条件中的 请求并保持条件,避免了死锁的发生。在解锁时,顺序可以是任意的,只需要确保最终两个锁都被正确释放即可。
-
配合 std::lock_guard
我们也可以
std::lock
配合std::lock::guard
加上std::adopt_lock
参数来解决死锁。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
// 方法2:两个线程对互斥量加锁时,同时对需要的所有互斥量一次性加锁 std::lock
// 可以通过 lock_guard 转移锁的管理,加上 std::adopt_lock 参数
void foo1(){
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lck1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lck2(mtx2, std::adopt_lock);
std::cout << "thread 1 get mtx1." << "\n";
std::cout << "thread 1 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
void foo2(){
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lck1(mtx2, std::adopt_lock);
std::lock_guard<std::mutex> lck2(mtx1, std::adopt_lock);
std::cout << "thread 2 get mtx1." << "\n";
std::cout << "thread 2 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
int main(){
std::thread t1(foo1);
std::thread t2(foo2);
t1.join();
t2.join();
std::cout << "program ends." << "\n";
return 0;
}在上面的代码中,在调用
std::lock
对互斥量同时加锁后,定义两个std::lock_guard
将互斥量的管理权进行了转移(加上std::adopt_lock
参数,表明之前已经加过锁),之后就不需要再手动进行解锁了。 -
配合 std::unique_lock
我们也可以用
std::unique_lock
加上std::defer_lock
延迟加锁标记,这样的话,可以延迟进行同时获取多个互斥量的锁,并且可以更加灵活地解锁和重新加锁。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
// 方法2:两个线程对互斥量加锁时,同时对需要的所有互斥量一次性加锁 std::lock
// 最后每个锁都要释放,解锁的顺序可以是任意的
// 可以配合 unique_lock 延迟加锁,加上 std::defer_lock 参数
// 定义 unique_lock 对象并加上延迟加锁参数
// 然后一次性对所需互斥量同时进行加锁
void foo1(){
std::unique_lock<std::mutex> lck1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lck2(mtx2, std::defer_lock);
std::lock(lck1, lck2);
std::cout << "thread 1 get mtx1." << "\n";
std::cout << "thread 1 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
void foo2(){
std::unique_lock<std::mutex> lck1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lck2(mtx2, std::defer_lock);
std::lock(lck2, lck1);
std::cout << "thread 2 get mtx1." << "\n";
std::cout << "thread 2 get mtx2." << "\n";
// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
int main(){
std::thread t1(foo1);
std::thread t2(foo2);
t1.join();
t2.join();
std::cout << "program ends." << "\n";
return 0;
}在上面的代码中,创建了
std::unique_lock
对象,并使用std::defer_lock
标记不立即获取锁。之后再通过std::lock
函数尝试同时获取多个互斥量的锁。这样可以更加灵活地处理加锁解锁,并且最后也不需要手动进行解锁。
上面的 3 个代码都实现了一次性同时加锁,破坏了死锁产生条件中的 请求并保持 条件,成功避免了死锁的发生。
可能的运行结果如下:
4.3 超时放弃
我们也可以通过 try_lock
或者 std::timed_mutex
中的 try_lock_for
等,采用 超时放弃 的机制来避免死锁。当线程(在等待的时间内)无法获取所需互斥量的锁时,不会被阻塞去等待互斥量,而是放弃尝试,直接进行返回。
这样的机制破坏的主要是 循环等待条件 和 请求与保持条件,避免了死锁。
但是,如果我们需要确保每个线程能够完整执行,那么 超时放弃 的机制并不适用。