ThreadSanitizer(TSan)的原理 —— 运行时追踪 happens-before 来抓数据竞争
崩溃排查里最难缠的一类:数据竞争(data race)。它常常不当场崩,而是偶发、随调度飘忽、上线三个月才炸一次。普通测试、code review、看日志几乎都抓不到。TSan(ThreadSanitizer)是编译期插桩的动态数据竞争检测器,它换了个思路:不等 bug 发作,而是在运行时动态构建线程间的 happens-before 关系图,只要一次访问和上一次访问同一内存之间"没有同步 + 至少一个是写",就当场报警——哪怕这次运行程序看起来"完全正常"。
本篇讲 TSan 的原理。它和兄弟工具是一个检测器家族:asan 抓内存访问错误(越界 / UAF),本篇的 TSan 抓线程间数据竞争,valgrind 用另一条技术路线(二进制插桩)覆盖两者。data race 的语言层背景请看 memory-order 的 happens-before 模型,以及 compiler-reordering / memory-reordering(data race 为什么是 UB)。线程本身见 thread-creation。
一、TSan 是什么、解决什么
TSan = ThreadSanitizer,GCC / Clang 内置的编译期插桩工具,一个开关就能开:
g++ -fsanitize=thread -g -O1 race.cpp -o race
./race # 运行时若检测到数据竞争,立即打印两个线程的访问栈它解决的核心问题只有一个:数据竞争(data race)。
什么是数据竞争
C++ 内存模型给的定义很精确(见 memory-order):
两个线程访问同一块内存,其中至少一个是写,且这两次访问之间没有 happens-before 关系(即没有任何同步把它们排序)——这就是数据竞争,结果是未定义行为(UB)。
拆成三个条件,缺一不可:
| 条件 | 含义 |
|---|---|
| 同一内存位置 | 两次访问落在同一字节 / 同一变量上 |
| 至少一个是写 | 读-读不算竞争(都不改数据);读-写、写-写才算 |
| 无 happens-before | 中间没有锁、原子、线程 create/join 等同步把它们排出先后 |
注意第三条:只要有正确的同步把两次访问排出先后,就不是竞争。加了锁的共享变量、用
memory_order正确配对的原子变量,都不是 data race。TSan 判定的正是"有没有这条 happens-before 关系"。
为什么 data race 极难靠人和普通测试发现
data race 是所有并发 bug 里最"阴"的一类,原因是它非确定:
- 偶发:竞争窗口可能只有几纳秒,要两个线程恰好在那一瞬间撞上才出错,绝大多数运行都相安无事。
- 和调度相关:换台机器、换个核数、加点负载、开个 log,调度一变,原来能复现的竞争就消失了(所谓 "Heisenbug"——你一观察它就跑)。
- 后果延迟:竞争写坏的数据可能过很久才在别处引发崩溃,现场(core dump、崩溃栈)离真正的病根十万八千里。
- 测试无力:单元测试跑一遍"没崩"完全不能证明没有竞争——你只是这次调度没撞上而已。
TSan 的杀手锏:它不依赖竞争"真的发作"。只要程序执行到了那两次无同步的访问(哪怕这次它们没在同一瞬间撞车、哪怕数据没被写坏),TSan 也能从 happens-before 关系里判定"这两次访问之间缺少同步",从而报告出竞争的可能性。这就是它比"跑一万遍碰运气"强的根本原因。
一句话:TSan 检测的是"这两次访问之间有没有同步"这个静态可判定的关系,而不是"这次它们有没有真的撞上"这个靠运气的事件——所以一次正常运行也能抓出潜伏的竞争。
二、核心原理:运行时追踪 happens-before 关系
这是 TSan 的灵魂。它把 memory-order 里那个 C++ 标准的 happens-before 模型,从"纸面上的语义"变成了"运行时动态构建的关系图"。
三步机制
① 在每次内存访问、每个同步操作前插桩。 编译期,TSan 在你每一条 load/store 前、每一次加锁/解锁/原子操作/线程 create/join 前,都插入一段回调,通知 TSan 运行时库"谁、在什么逻辑时刻、对哪块内存、做了读还是写 / 做了什么同步"。
② 用向量时钟(vector clock)维护 happens-before。 TSan 给每个线程一个逻辑时钟,用向量时钟的思想记录"每个线程各自走到了第几步":
- 每个线程 T 有一个计数器,自己每做一步就 +1。
- 一次同步操作(如 B 加了 A 刚解开的锁)会让 B 把 A 的时钟"吸收"进自己的向量——这正是一条 happens-before 边(A 解锁 synchronizes-with B 加锁)。
- 于是任意时刻,每个线程都持有一份"我已经 happens-before 于哪些线程的哪些步骤"的向量快照。
③ 每次访问时比对,判定竞争。 每次读/写某内存位置时,TSan 从 shadow memory(见第三节)取出"这块内存上一次是被哪个线程、在什么逻辑时刻、读还是写",然后问一句:
这次访问 与 上次访问 之间,存在 happens-before 关系吗(即两次访问之间是否夹着足够的同步)?
- 存在 → 安全,两次访问已被排序,更新记录,放行。
- 不存在,且至少一次是写 → 数据竞争! 立即报告。

