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的 helgrinddrd 工具,专门用来抓线程相关的错误。我建议先用 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_waitfutex 出现在热点中,基本可以断定是锁竞争问题。

10.4 常见死锁问题排查

死锁这东西,说白了就是“你等我,我等你,谁也别想动”。排查死锁,我有三板斧。

10.4.1 第一板斧:看堆栈

用GDB的 thread apply all bt,看每个线程卡在哪个锁上。如果多个线程都在 __lll_lock_wait 里,那基本就是死锁了。

10.4.2 第二板斧:查锁顺序

把代码里所有加锁、解锁的地方列出来,画一个锁依赖图。如果图里有环,那就是死锁的根源。

死锁检测:锁依赖图 线程A 线程B 锁L1 锁L2 持有 等待 持有 等待 ⚠ 形成环路 → 死锁!

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);

排查步骤:

  1. 程序卡住后,用 kill -3 PID 打印线程堆栈(Java程序员可能熟悉这个,C也能用)。
  2. 看到两个线程都在 __lll_lock_wait 里,一个等lock_a,一个等lock_b。
  3. 用GDB attach上去,thread apply all bt 确认锁的地址。
  4. 修复方案:统一锁顺序,比如都先锁lock_a再锁lock_b。

重要提醒:不要以为死锁只会发生在显式的锁上。信号量、条件变量、甚至文件锁都可能造成死锁。排查时思路要开阔。

好了,这一章的内容就到这里。调试多线程程序,说白了就是跟不确定性做斗争。工具给你武器,但真正的功夫在于你如何分析、如何推理。多练、多踩坑,慢慢就有感觉了。


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