15、并发调试与工具:GDB多线程调试、ThreadSanitizer使用、Valgrind与Helgrind、性能分析工具(perf/Intel VTune)

并发编程写起来爽,调试起来……嗯,那真是另一回事了。

我记得刚入行那会儿,写了个多线程队列,自测跑了一整天都没问题。结果上线第一天,用户量一上来,直接崩了。我盯着core dump看了三个小时,愣是没看出问题在哪。后来老同事过来,用GDB挂上去,三分钟定位到——一个变量没加volatile,编译器优化把循环给优化掉了。

从那以后我就明白了一个道理:并发调试不是靠眼睛看的,得靠工具

本章核心脉络:并发调试四件套——GDB看现场、ThreadSanitizer抓数据竞争、Valgrind/Helgrind查死锁、perf/VTune找性能瓶颈。每个工具都有自己的主场,别指望一把锤子敲所有钉子。

并发调试与工具 GDB 多线程调试 thread apply all bt info threads / set scheduler-locking ThreadSanitizer -fsanitize=thread 数据竞争检测 Valgrind / Helgrind 内存错误 + 死锁检测 --tool=helgrind perf / Intel VTune perf top / perf record 热点分析 / 缓存 miss 实战调试流程 复现 → 定位 → 修复 → 验证 多工具组合使用 没有银弹,但工具能让你少掉头发

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 实战调试流程:从崩溃到修复

最后,我总结一下自己常用的调试流程:

  1. 复现问题:写一个最小复现用例。如果复现不了,加日志、加计数器,缩小范围。
  2. 初步定位:用GDB挂上去,看core dump或者断点。如果是数据竞争,直接上TSan。
  3. 深入分析:如果是死锁,Helgrind走一波。如果是性能问题,perf先采样,VTune再细化。
  4. 修复验证:改完代码后,用TSan再跑一遍,确保没有新的数据竞争。然后压测,确认性能没下降。

记住一句话:并发bug就像鬼——你越怕它,它越找你。工具就是你的桃木剑,别空手上阵。

好了,这一章的内容就到这里。工具是死的,人是活的。多练、多用、多踩坑,慢慢你就会有感觉了。


公众号:蓝海资料掘金营,微信 deep3321