什么是死锁?

死锁是指一组并发进程(或线程)彼此等待对方所持有的资源,从而导致所有进程都无法继续向前执行的一种僵持状态。

可以把它想象成一个交通堵塞:

  • 四辆车在一个十字路口相遇。

  • 每辆车都需要其他车让出道路才能通过。

  • 但没有任何一辆车愿意后退。

  • 结果就是所有车都卡在原地,谁也动不了。

在计算机中,这些“道路”就是互斥资源,如:锁、信号量、文件句柄、套接字等。

死锁产生的四个必要条件

这四个条件必须同时满足,死锁才会发生。只要破坏其中任意一个,就能预防死锁。

  • 互斥条件:一个资源每次只能被一个进程使用。

    • 例如:一把锁一次只能被一个线程持有。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

    • 例如:线程 A 拿着锁 L1 不放,同时去请求锁 L2。
  • 不剥夺条件:进程已获得的资源,在未使用完之前,不能被强行剥夺,只能由该进程主动释放。

    • 例如:操作系统不能强行把线程 A 手中的锁 L1 抢过来给线程 B。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

    • 例如:线程 A 等待线程 B 持有的资源,线程 B 又在等待线程 A 持有的资源。

Linux C++ 代码示例

下面是一个经典的、必然会发生死锁的双线程示例:

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1, mutex2;

void threadA() {
    std::unique_lock<std::mutex> lock1(mutex1); // 1. 线程A锁住mutex1
    std::cout << "Thread A acquired mutex1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 增加不确定性,确保线程B能锁住mutex2

    std::unique_lock<std::mutex> lock2(mutex2); // 3. 线程A尝试获取mutex2(但此时已被线程B锁住) -> 阻塞!
    std::cout << "Thread A acquired mutex2" << std::endl; // 永远不会执行
}

void threadB() {
    std::unique_lock<std::mutex> lock2(mutex2); // 2. 线程B锁住mutex2
    std::cout << "Thread B acquired mutex2" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    std::unique_lock<std::mutex> lock1(mutex1); // 4. 线程B尝试获取mutex1(但此时已被线程A锁住) -> 阻塞!
    std::cout << "Thread B acquired mutex1" << std::endl; // 永远不会执行
}

int main() {
    std::thread t1(threadA);
    std::thread t2(threadB);

    t1.join();
    t2.join();

    std::cout << "Main thread exits" << std::endl;
    return 0;
}

分析:

  • 程序启动两个线程 t1 和 t2。

  • t1 先锁住 mutex1,t2 先锁住 mutex2。

  • 接着,t1 尝试去锁 mutex2(但它在 t2 手里),t2 尝试去锁 mutex1(但它在 t1 手里)。

  • 双方都持有一把锁,并无限期地等待对方释放另一把锁。循环等待形成,程序卡死,无法输出最后一条信息。

这就是一个典型的死锁。

在 Linux 中如何检测和调试死锁?

1.观察现象:
程序表现为“卡住”,不产生任何输出,CPU 占用率可能很低(因为线程在阻塞等待,而不是忙循环)。

2.使用调试工具:
Linux 提供了强大的命令行工具来帮助诊断。

  • gdb (GNU Debugger)

    • 首先,用 ps -aux | grep <program_name> 找到卡死程序的 PID。

    • 然后,用 gdb 附加到该进程:sudo gdb -p

    • 在 gdb 中,输入 thread apply all bt(查看所有线程的调用栈回溯)。

    • 分析结果:你会看到两个或多个线程的栈回溯,它们都卡在 __lll_lock_wait 或类似的函数中,这正是等待锁的地方。通过查看栈帧,你可以清楚地看到每个线程在哪个源代码行正在请求哪把锁。

  • pstack

    • 这是一个脚本,可以快速打印一个进程的栈跟踪:pstack

    • 其输出类似于 thread apply all bt,但更快捷。

  • strace / ltrace

    • strace 跟踪系统调用。

    • ltrace 跟踪库函数调用。

    • 你可以用它们启动程序(strace ./my_program)或附加到已运行进程(strace -p ),观察程序卡在哪个系统调用(很可能是 futex,这是 Linux 中实现锁的常见系统调用)。

如何避免和预防死锁?

记住死锁的四个必要条件,破坏其中之一即可。

  • 破坏“请求与保持”条件:一次性申请所有需要的资源。

    • 缺点:可能降低资源利用率,因为有些资源可能很晚才用到。
  • 破坏“不剥夺”条件:如果一个进程请求资源失败,则释放它当前持有的所有资源。

    • 缺点:实现复杂,可能导致任务重复执行。
  • 破坏“循环等待”条件:这是最常用且有效的方法。

    • 对资源进行排序:给所有锁定义一个全局的获取顺序。每个线程都必须按照这个顺序来申请锁。

    • 修改上面的例子:让 threadA 和 threadB 都按照先 mutex1 后 mutex2 的顺序申请锁。这样,threadA 拿到 mutex1 后,threadB 在申请 mutex1 时就会阻塞,直到 threadA 释放 mutex1 和 mutex2。这样就不会形成循环。

void threadA() {
    std::unique_lock<std::mutex> lock1(mutex1);
    std::unique_lock<std::mutex> lock2(mutex2); // 顺序和threadB一致
    // ... 安全地操作资源
}

void threadB() {
    std::unique_lock<std::mutex> lock1(mutex1); // 先尝试获取mutex1,而不是mutex2
    std::unique_lock<std::mutex> lock2(mutex2);
    // ... 安全地操作资源
}

使用更高级的抽象:

  • std::lock():C++ 标准库提供的函数,可以一次性锁住多个互斥量,并且避免了死锁(它内部使用特殊的算法来避免循环等待)。这是首选方案
#include <mutex>
...
void safe_thread() {
    std::unique_lock<std::mutex> lock1(mutex1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);
    std::lock(lock1, lock2); // 一次性锁住两个,不会死锁
    // ... 临界区操作
}

避免嵌套锁

  • 尽量减少持有锁的同时去获取另一把锁。如果逻辑不可避免,务必使用上述的“锁排序”或 std::lock。

小结

方面要点
定义一组进程/线程相互等待,导致所有进程都无法执行。
必要条件互斥、请求与保持、不剥夺、循环等待。
调试方法使用 gdb -p + thread apply all bt,或 pstack 。
预防关键破坏循环等待:对锁进行排序或使用 std::lock() 一次性获取多个锁。
最佳实践锁的粒度要细,持锁时间要短,尽量避免嵌套锁,必须嵌套时规定顺序。
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