20、死锁与竞态条件:ThreadSanitizer实战

多线程编程,说白了就是让程序同时干几件事。但这事吧,搞不好就是灾难现场。死锁和竞态条件,这两个词我估计你听得耳朵都起茧了。但真正在项目里遇到时,那种抓狂的感觉,我至今记忆犹新。

今天咱们不聊虚的。直接上 ThreadSanitizer(简称 TSan),这个工具是我调试并发问题的救命稻草。我会结合自己的实战经历,带你把它用透。

20.1 死锁与竞态条件:到底是个啥?

先简单捋一下概念。死锁,就是两个线程互相等对方释放资源,结果谁也动不了。竞态条件,就是多个线程同时读写共享数据,结果数据乱套了。

举个例子。线程 A 拿了锁 1,想拿锁 2。线程 B 拿了锁 2,想拿锁 1。好了,两个人都卡住了。这就是死锁。

再比如,两个线程同时给一个全局变量加 1。你以为结果是 2,结果可能是 1。因为加 1 这个操作不是原子的。这就是竞态条件。

核心区别:死锁是程序卡住不动,竞态条件是程序跑得欢但结果不对。前者好定位,后者隐蔽得多。

20.2 ThreadSanitizer 是什么?

TSan 是 Google 开发的一个动态分析工具。它会在程序运行时检测数据竞争和死锁。说白了,它就像个侦探,盯着每个线程的每一次内存访问。

我刚开始用 TSan 时,觉得它就是个普通的 sanitizer。后来有一次,一个 bug 折腾了我三天,最后 TSan 一秒就揪出来了。嗯,从那以后,我写多线程代码必开 TSan。

TSan 的工作原理其实不复杂。它会在每次内存读写时插入检查代码。如果发现两个线程同时访问同一块内存,且至少有一个是写操作,又没有锁保护,它就报错。

我的习惯:开发阶段一直开着 TSan。虽然会慢 2-3 倍,但比起事后排查 bug 的时间,这点代价太值了。

20.3 怎么用 ThreadSanitizer?

用 TSan 很简单。编译时加两个 flag 就行:

gcc -fsanitize=thread -g -O1 -o my_program my_program.c -lpthread

关键参数说明:

参数 作用 我的建议
-fsanitize=thread 启用 TSan 必加
-g 生成调试信息 必加,否则报错看不到行号
-O1 优化级别 推荐 O1,O2 可能误报
-lpthread 链接 pthread 库 多线程程序必加

运行程序后,TSan 会在标准错误输出报告。如果没发现错误,它静悄悄的不说话。发现了,它会打印详细的调用栈。

注意:TSan 和 AddressSanitizer(ASan)不能同时使用。它们会冲突。我吃过这个亏,编译了半天报错,最后发现是这两个家伙打架。

20.4 实战:用 TSan 抓竞态条件

来看一个经典的例子。两个线程同时给一个全局变量加 1:

#include <pthread.h>
#include <stdio.h>

int counter = 0;

void* worker(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++;  // 这里有问题!
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, NULL);
    pthread_create(&t2, NULL, worker, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

用 TSan 编译运行:

gcc -fsanitize=thread -g -O1 -o race race.c -lpthread
./race

TSan 会输出类似这样的信息:

==================
WARNING: ThreadSanitizer: data race (pid=12345)
  Write of size 4 at 0x7f... by thread T1:
    #0 worker race.c:8 (race+0x...)

  Previous write of size 4 at 0x7f... by thread T2:
    #0 worker race.c:8 (race+0x...)

  Location is global 'counter' at 0x7f... (race+0x...)
==================

看到了吗?它直接告诉你:第 8 行,counter++ 那里,两个线程在打架。而且它把两个线程的调用栈都打印出来了。

修复方法:加一把互斥锁。或者用原子操作 __sync_fetch_and_add。我个人习惯用锁,因为可读性更好。

20.5 实战:用 TSan 抓死锁

死锁的检测,TSan 也有一套。来看这个例子:

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

void* thread_a(void* arg) {
    pthread_mutex_lock(&lock1);
    sleep(1);  // 故意等一会
    pthread_mutex_lock(&lock2);
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

void* thread_b(void* arg) {
    pthread_mutex_lock(&lock2);
    sleep(1);
    pthread_mutex_lock(&lock1);
    pthread_mutex_unlock(&lock1);
    pthread_mutex_unlock(&lock2);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_a, NULL);
    pthread_create(&t2, NULL, thread_b, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

运行后,TSan 会输出死锁警告。它会告诉你哪个线程持有了哪把锁,又在等哪把锁。

我曾经在一个项目中遇到过类似的问题。两个线程锁的顺序反了,结果程序每隔几分钟卡死一次。当时我以为是硬件问题,查了两天。最后 TSan 一跑,直接定位到代码。嗯,从那以后我写锁的代码,都会在注释里标明锁的顺序。

避坑指南:我曾经犯过一个低级错误——忘记初始化互斥锁。TSan 会报「lock order violation」,但信息不够直观。所以我的建议是:所有锁都用 PTHREAD_MUTEX_INITIALIZER 静态初始化,或者用 pthread_mutex_init 动态初始化。别偷懒。

20.6 TSan 的常见误报与处理

TSan 不是万能的。它有时候会误报。常见的情况有:

  • 原子操作误报:如果你用了 GCC 的 __sync_* 系列原子操作,TSan 可能不认识。解决办法是用 C11 的 stdatomic.h。
  • 线程安全库误报:有些第三方库内部用了自己的同步机制,TSan 可能检测不到。可以加黑名单。
  • 编译器优化导致误报:O2 以上优化级别可能让 TSan 误判。我一般用 O1。

怎么处理误报?我的做法是:先确认是不是真问题。如果是误报,在代码里加注释说明,然后用 TSan 的抑制文件过滤掉。

20.7 知识体系图

下面这张图总结了本章的核心逻辑。你看一眼就能明白 TSan 在整个调试流程中的位置:

ThreadSanitizer 实战知识体系 并发问题 死锁 / 竞态条件 TSan 检测 动态分析 / 插桩 输出结果 警告 / 调用栈 竞态条件检测 死锁检测 误报处理 实战建议 开发阶段始终开启 TSan | 优先使用 C11 原子操作 | 统一锁顺序

20.8 总结与建议

TSan 是个好工具,但它不是银弹。我的经验是:

  • 写多线程代码时,脑子里先想清楚锁的粒度。锁太大性能差,锁太小容易死锁。
  • 能用原子操作解决的问题,就别用锁。原子操作的开销小得多。
  • TSan 的警告,每条都认真看。90% 的情况下,它是对的。
  • 如果 TSan 报错但你确认没问题,先怀疑自己的代码,别急着怪工具。

最后说一句。调试并发问题,心态很重要。别急,一步步来。TSan 会帮你找到问题在哪。剩下的,就是你的经验了。

一句话总结:ThreadSanitizer 是你调试多线程程序的第三只眼。开它,省心。


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