﻿# ThreadSanitizer（TSan）的原理 —— 运行时追踪 happens-before 来抓数据竞争

> 崩溃排查里最难缠的一类：**数据竞争（data race）**。它常常不当场崩，而是偶发、随调度飘忽、上线三个月才炸一次。普通测试、code review、看日志几乎都抓不到。TSan（ThreadSanitizer）是编译期插桩的**动态数据竞争检测器**，它换了个思路：不等 bug 发作，而是在运行时**动态构建线程间的 happens-before 关系图**，只要一次访问和上一次访问同一内存之间"没有同步 + 至少一个是写"，就当场报警——哪怕这次运行程序看起来"完全正常"。


> 本篇讲 TSan 的**原理**。它和兄弟工具是一个检测器家族：[asan.md](/crash/asan.md) 抓内存访问错误（越界 / UAF），本篇的 TSan 抓线程间数据竞争，[valgrind.md](/crash/valgrind.md) 用另一条技术路线（二进制插桩）覆盖两者。data race 的语言层背景请看 [../cache/memory-order.md](/concepts/memory-ordering/memory-order.md) 的 happens-before 模型，以及 [../cache/compiler-reordering.md](/concepts/memory-ordering/compiler-reordering.md) / [../cache/memory-reordering.md](/concepts/memory-ordering/memory-reordering.md)（data race 为什么是 UB）。线程本身见 [../process/thread-creation.md](/concepts/process/thread-creation.md)。

## 一、TSan 是什么、解决什么

**TSan = ThreadSanitizer**，GCC / Clang 内置的编译期插桩工具，一个开关就能开：

```bash
g++ -fsanitize=thread -g -O1 race.cpp -o race
./race          # 运行时若检测到数据竞争,立即打印两个线程的访问栈
```

它解决的核心问题只有一个：**数据竞争（data race）**。

### 什么是数据竞争

C++ 内存模型给的定义很精确（见 [../cache/memory-order.md](/concepts/memory-ordering/memory-order.md)）：

> **两个线程访问同一块内存，其中至少一个是写，且这两次访问之间没有 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 的灵魂。它把 [../cache/memory-order.md](/concepts/memory-ordering/memory-order.md) 里那个 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 关系吗**（即两次访问之间是否夹着足够的同步）？

> - **存在** → 安全，两次访问已被排序，更新记录，放行。

> - **不存在，且至少一次是写** → **数据竞争！** 立即报告。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "线程A" as A
participant "线程B" as B
== 情况一:有同步 → 安全 ==
A -> A : x = 1        (写, A时钟=5)
A -> A : mutex.unlock()   ← 同步点
B -> B : mutex.lock()     ← 吸收A的时钟\nB 建立 "A@5 happens-before 我"
B -> B : x = 2        (写)
note over A,B : 比对:两次写之间有 happens-before(经由锁)\n⇒ 已排序,不是竞争 ✅
== 情况二:无同步 → 数据竞争 ==
A -> A : y = 1        (写, A时钟=8)
B -> B : r = y        (读, B与A之间无任何同步)
note over A,B : 比对:两次访问之间【没有】happens-before\n且其中一个是写\n⇒ DATA RACE ❌ 立即报告
@enduml
```

> **和 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 的影子回答"这个地址**最近被谁、在什么逻辑时刻、怎么碰过**"——一个存状态，一个存历史 + 时钟。

## 四、一次检测流程

把前面几节串起来，走一遍完整流程：

```plantuml
@startuml
skinparam shadowing false
skinparam activity {
  BackgroundColor #E8F5E9
  BorderColor     #2E7D32
  DiamondBackgroundColor #FFF9C4
  DiamondBorderColor     #F9A825
}
start
:线程 T 访问变量 M(读或写);
:插桩回调触发\n记录本次访问 = {TID=T, 时钟=当前epoch, 读/写};
:从 shadow memory 取出 M 已有的\n最近若干条访问记录;
if (存在一条历史访问,\n来自【不同线程】?) then (是)
  if (本次 与 该历史访问之间\n有 happens-before?) then (有同步)
    :已被排序,安全;
    :更新 shadow 记录\n放行;
    stop
  else (无同步)
    if (两次之中至少一个是【写】?) then (是)
      #FFCDD2:判定 DATA RACE!\n打印:\n- 线程1 的访问栈 + 读/写\n- 线程2 的访问栈 + 读/写\n- 涉及(或缺失)的同步/锁;
      stop
    else (都是读)
      :读-读,无害\n放行;
      stop
    endif
  endif
else (同线程或首次)
  :同线程访问天然有序\n更新 shadow 放行;
  stop
endif
@enduml
```

报告长这样（示意）：

```bash
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 用的，不是给生产环境常开的。** 典型用法：

```bash
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.md](/crash/asan.md) 是同一套插桩 + 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",一旦发现无同步且至少一个是写,就当场判定为数据竞争——因此它能在一次看似正常的运行里,揪出那些偶发、非确定、靠普通测试几乎抓不到的并发隐患。**

