目录
[1. 什么是死锁?一个生活中的比喻](#1. 什么是死锁?一个生活中的比喻)
[2. 死锁的四个必要条件(科夫曼条件)](#2. 死锁的四个必要条件(科夫曼条件))
[a. 互斥 (Mutual Exclusion)](#a. 互斥 (Mutual Exclusion))
[b. 持有并等待 (Hold and Wait)](#b. 持有并等待 (Hold and Wait))
[c. 不可抢占 (No Preemption)](#c. 不可抢占 (No Preemption))
[d. 循环等待 (Circular Wait)](#d. 循环等待 (Circular Wait))
[3. 如何处理死锁?](#3. 如何处理死锁?)
[4. C++ 标准库提供的"死锁安全带"](#4. C++ 标准库提供的“死锁安全带”)
[std::lock 和 std::scoped_lock (C++17)](#std::lock 和 std::scoped_lock (C++17))
1. 什么是死锁?一个生活中的比喻
想象一个场景:在一个狭窄的单行道上,两辆车迎面相遇。
-
车A 想要前进,但被 车B 挡住了。
-
车B 也想前进,但被 车A 挡住了。
两辆车都占着自己当前的路(持有资源 ),同时又在等待对方让出道路(请求资源 )。如果两位司机都互不相让,他们就会永远卡在这里,谁也动不了。这种情况,就是死锁。
在并发编程中,"车"就是线程 ,"道路"就是资源 (最常见的是互斥锁 std::mutex
)。
死锁的正式定义:指两个或多个并发线程,在执行过程中因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
2. 死锁的四个必要条件(科夫曼条件)
一个死锁的发生,必须同时满足以下全部四个条件。只要破坏其中任意一个,死锁就不会发生。
a. 互斥 (Mutual Exclusion)
-
定义:一个资源在同一时刻只能被一个线程持有。如果其他线程想使用该资源,必须等待直到资源被释放。
-
比喻:打印机在任何时候只能打印一个人的文件。在你打印完成之前,其他人必须等待。
-
编程体现 :
std::mutex
本身就是互斥的。lock()
操作确保了只有一个线程能进入临界区。这个条件在并发编程中通常是无法避免的,因为我们就是需要用锁来保护共享资源。
b. 持有并等待 (Hold and Wait)
-
定义:一个线程至少持有一个资源,并且正在请求另一个被其他线程持有的资源。在等待新资源的同时,它并不会释放自己已经持有的资源。
-
比喻:你手里拿着一支笔(持有资源1),现在你需要一张纸来写字,但纸在另一个人手里。你在等待他给你纸的同时,并没有放下你手中的笔。
-
编程体现:
std::lock_guard<std::mutex> lock1(mtx1); // 持有 mtx1 // ...做一些事... std::lock_guard<std::mutex> lock2(mtx2); // 等待 mtx2
c. 不可抢占 (No Preemption)
-
定义:资源不能被强制地从持有它的线程中"抢"走。只能由持有者在完成任务后自愿释放。
-
比喻:别人不能从你手里强行夺走你正在使用的笔。你必须自己决定什么时候用完并把它放下。
-
编程体现 :当一个线程通过
lock()
获得了互斥锁,操作系统或其他线程无法强制它unlock()
。它必须自己执行到作用域结束(对于lock_guard
)或手动调用unlock()
。
d. 循环等待 (Circular Wait)
-
定义:存在一个线程的等待链,形成一个闭环。
-
线程 T1 正在等待线程 T2 持有的资源。
-
线程 T2 正在等待线程 T3 持有的资源。
-
...
-
线程 Tn 最终等待线程 T1 持有的资源。
-
-
比喻:
-
你 想借小明 的钢笔,但小明 说必须先借到小红的笔记本才行。
-
而小红 说,她必须先借到你的橡皮擦,才肯借出笔记本。
-
于是,你等小明,小明等小红,小红又在等你。三个人陷入了无限的循环等待。
-
-
最经典的编程场景:
// 线程 1 void func1() { std::lock_guard<std::mutex> lock1(mtx1); // 1. 锁住 mtx1 std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> lock2(mtx2); // 2. 尝试锁住 mtx2 (等待线程2释放) } // 线程 2 void func2() { std::lock_guard<std::mutex> lock2(mtx2); // 1. 锁住 mtx2 std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> lock1(mtx1); // 2. 尝试锁住 mtx1 (等待线程1释放) }
如果
func1
和func2
并发执行,func1
可能锁住mtx1
等待mtx2
,而func2
同时锁住了mtx2
等待mtx1
,这就形成了循环等待,导致死锁。
3. 如何处理死锁?
主要有三种策略:预防、避免、检测与恢复。在应用级编程中,我们最关心的是死锁预防。
死锁预防:破坏四个必要条件之一
预防死锁的核心思想,就是通过编码规范和设计,来破坏四个必要条件中的至少一个。
-
破坏"互斥":
-
方法:允许多个线程同时访问资源。
-
可行性 :很低。对于打印机、共享计数器这类资源,我们就是为了互斥才加锁的。但对于只读数据,可以使用无锁数据结构或原子操作
std::atomic
来代替互斥锁。
-
-
破坏"持有并等待":
-
方法:规定线程在请求资源前,不能持有任何其他资源。要么一次性申请所有需要的资源,要么在申请新资源前,先释放已持有的所有资源。
-
可行性:较低。很难预知一个任务到底需要哪些资源,且"先释放再申请"可能导致线程活锁(不断尝试但总失败)。
-
-
破坏"不可抢占":
-
方法:允许系统或优先级更高的线程抢占资源。
-
可行性:极低。在应用层面几乎无法实现,且会使程序逻辑变得异常复杂。
-
-
破坏"循环等待" (最实用、最常用的策略)
-
方法 :对所有资源(互斥锁)进行全局排序,并强制所有线程都必须按照这个统一的顺序来获取锁。
-
可行性 :非常高,是预防死锁的黄金法则。
-
比喻:规定所有司机在十字路口都必须先让右边的车。这样就不会出现所有车都想抢先而堵死的局面。
-
编程实现:
std::mutex mtx1, mtx2; // 假设我们规定,必须先锁地址较小的互斥量 // 这是一个全局统一的顺序 // 线程 1 void func1_fixed() { // 总是按 mtx1 -> mtx2 的顺序加锁 std::lock_guard<std::mutex> lock1(mtx1); std::lock_guard<std::mutex> lock2(mtx2); } // 线程 2 void func2_fixed() { // 同样遵守 mtx1 -> mtx2 的顺序 std::lock_guard<std::mutex> lock1(mtx1); std::lock_guard<std::mutex> lock2(mtx2); }
通过强制所有线程都遵循
mtx1 -> mtx2
的加锁顺序,就打破了循环等待的可能性,从根本上消除了死锁。
-
4. C++ 标准库提供的"死锁安全带"
手动管理锁的顺序很麻烦且容易出错。为此,C++ 标准库提供了更高级的工具来自动处理这个问题。
std::lock
和 std::scoped_lock
(C++17)
当你需要同时锁定多个互斥锁时,应该使用它们。
-
std::lock(mtx1, mtx2, ...)
: 这是一个函数,可以一次性、无死锁地锁定传入的所有互斥锁。它内部实现了一套避免死锁的算法(例如,它会尝试锁定,如果某个锁失败了,它会解开所有已锁定的锁,然后重试)。 -
std::scoped_lock(mtx1, mtx2, ...)
(C++17, 最佳选择) : 这是一个 RAII 风格的类,是std::lock
的现代化封装。它在构造时自动调用std::lock
来安全地锁定所有互斥锁,在析构时(离开作用域时)自动将它们全部解锁。#include <mutex>
#include <thread>std::mutex mtx_A, mtx_B;
void safe_operation() {
// 只需要一行代码,就能安全、无死锁地锁定两个互斥锁
// 无需关心 mtx_A 和 mtx_B 的顺序
std::scoped_lock lock(mtx_A, mtx_B);// ... 在此作用域内,mtx_A 和 mtx_B 都被安全锁定 ... // ... 执行你的操作 ...
} // lock 析构时,会自动解锁 mtx_A 和 mtx_B
结论 :在需要同时锁定多个互斥锁的场景下,永远优先使用 std::scoped_lock
。这是现代 C++ 中预防死锁的最佳实践。