15、多线程调试与性能分析:GDB多线程调试技巧、Valgrind检测线程错误、性能分析工具perf的使用、常见并发Bug模式
多线程编程,写起来一时爽,调试起来火葬场。这话我当年刚入行时就深有体会。
你想想看,单线程程序出bug,大不了从头跑一遍,加几个打印就定位了。但多线程呢?线程切换的时机、竞态条件的触发、死锁的复现——这些玩意儿就像幽灵一样,时隐时现。我见过太多同事在并发bug面前抓耳挠腮,最后只能靠"加sleep大法"碰运气。
今天这一章,我就把压箱底的多线程调试和性能分析手段摊开来聊。说白了,就三样法宝:GDB、Valgrind、perf。用好它们,并发bug基本无处遁形。
GDB 多线程调试:别再用printf了
我刚开始写多线程程序时,调试全靠printf。后来发现这方法太蠢了——你加一行打印,可能就改变了线程的调度时序,bug反而复现不了了。这就是所谓的Heisenbug(海森堡bug):你一观测,它就不见了。
GDB对多线程的支持其实很成熟。我个人习惯用这几个命令:
| 命令 | 作用 | 我的使用频率 |
|---|---|---|
info threads | 列出所有线程及其ID | ⭐⭐⭐⭐⭐ |
thread N | 切换到线程N | ⭐⭐⭐⭐⭐ |
thread apply all bt | 打印所有线程的调用栈 | ⭐⭐⭐⭐ |
set scheduler-locking on | 调试时只运行当前线程 | ⭐⭐⭐ |
break func thread N | 只在线程N的func处断下 | ⭐⭐⭐⭐ |
thread apply all bt一把梭。看到两个线程都在等锁,而且锁的地址一样——嗯,基本就破案了。
举个例子。假设你有两个线程,一个在等mutex1,一个在等mutex2,但各自又持有了对方需要的锁。这时候GDB里跑一下:
(gdb) thread apply all bt
Thread 2 (Thread 0x7ffff6ffb700 (LWP 12345)):
#0 __lll_lock_wait () at ...
#1 _L_lock_812 () at ...
#2 pthread_mutex_lock (mutex=0x601060 <mutex1>) at ...
#3 worker_b () at deadlock.c:25
Thread 1 (Thread 0x7ffff77fc700 (LWP 12344)):
#0 __lll_lock_wait () at ...
#1 _L_lock_812 () at ...
#2 pthread_mutex_lock (mutex=0x601080 <mutex2>) at ...
#3 worker_a () at deadlock.c:15
看到没?线程1在等mutex2,线程2在等mutex1。典型的ABBA死锁。这时候你再查一下代码里谁先拿了谁,问题就清楚了。
set scheduler-locking on。我曾经因为这个没设,单步调试时另一个线程突然把变量改了,我盯着屏幕看了十分钟才反应过来。
Valgrind 检测线程错误:你的代码里藏着多少雷?
Valgrind这个工具,说实话,我第一次用的时候被它输出的错误数量吓到了。你以为写得很完美的代码,在Valgrind眼里可能千疮百孔。
对于多线程,Valgrind提供了两个专用工具:Helgrind和DRD。它们能检测:
- 数据竞争(data race):两个线程同时访问同一变量,且至少一个是写操作
- 锁顺序违规:可能导致死锁的加锁顺序问题
- 未初始化的锁:用了没初始化的pthread_mutex_t
- 重复解锁:同一个锁被解了两次
用法很简单:
$ gcc -g -pthread myprogram.c -o myprogram
$ valgrind --tool=helgrind ./myprogram
它会输出类似这样的信息:
==12345== Possible data race during write of size 4 at 0x601050
==12345== at 0x4005F2: increment (test.c:10)
==12345== by 0x4006A1: worker (test.c:25)
==12345== This conflicts with a previous write of size 4 at 0x601050
==12345== at 0x4005F2: increment (test.c:10)
==12345== by 0x4006A1: worker (test.c:25)
==12345== Address 0x601050 is 0 bytes inside a variable declared in main.c:5
==12345== declared: global_counter
你看,它直接告诉你:global_counter这个变量有数据竞争。两个线程都在写它,而且没有加锁保护。
我的经验:Valgrind跑多线程程序会慢很多(5-10倍),但值得等。我曾经在一个线上服务里用Helgrind抓到了一个极其隐蔽的数据竞争——那个bug在测试环境跑了两个月都没复现,Valgrind一跑就现原形了。
perf 性能分析:别猜,要测
很多人优化多线程程序靠"感觉"。感觉这里慢,感觉那里有瓶颈。我建议你改掉这个习惯——用perf说话。
perf是Linux内核自带的性能分析工具。它能告诉你:
- CPU到底在忙什么(用户态?内核态?)
- 缓存命中率如何
- 上下文切换有多频繁
- 锁的争抢有多激烈
几个常用命令:
| 命令 | 用途 |
|---|---|
perf stat ./program | 统计程序整体性能指标 |
perf record ./program | 采样记录性能数据 |
perf report | 分析采样结果 |
perf top | 实时查看热点函数 |
举个例子。你写了一个多线程程序,但发现性能上不去。跑一下perf stat:
$ perf stat ./myprogram
Performance counter stats for './myprogram':
10,000,000 cycles
5,000,000 instructions
500,000 context-switches
100,000 cpu-migrations
20,000 page-faults
看到context-switches有50万次?这说明你的线程在疯狂切换。为什么?很可能是因为锁争抢太激烈,线程频繁被挂起。这时候你就该想想:能不能用读写锁?能不能用无锁数据结构?
perf stat看宏观指标,再用perf record/report定位具体热点函数。别一上来就钻细节,先搞清楚瓶颈在哪一层。
常见并发Bug模式:这些坑我都踩过
写多线程代码这么多年,我总结了几种最常见的bug模式。你写代码时多留个心眼,能省不少调试时间。
1. 数据竞争(Data Race)
两个线程同时读写同一变量,没有同步机制。这是最基础的并发bug。
// 错误示例
int counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 100000; i++) {
counter++; // 没有锁!
}
return NULL;
}
你以为counter最后会是200000?实际上可能只有十几万。为什么?因为counter++不是原子操作——它包含读、加、写三步,两个线程可能同时读到同一个值。
2. 死锁(Deadlock)
两个线程互相等待对方释放锁。前面GDB的例子就是典型。避免死锁的黄金法则是:固定加锁顺序。比如所有线程都先锁A再锁B,就不会出现ABBA的情况。
3. 活锁(Livelock)
线程没有阻塞,但一直在做无用功。比如两个线程检测到冲突后,各自退让重试,结果又撞上了。这比死锁更难调试——因为程序还在"跑",但就是不干活。
4. 锁的粒度问题
锁太大,性能差(相当于串行化);锁太小,容易出bug。我见过有人给每个循环迭代都加锁解锁,结果性能比单线程还差。也见过有人用一个全局大锁保护所有数据,8核CPU只跑出了1核的性能。
小结
多线程调试没有银弹。GDB帮你定位死锁和线程状态,Valgrind帮你揪出数据竞争,perf帮你分析性能瓶颈。这三板斧用好了,大部分并发问题都能搞定。
记住一点:不要靠猜,要靠工具。你觉得是A问题,测出来可能是B问题。我见过太多人花了一整天在错误的方向上打转,最后发现是锁用错了。
嗯,这一章的内容就到这。工具是死的,人是活的。多练多用,慢慢你就会有感觉了。