「C++ 多线程」死锁及 C++ 多线程解决死锁方法

文章大图来源:pixiv_id=125531287

1 死锁

1.1 死锁基本概念

死锁(dead lock)是指两个或多个线程在执行过程中,因争夺资源而造成的一种 互相等待 的现象。若无外力作用,这些线程都将无法向前推进。

1.2 死锁产生条件

  • 互斥条件:共享资源只能同一时刻被一个线程占用。

    例如,一个文件只能同一时刻被一个线程进行写的操作。

  • 请求与保持条件:线程已经持有并保持至少一个资源,同时又提出新的资源请求。

    当新请求的资源被其他线程占有,那么当前请求的线程会被阻塞,同时保持自己占有的资源。

  • 不可剥夺条件:线程所获得的资源在未使用完之前,不能被其他线程强行夺走,只能由自己释放。

  • 循环等待条件:多线程情况下,存在一个线程序列,其中每个线程都在等待下一个线程占有的资源被释放。

    例如,线程 t1 等待线程 t2 占有的资源,线程 t2 等待线程 t3 的资源,而线程 t3 又在等待线程 t1 的资源。这些线程保持自己占有的资源,又提出新的资源请求,形成了一个环路,也就是 循环等待

产生死锁这 4 个条件缺一不可。如果其中一个条件被破坏,那么就可以避免死锁。

2 C++ 多线程死锁示例

以下是一个简单的死锁的示例:

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
45
46
47
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx1, mtx2;

// 当线程 1 持有 mtx1,等待获取 mtx2;线程 2 持有 mtx2,等待获取 mtx1
// 此时形成了循环等待,导致了死锁

void foo1(){
mtx1.lock();
std::cout << "thread 1 get mtx1." << "\n";
// 当前线程持有 mtx1 的锁,等待获取 mtx2 的锁
mtx2.lock();
std::cout << "thread 1 get mtx2." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));

mtx1.unlock();
mtx2.unlock();
}

void foo2(){
mtx2.lock();
std::cout << "thread 2 get mtx2." << "\n";
// 当前线程持有 mtx2 的锁,等待获取 mtx1 的锁
mtx1.lock();
std::cout << "thread 2 get mtx1." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));

mtx1.unlock();
mtx2.unlock();
}

int main(){
std::thread t1(foo1);
std::thread t2(foo2);
t1.join();
t2.join();

std::cout << "program ends." << "\n";

return 0;
}

在上面的代码中,定义了两个互斥量 mtx1mtx2,线程 t1 执行函数 foo1(),线程 t2 执行函数 foo2()

线程 t1 先对 mtx1 进行加锁,然后在持有 mtx1 的锁的情况下等待获取 mtx2 的锁;线程 t2 先对 mtx2 进行加锁,然后在持有 mtx2 的锁的情况下等待获取 mtx1 的锁。此时,两个线程就形成了循环等待,导致了 死锁 的发生。

可能的运行结果如下:

可以看出,程序运行到两个线程各自获得所需的第一个锁时就停止不动了,表明发生了死锁。

我们也可以用 std::lock_guard 写一个死锁示例:

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;

// 当线程 1 持有 mtx1,等待获取 mtx2。同时线程 2 持有 mtx2,等待获取 mtx1 时
// 此时形成了循环等待,导致了死锁

