4、死锁侦探:死锁检测原理与GDB实战

多线程编程里,死锁是个让人头疼的问题。说白了,就是两个线程互相等着对方释放资源,结果谁也动不了。我刚开始接触多线程时,就栽过跟头——程序跑着跑着突然卡死,CPU占用率掉到0,所有线程都睡大觉去了。

今天咱们就来聊聊,怎么用GDB这个工具,把死锁揪出来。

死锁是怎么发生的?

死锁有四个必要条件,教科书上叫「互斥、持有并等待、不可剥夺、循环等待」。嗯,记不住也没关系,你只要知道:两个线程各自拿着一把锁,都在等对方手里的锁,这就死锁了。

看个简单的例子:

// 线程A
pthread_mutex_lock(&lock1);
// 做一些操作...
pthread_mutex_lock(&lock2);  // 等lock2
// 临界区代码
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);

// 线程B
pthread_mutex_lock(&lock2);
// 做一些操作...
pthread_mutex_lock(&lock1);  // 等lock1
// 临界区代码
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);

线程A拿了lock1,线程B拿了lock2。然后A等B手里的lock2,B等A手里的lock1。完蛋,两个线程都卡住了。

核心要点:死锁的本质就是「循环等待」。你等我,我等你,谁也不让谁。

GDB怎么检测死锁?

我个人习惯用GDB的thread apply all bt命令。这个命令会打印所有线程的调用栈。死锁时,你一眼就能看出哪些线程卡在pthread_mutex_lock上。

操作步骤很简单:

  1. 用gdb -p <进程PID>附加到卡死的进程
  2. 输入thread apply all bt
  3. 观察输出,找pthread_mutex_lock的调用

输出大概长这样:

(gdb) thread apply all bt

Thread 2 (Thread 0x7f123456a700 (LWP 12345)):
#0  __lll_lock_wait () at .../lowlevellock.c:52
#1  0x00007f1234567890 in __pthread_mutex_lock (mutex=0x60104c) at .../pthread_mutex.c:148
#2  0x00000000004005a0 in thread_func_b () at deadlock.c:25
#3  0x00007f1234567890 in start_thread () at .../pthread_create.c:456

Thread 1 (Thread 0x7f1234567800 (LWP 12344)):
#0  __lll_lock_wait () at .../lowlevellock.c:52
#1  0x00007f1234567890 in __pthread_mutex_lock (mutex=0x601048) at .../pthread_mutex.c:148
#2  0x0000000000400570 in thread_func_a () at deadlock.c:15
#3  0x00007f1234567890 in start_thread () at .../pthread_create.c:456

看到没?两个线程都卡在__lll_lock_wait里。这就是死锁的典型特征——所有线程都在等锁,没有线程在干活。

小技巧:如果线程太多,可以用thread apply all bt full打印更详细的信息,包括局部变量。有时候锁的地址能帮你判断是哪把锁出了问题。

用GDB找出锁的持有者

光知道线程卡在锁上还不够。你得知道:这把锁现在被谁拿着?

GDB里有个命令叫pthread_mutex_t,但直接看结构体有点费劲。我一般这么干:

  1. 先找到锁的地址(从调用栈里能看到)
  2. 用print (pthread_mutex_t)*0x60104c查看锁的状态
  3. 看__owner字段,里面存的是持有锁的线程ID

举个例子:

(gdb) print (pthread_mutex_t)*0x60104c
$1 = {__data = {__lock = 2, __count = 0, __owner = 12344, ...}, ...}

__owner = 12344表示线程ID为12344的线程拿着这把锁。你再看看调用栈,12344是不是也在等别的锁?嗯,死锁的链条就出来了。

注意:GDB里看到的线程ID是轻量级进程ID(LWP),不是pthread_create返回的pthread_t。用info threads可以查看对应关系。

实战:手把手排查死锁

我记得有一次线上服务卡死了,用户反馈说某个接口一直没响应。我上去一看,进程还活着,但CPU占用率是0。嗯,典型的死锁症状。

排查步骤:

  1. gdb -p <PID> 附加进程
  2. thread apply all bt 看所有线程的调用栈
  3. 发现两个线程都卡在pthread_mutex_lock上
  4. 分别查看两把锁的__owner字段
  5. 确认锁A的持有者是线程B,锁B的持有者是线程A

真相大白了。代码里两个线程加锁的顺序不一致,一个先A后B,另一个先B后A。只要稍微错开一点时间,就死锁了。

修复方案很简单:统一加锁顺序。让所有线程都先锁A再锁B,问题就解决了。

用SVG画个死锁检测流程图

下面这张图,展示了用GDB检测死锁的完整流程:

GDB死锁检测流程图 程序卡死,怀疑死锁 gdb -p <PID> 附加进程 thread apply all bt 是否都在等锁? 是 查看锁的__owner 确认循环等待链 否 不是死锁

预防死锁的几个习惯

说实话,等死锁发生了再去排查,已经有点晚了。更好的做法是提前预防。我总结了几条经验:

  • 统一加锁顺序:所有线程按同样的顺序加锁,这是最有效的办法
  • 使用trylock:用pthread_mutex_trylock代替pthread_mutex_lock,拿不到锁就返回,避免死等
  • 缩小锁范围:只在真正需要保护的数据上加锁,别锁一大片
  • 加锁超时:有些系统支持带超时的锁,超时了就放弃

避坑指南:我曾经在项目里遇到过一个隐蔽的死锁——回调函数里加锁了。A线程调了B模块的回调,B模块的回调里又去拿A线程的锁。这种间接死锁最难查,建议所有回调函数都明确标注「是否允许加锁」。

总结一下

死锁检测其实不复杂。核心就三步:

  1. 用GDB看所有线程的调用栈
  2. 找到卡在pthread_mutex_lock上的线程
  3. 查看锁的__owner,确认循环等待链

你想想看,死锁说白了就是「你等我,我等你」。只要把等待链画出来,问题就一目了然了。下次遇到程序卡死,别急着重启,先用GDB看看——说不定就能抓到那个「死锁元凶」。

可可精品资料库