15、并发调试与工具:GDB多线程调试、ThreadSanitizer使用、Valgrind与Helgrind、性能分析工具(perf/Intel VTune)
并发编程写起来爽,调试起来……嗯,那真是另一回事了。
我记得刚入行那会儿,写了个多线程队列,自测跑了一整天都没问题。结果上线第一天,用户量一上来,直接崩了。我盯着core dump看了三个小时,愣是没看出问题在哪。后来老同事过来,用GDB挂上去,三分钟定位到——一个变量没加volatile,编译器优化把循环给优化掉了。
从那以后我就明白了一个道理:并发调试不是靠眼睛看的,得靠工具。
本章核心脉络:并发调试四件套——GDB看现场、ThreadSanitizer抓数据竞争、Valgrind/Helgrind查死锁、perf/VTune找性能瓶颈。每个工具都有自己的主场,别指望一把锤子敲所有钉子。
15.1 GDB多线程调试:现场勘查第一利器
GDB调试多线程程序,说白了就是「看现场」。程序崩了,core dump一出来,GDB就是你的法医。
我个人的调试习惯是这样的:
- 先用
info threads看看有多少线程活着 - 再用
thread apply all bt打印所有线程的调用栈 - 最后切到可疑线程,
frame看局部变量
这里有个坑——GDB默认调试时,所有线程都在跑。你断点停在一个线程,其他线程还在执行。这会导致你看到的「现场」其实已经变了。
避坑指南:我曾经调试一个死锁问题,断点停在线程A,看到它持有了锁L1,正在等锁L2。但切到线程B一看,它也在等锁L1。我心想「这不就是死锁吗?」结果修了半天发现——线程B其实早就释放了L1,只是GDB没停住它,等我切过去时状态已经变了。
解决方案:调试前先执行 set scheduler-locking on,让断点命中时所有线程都暂停。
// 典型调试会话
(gdb) set scheduler-locking on
(gdb) break worker_func
(gdb) run
(gdb) info threads
Id Target Id Frame
1 Thread 0x7f... worker_func (arg=0x0) at test.cpp:42
2 Thread 0x7f... 0x7f... in __lll_lock_wait ()
* 3 Thread 0x7f... worker_func (arg=0x1) at test.cpp:42
(gdb) thread 3
(gdb) bt
#0 worker_func (arg=0x1) at test.cpp:42
#1 0x7f... in start_thread ()
#2 0x7f... in clone ()
(gdb) print my_var
$1 = 42
嗯,这里还要提一句:多线程调试时,条件断点特别好用。比如你只想在某个线程ID下断住,可以写 break foo if pthread_self() == 0x7f...。虽然麻烦点,但比手动切线程快多了。
15.2 ThreadSanitizer:数据竞争的照妖镜
数据竞争(Data Race)是并发编程里最隐蔽的bug。两个线程同时访问同一个变量,至少一个是写操作,而且没有同步——程序可能跑一万次才出一次问题。
我有个朋友(真的不是我)曾经因为一个int没加锁,线上服务每两周随机崩溃一次。查了整整一个月,最后用ThreadSanitizer一把揪出来。
ThreadSanitizer(简称TSan)的使用方式极其简单:
// 编译时加上 -fsanitize=thread
g++ -g -fsanitize=thread -o test test.cpp -lpthread
// 运行即可,有数据竞争会直接报错
./test
TSan输出示例:
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x7f... by thread T1:
#0 worker_func test.cpp:15 (test+0x...)
Previous read of size 4 at 0x7f... by thread T2:
#0 worker_func test.cpp:16 (test+0x...)
Location is global 'shared_counter' of size 4 at 0x7f...
你看,它直接告诉你:哪个线程、哪行代码、什么变量、什么操作。比你自己翻代码找快一百倍。
个人经验:TSan有个缺点——内存开销大。程序跑起来会慢5-10倍,内存占用翻倍。所以别在生产环境开,只在测试环境用。我一般会在CI流程里加一个TSan的构建配置,每次提交自动跑一遍。
另外要注意:TSan和AddressSanitizer(ASan)不能同时用。它们会互相冲突。如果你既要查内存越界又要查数据竞争,那就分开编译两版,分别跑。
15.3 Valgrind与Helgrind:老牌工具依然能打
Valgrind是个工具集,咱们最常用的是两个:
- Memcheck:查内存泄漏、越界访问
- Helgrind:查POSIX线程错误,比如死锁、数据竞争
说实话,Helgrind现在有点被TSan取代的趋势。但Valgrind有个TSan比不了的优势——它不需要重新编译。你拿一个现成的二进制文件,直接跑 valgrind --tool=helgrind ./your_program 就行。
我曾在客户现场遇到过这种情况:客户给的二进制,没有源码,没有调试符号。TSan用不了,GDB也看不太明白。最后就是用Helgrind跑了一遍,直接报出死锁的锁地址和线程ID。
// Helgrind检测死锁
valgrind --tool=helgrind ./deadlock_demo
// 输出示例
==12345== Thread #1: lock order "0x... before 0x..." violated
==12345== at 0x...: pthread_mutex_lock (hg_intercepts.c:...)
==12345== by 0x...: worker_a (deadlock.cpp:25)
==12345== Required order was established by acquisition of lock at 0x...
==12345== at 0x...: pthread_mutex_lock (hg_intercepts.c:...)
==12345== by 0x...: worker_b (deadlock.cpp:35)
注意:Valgrind跑起来比TSan还慢,大概慢20-30倍。我建议只在以下场景用:
- 没有源码,只有二进制
- 需要同时查内存错误和线程错误
- TSan搞不定的奇怪问题(比如和第三方库的交互)
15.4 性能分析工具:perf与Intel VTune
调试完正确性,接下来就是性能了。多线程程序最常见的性能问题:
- 锁竞争:多个线程抢同一把锁,大部分时间在等待
- 伪共享:不同线程操作不同变量,但它们在同一个缓存行上
- 上下文切换:线程太多,CPU都在切线程,没空干活
perf是Linux自带的,免费、轻量、够用。我平时排查性能问题,第一件事就是跑 perf top -p <pid>,看看CPU时间都花在哪了。
// 采样热点函数
perf record -g -p <pid> -- sleep 10
perf report
// 查看缓存miss
perf stat -e cache-misses,cache-references -p <pid> -- sleep 5
如果perf的输出让你觉得「嗯,好像有点问题但说不清」,那就上Intel VTune。VTune的图形界面做得很好,能直接告诉你:
- 哪些锁是热点(锁等待时间占比)
- 哪些缓存行有伪共享
- 哪些分支预测失败率高
我的经验:VTune虽然强大,但学习曲线陡。我建议初学者先从perf入手,等perf满足不了需求了,再上VTune。别一上来就搞VTune,容易劝退。
15.5 实战调试流程:从崩溃到修复
最后,我总结一下自己常用的调试流程:
- 复现问题:写一个最小复现用例。如果复现不了,加日志、加计数器,缩小范围。
- 初步定位:用GDB挂上去,看core dump或者断点。如果是数据竞争,直接上TSan。
- 深入分析:如果是死锁,Helgrind走一波。如果是性能问题,perf先采样,VTune再细化。
- 修复验证:改完代码后,用TSan再跑一遍,确保没有新的数据竞争。然后压测,确认性能没下降。
记住一句话:并发bug就像鬼——你越怕它,它越找你。工具就是你的桃木剑,别空手上阵。
好了,这一章的内容就到这里。工具是死的,人是活的。多练、多用、多踩坑,慢慢你就会有感觉了。