死锁 (Deadlock) 深度解析 - 详解
目录 1. 什么是死锁?一个生活中的比喻 2. 死锁的四个必要条件(科夫曼条件) a. 互斥 (Mutual Exclusion) b. 持有并等待 (Hold and Wait) c. 不可抢占 (No P...
目录
1. 什么是死锁?一个生活中的比喻
2. 死锁的四个必要条件(科夫曼条件)
a. 互斥 (Mutual Exclusion)
b. 持有并等待 (Hold and Wait)
c. 不可抢占 (No Preemption)
d. 循环等待 (Circular Wait)
3. 如何处理死锁?
死锁预防:破坏四个必要条件之一
4. C++ 标准库提供的“死锁安全带”
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 lock1(mtx1); // 持有 mtx1
// ...做一些事...
std::lock_guard 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 lock1(mtx1); // 1. 锁住 mtx1
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard lock2(mtx2); // 2. 尝试锁住 mtx2 (等待线程2释放)
}
// 线程 2
void func2() {
std::lock_guard lock2(mtx2); // 1. 锁住 mtx2
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard 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 lock1(mtx1);
std::lock_guard lock2(mtx2);
}
// 线程 2
void func2_fixed() {
// 同样遵守 mtx1 -> mtx2 的顺序
std::lock_guard lock1(mtx1);
std::lock_guard 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
#include
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++ 中预防死锁的最佳实践。