10. 多线程调试与性能分析:GDB调试多线程程序、Valgrind检测线程错误、性能分析工具(perf)、常见死锁问题排查
多线程编程写起来爽,调试起来……嗯,那叫一个酸爽。我见过太多人,代码写得飞起,一跑就崩,一崩就懵。为什么?因为线程之间的执行顺序是不确定的,你没法像单线程那样一步步复现。今天这一章,我就带你把这几个硬骨头啃下来。
核心要点:多线程调试的核心不是“找到bug”,而是“找到不确定性的规律”。工具只是辅助,思路才是关键。
10.1 GDB调试多线程程序
GDB调试多线程,说白了就是你要学会“盯住”多个执行流。我个人习惯先让程序跑起来,再根据现象决定从哪个线程入手。
10.1.1 基本调试命令
先看一个简单的多线程程序:
#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("Thread %d is running\n", id);
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
编译时记得加 -g 和 -lpthread:
gcc -g -o demo demo.c -lpthread
进入GDB后,常用的多线程命令:
| 命令 | 作用 |
|---|---|
info threads |
查看所有线程列表 |
thread N |
切换到线程N |
thread apply all bt |
打印所有线程的调用栈 |
set scheduler-locking on |
锁定调度,只运行当前线程 |
我的小技巧:调试死锁时,先用 thread apply all bt 看一眼所有线程卡在哪里。我曾经花了两小时逐线程单步,结果发现就是一把锁没释放——用这个命令十秒就定位了。
10.1.2 条件断点与线程断点
你想想看,如果10个线程都在跑同一个函数,你打断点会怎样?每次都会停。这时候可以用线程专有断点:
(gdb) break worker thread 2
(gdb) condition 1 id == 5
第一个命令只在线程2上打断点,第二个命令给断点加条件——只有id等于5时才停。嗯,这里要注意:条件断点会拖慢执行速度,生产环境慎用。
10.2 Valgrind检测线程错误
Valgrind的 helgrind 和 drd 工具,专门用来抓线程相关的错误。我建议先用 helgrind,它更激进,能发现更多潜在问题。
10.2.1 数据竞争检测
看这个经典错误:
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
counter++; // 没有加锁!
}
return NULL;
}
运行 valgrind --tool=helgrind ./demo,你会看到类似这样的输出:
==12345== Possible data race during read of size 4 at 0x...
==12345== at 0x...: increment (demo.c:8)
==12345== This conflicts with a previous write of size 4 at 0x...
==12345== at 0x...: increment (demo.c:8)
注意:Valgrind会大幅拖慢程序运行速度,有时候慢10倍以上。我曾经在一个大型服务上跑helgrind,等了半小时才出结果——所以建议先用小规模测试用例验证。
10.2.2 锁顺序错误检测
死锁的另一种常见形式是锁顺序不一致。比如线程A先锁L1再锁L2,线程B先锁L2再锁L1。helgrind能检测到这种潜在死锁:
==12345== Thread #1: lock order "0x... (L1) -> 0x... (L2)" violated
==12345== (see also: Thread #2)
看到这个输出,你就知道该统一锁的顺序了。
10.3 性能分析工具perf
perf是Linux自带的性能分析神器。它不打断程序运行,靠采样来统计热点。我个人习惯用它来回答三个问题:
- CPU时间花在哪了?
- 缓存命中率如何?
- 线程间是否有严重的锁竞争?
10.3.1 基本用法
# 记录性能数据,持续10秒
perf record -g -p PID -- sleep 10
# 生成报告
perf report
-g 表示记录调用栈,这样你能看到热点函数是被谁调用的。
10.3.2 分析锁竞争
perf可以统计spinlock的等待时间:
perf stat -e spin_lock_spin -p PID -- sleep 5
如果这个数值很高,说明你的锁竞争很严重。我曾经优化过一个消息队列,把一把大锁拆成多把细粒度锁,spinlock等待时间从15%降到了2%——吞吐量直接翻倍。
经验之谈:perf report里如果看到 __lll_lock_wait 或 futex 出现在热点中,基本可以断定是锁竞争问题。
10.4 常见死锁问题排查
死锁这东西,说白了就是“你等我,我等你,谁也别想动”。排查死锁,我有三板斧。
10.4.1 第一板斧:看堆栈
用GDB的 thread apply all bt,看每个线程卡在哪个锁上。如果多个线程都在 __lll_lock_wait 里,那基本就是死锁了。
10.4.2 第二板斧:查锁顺序
把代码里所有加锁、解锁的地方列出来,画一个锁依赖图。如果图里有环,那就是死锁的根源。
10.4.3 第三板斧:用工具自动检测
除了Valgrind,Linux自带的 lockdep 内核锁检测器也能用。不过用户态程序更推荐用 ThreadSanitizer(TSan):
gcc -fsanitize=thread -g -o demo demo.c -lpthread
./demo
TSan会在运行时检测数据竞争和死锁,而且性能开销比Valgrind小得多。我现在的项目基本都默认开启TSan编译。
避坑指南:我曾经在生产环境上遇到过一种“活锁”——线程没死锁,但一直在重试,CPU跑满但业务没进展。这种用perf一看就能发现:热点全在循环重试的逻辑里。解决办法是加退避策略,或者用条件变量替代忙等。
10.5 综合实战:排查一个真实死锁
假设你有这样的代码:
// 线程1
pthread_mutex_lock(&lock_a);
pthread_mutex_lock(&lock_b);
// ... 干活 ...
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
// 线程2
pthread_mutex_lock(&lock_b);
pthread_mutex_lock(&lock_a);
// ... 干活 ...
pthread_mutex_unlock(&lock_a);
pthread_mutex_unlock(&lock_b);
排查步骤:
- 程序卡住后,用
kill -3 PID打印线程堆栈(Java程序员可能熟悉这个,C也能用)。 - 看到两个线程都在
__lll_lock_wait里,一个等lock_a,一个等lock_b。 - 用GDB attach上去,
thread apply all bt确认锁的地址。 - 修复方案:统一锁顺序,比如都先锁lock_a再锁lock_b。
重要提醒:不要以为死锁只会发生在显式的锁上。信号量、条件变量、甚至文件锁都可能造成死锁。排查时思路要开阔。
好了,这一章的内容就到这里。调试多线程程序,说白了就是跟不确定性做斗争。工具给你武器,但真正的功夫在于你如何分析、如何推理。多练、多踩坑,慢慢就有感觉了。
公众号:蓝海资料掘金营,微信deep3321