和 memory-order.md 对照着看:那篇讲的是"程序员应该用
release/acquire、锁来建立 happens-before 桥梁";TSan 做的就是在运行时把这些桥一座座记下来,然后检查每一对内存访问是不是至少被一座桥连着。没桥连着的写访问,就是标准意义上的 UB,TSan 逮个正着。
三、shadow memory:存的是"访问历史",不是"可寻址状态"
TSan 也用 shadow memory(影子内存)这套机制,但存的东西和 ASan 完全不同——这是理解两者区别的关键。
- ASan 的 shadow:给每 8 字节应用内存配 1 字节影子,记录这块内存"可不可寻址"(是否已分配、是否在红区、是否已释放)。用来抓越界 / use-after-free。
- TSan 的 shadow:给每块应用内存配一组**"影子单元(shadow cell)",每个单元记录最近一次访问的元数据**:
| 字段 | 含义 |
|---|---|
| 线程 ID(TID) | 是哪个线程访问的 |
| 时钟 / epoch | 访问发生在该线程的哪个逻辑时刻(用于比对 happens-before) |
| 读 / 写 | 这次是读还是写 |
| 访问大小 / 偏移 | 落在这块内存的哪几个字节 |
TSan 通常给每个应用内存位置保留 最近 N 次访问(典型 N=4)的影子单元,这样能覆盖"几个不同线程先后访问同一变量"的组合。每次新访问进来,就拿它和这几条历史记录逐一比对 happens-before。
这也解释了 TSan 的开销来源:要为大量内存位置存"多条带时钟的访问历史",内存开销通常是原来的 5~10 倍(比 ASan 更重)。
一句话:ASan 的影子回答"这个地址现在能不能碰";TSan 的影子回答"这个地址最近被谁、在什么逻辑时刻、怎么碰过"——一个存状态,一个存历史 + 时钟。
四、一次检测流程
把前面几节串起来,走一遍完整流程:

报告长这样(示意):
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x55a... by thread T2:
#0 worker() race.cpp:12
Previous read of size 4 at 0x55a... by main thread:
#0 main race.cpp:20
Location is global 'counter' at 0x55a...两个线程的栈都给出来了——你能同时看到"谁写的"和"谁读的",以及缺了哪把锁。
五、开销与取舍
| 维度 | 典型倍数 | 说明 |
|---|---|---|
| 内存 | 5~10x | shadow 要存多条带时钟的访问历史,比 ASan 更吃内存 |
| 速度 | 5~15x | 每次访问都要插桩 + 比对历史 + 维护向量时钟 |
结论和 ASan 一致:这是给测试 / CI 用的,不是给生产环境常开的。 典型用法:
g++ -fsanitize=thread -g -O1 app.cpp -o app_tsan # -g 出栈,-O1 兼顾速度和可读栈关键限制:TSan 不能和 ASan 同时开。 二者都要独占管理 shadow memory 的地址空间,-fsanitize=address,thread 会直接冲突。做法是分两次跑:一次 ASan 查内存错误、一次 TSan 查竞争。
六、TSan 能抓什么 / 抓不到什么
能抓(都是"缺同步或同步用错"):
- 多线程读写同一共享变量却没加锁(最经典的 data race)。
- 该用原子却用了普通变量,或原子的
memory_order用错(如该 acquire/release 的地方用了 relaxed,桥没搭上)。 - 锁用错:读写用了不同的锁、忘了加锁、加锁范围没覆盖到访问点。
- 部分死锁 / 锁顺序反转(TSan 有死锁检测选项)。
抓不到(动态检测的根本局限):
- 它只报告本次运行实际执行到的代码路径上出现的竞争。没跑到的分支、没触发的调度交错,它一无所知——覆盖率取决于你的测试是否跑到那条路径,以及那次运行的调度是否让两个线程都碰了那块内存。
- 所以 TSan"报了竞争"一定是真的(几乎无误报),但"没报竞争"不等于没有竞争——只是这次测试没覆盖到。要提高把握,得让测试尽量跑遍并发路径、多跑几轮不同调度。
一句话:TSan 是动态检测器——它的判定(有没有 happens-before)是精确的,但它的视野受限于"这次到底跑了哪些代码、怎么调度的"。
七、对比 ASan
TSan 和 asan 是同一套插桩 + shadow 思路的两个方向,抓的东西正交:
| 维度 | ASan | TSan |
|---|---|---|
| 抓什么 | 内存访问错误:堆/栈越界、use-after-free、double free | 线程间数据竞争:无同步的共享读写、原子/锁用错 |
| 核心机制 | shadow memory 存"可寻址状态" + 红区(redzone) | shadow memory 存"访问历史 + 向量时钟",运行时构建 happens-before |
| 判定依据 | 访问的地址在 shadow 里是否被标记为不可碰 | 两次访问之间是否存在 happens-before |
| 内存开销 | ~2x | ~5–10x |
| 速度开销 | ~2x | ~5–15x |
| 能否同时开 | 否——都要独占 shadow,分两次跑 | 同左 |
| 单线程能用 | 是(内存错误和线程无关) | 意义不大(没有线程就没有竞争) |
记忆点:同一把"影子内存"的锤子,ASan 用它敲"这块内存能不能碰",TSan 用它敲"这两次碰之间有没有同步"。
八、一句话总结
ThreadSanitizer 通过在每次内存访问和每个同步操作前插桩、用向量时钟在运行时动态构建线程间的 happens-before 关系图,并把每块内存的最近访问历史(线程/时钟/读写)存进 shadow memory;每次访问都比对"这次和上次同一位置的访问之间有没有 happens-before",一旦发现无同步且至少一个是写,就当场判定为数据竞争——因此它能在一次看似正常的运行里,揪出那些偶发、非确定、靠普通测试几乎抓不到的并发隐患。