目录
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++ 中预防死锁的最佳实践。