void foo1(){
std::lock_guard<std::mutex> lck1(mtx1);
std::cout << "thread 1 get mtx1." << "\n";
// 当前线程持有 mtx1 的锁,等待获取 mtx2 的锁
std::lock_guard<std::mutex> lck2(mtx2);
std::cout << "thread 1 get mtx2." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

void foo2(){
std::lock_guard<std::mutex> lck1(mtx2);
std::cout << "thread 2 get mtx2." << "\n";
// 当前线程持有 mtx2 的锁,等待获取 mtx1 的锁
std::lock_guard<std::mutex> lck2(mtx1);
std::cout << "thread 2 get mtx1." << "\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;
}

可能的运行结果如下:

可以发现程序运行停止不动,表明出现了死锁。

3 C++ 多线程减少死锁方法

我们可以使用 std::unique_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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <iostream>
#include <mutex>
#include <thread>
#include <chrono>

// 可以使用 unique_lock 暂时解锁,使得其他线程有机会获得锁
// 可以减少死锁的发生,但是无法解决死锁。

std::mutex mtx1, mtx2;

void foo1() {
std::unique_lock<std::mutex> lck1(mtx1);
std::cout << "thread 1 acquired mtx1" << std::endl;

// 暂时解锁 mtx1
lck1.unlock();
std::cout << "thread 1 unlocked mtx1" << std::endl;

// 暂停一会使得其他线程有机会获得 mtx1
std::this_thread::sleep_for(std::chrono::milliseconds(300));

// 尝试获取 mtx2
std::unique_lock<std::mutex> lck2(mtx2);
std::cout << "thread 1 acquired mtx2" << std::endl;

// 重新获取 mtx1 的锁
lck1.lock();
std::cout << "thread 1 reacquired mtx1" << std::endl;

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

void foo2() {
std::unique_lock<std::mutex> lck2(mtx2);
std::cout << "thread 2 acquired mtx2" << std::endl;

// 暂时解锁 mtx2
lck2.unlock();
std::cout << "thread 2 unlocked mtx2" << std::endl;

// 暂停一会使得其他线程有机会获得 mtx2
std::this_thread::sleep_for(std::chrono::milliseconds(100));

// 尝试获取 mtx1
std::unique_lock<std::mutex> lck1(mtx1);
std::cout << "thread 2 acquired mtx1" << std::endl;

// 重新获取 mtx2 的锁
lck2.lock();
std::cout << "thread 2 reacquired mtx2" << std::endl;

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

int main() {
std::thread thread1(foo1);
std::thread thread2(foo2);

thread1.join();
thread2.join();

std::cout << "program ends." << "\n";

return 0;
}

在上面的代码中,线程 t1 先对 mtx1 进行加锁,(之前假设也做过了一些只需要一个互斥量的互斥操作)然后暂时释放并休息一段时间,在暂时释放之后的一段时间中,线程 t2 有机会获得 mtx1 的锁。

有一种不会出现死锁的情况就是:

此时线程 t2 在线程 t1 暂时释放 mtx1 的一段时间内尝试获得 mtx1 成功(之前已经暂时释放了 mtx2 的锁),然后线程 t2 又重新对 mtx2 加锁,此时线程 t2 获得了两个互斥量,之后执行函数 foo2() 完毕,并释放两个互斥量 mtx1mtx2

线程 t1 尝试获得 mtx2 的锁,由尝试重新对 mtx1 进行加锁,此时两个互斥量 mtx1mtx2 都已经被释放了,线程 t1 可以顺利成功获得占有两个互斥量。之后顺利执行函数 foo1() 完毕,并释放两个互斥量。

但这类情况并不会保证发生,还是有可能会出现循环等待,导致死锁的情况。

4 C++ 多线程解决死锁方法

4.1 有序加锁

我们可以通过确保所有线程 按照相同的顺序获取多个锁,这样就可以避免循环等待的情况。例如,如果所有线程都按照 “先尝试获取 mtx1,再尝试获取 mtx2” 的顺序加锁,那么就不会出现死锁。

这种有序加锁的方法破坏的是死锁产生条件中的 循环等待条件

  • 使用 std::mutex::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;

// 方法1:两个线程对互斥量加锁的顺序一致,可以避免死锁

void foo1(){
mtx1.lock();
std::cout << "thread 1 get mtx1." << "\n";
mtx2.lock();
std::cout << "thread 1 get mtx2." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));

mtx1.unlock();
mtx2.unlock();
}

void foo2(){
mtx1.lock();
std::cout << "thread 2 get mtx1." << "\n";
mtx2.lock();
std::cout << "thread 2 get mtx2." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));

mtx1.unlock();
mtx2.unlock();
}

int main(){
std::thread t1(foo1);
std::thread t2(foo2);
t1.join();
t2.join();

std::cout << "program ends." << "\n";

return 0;
}
  • 使用 std::lock_guard
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
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx1, mtx2;

// 方法1:两个线程对互斥量加锁的顺序一致,可以避免死锁

void foo1(){
std::lock_guard<std::mutex> lck1(mtx1);
std::cout << "thread 1 get mtx1." << "\n";
std::lock_guard<std::mutex> lck2(mtx2);
std::cout << "thread 1 get mtx2." << "\n";

// 模拟进行一些需要同时持有两个互斥量的操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

void foo2(){
std::lock_guard<std::mutex> lck1(mtx1);
std::cout << "thread 2 get mtx1." << "\n";
std::lock_guard<std::mutex> lck2(mtx2);
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;
}

在上面的两个代码中,我们通过将所有线程都按照相同的顺序进行加锁,避免了循环等待,这样就可以避免死锁的发生。

可能的运行结果如下:

4.2 一次性同时加锁

我们也可以通过一次性分配所有资源(一次性同时对每个所需互斥量加锁),在 C++ 多线程中,通常可以采用 std::lock 函数来对多个互斥量进行同时加锁。

这样一次性同时加锁的方式破坏了死锁产生条件中的 请求并保持条件。一次性地分配所有资源给线程,使得线程不需要再进行新的资源请求,因此避免了死锁的产生。

  • 使用 std::lock

    std::lock 定义在 <mutex> 头文件中,主要功能是可以 同时锁住多个互斥量(mutex),能够自动避免死锁的发生。

    std::lock 是一个可变参数模板,可以接受两个或多个互斥量作为参数。例如,有两个 std::mutex 互斥量 mtx1mtx2,那么可以通过 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 函数尝试一次性同时获得 mtx1mtx2 的锁,破坏了死锁产生条件中的 请求并保持条件,避免了死锁的发生。

    在解锁时,顺序可以是任意的,只需要确保最终两个锁都被正确释放即可。

  • 配合 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 等,采用 超时放弃 的机制来避免死锁。当线程(在等待的时间内)无法获取所需互斥量的锁时,不会被阻塞去等待互斥量,而是放弃尝试,直接进行返回。

这样的机制破坏的主要是 循环等待条件请求与保持条件,避免了死锁。

但是,如果我们需要确保每个线程能够完整执行,那么 超时放弃 的机制并不适用。

参考

  1. 死锁面试题(什么是死锁,产生死锁的原因及必要条件)

  2. cppreference.com std::lock


「C++ 多线程」死锁及 C++ 多线程解决死锁方法
https://marisamagic.github.io/2024/12/27/20241227/
作者
MarisaMagic
发布于
2024年12月27日
许可协议