1. 竞态条件概述
竞态条件(Race Condition)是多线程程序中最常见的问题之一。
简单来说,当程序的执行结果依赖于多个线程或进程的执行顺序或调度时间时,就可能发生竞态条件。这种情况下,程序的行为是不确定的,可能会导致不期望的结果,从而引入 bug。
我们常用“线程安全”(Thread-safe)来形容一个程序、代码或数据结构在多线程环境下不会出现竞态问题。
举个例子:假设我们有两个银行账户 A 和 B,每个账户初始余额都是 500。我们尝试同时从 A 向 B 转账 300 元。
正常逻辑如下:
algorithm Transfer(amount, accountSource, accountTarget):
// INPUT
// amount = 转账金额
// accountSource = 转账来源账户
// accountTarget = 转账目标账户
// OUTPUT
// 如果余额足够,从来源账户扣除金额,并加到目标账户
if accountSource.balance < amount:
return
accountTarget.balance <- accountTarget.balance + amount
accountSource.balance <- accountSource.balance - amount
看起来没有问题,但如果两个线程同时执行这个函数,可能会导致账户余额不一致:
两个线程同时检查余额是否足够(都看到 A 有 500),然后各自执行转账,最终 A 的余额可能变成 -100,而不是期望的 -50。
这种现象就是典型的竞态条件。
要避免这种情况,对共享资源的操作必须是原子的。可以通过“临界区”(Critical Section)或“原子操作”(Atomic Operation)来实现。
2. Check-Then-Act 模式
上面的例子中,我们先检查余额是否足够,再执行转账操作,这种模式称为 Check-Then-Act。
这类竞态条件非常常见。它指的是程序根据一个可能已经过时的状态来做后续操作决策。这种问题也被称为 TOCTOU(Time-of-check to time-of-use)漏洞。
TOCTOU 类型的竞态在文件系统访问中尤其常见,攻击者可能利用这类漏洞进行权限提升或拒绝服务攻击。
另一个常见的例子是“懒加载初始化”(Lazy Initialization):
if (instance == null) {
instance = new Singleton();
}
如果多个线程同时进入判断,可能会创建多个实例。这也是典型的 Check-Then-Act 竞态。
3. Read-Modify-Write 模式
除了 Check-Then-Act,还有一种常见的竞态类型是 Read-Modify-Write。
比如下面这个生成唯一 ID 的函数:
algorithm GenerateId():
// INPUT
// counter = 全局计数器
// OUTPUT
// 返回一个唯一 ID,基于计数器递增
return counter++
虽然看起来简单,但 counter++
在底层实际上是三个操作:
- 读取当前值(Read)
- 修改为新值(Modify)
- 写回内存(Write)
如果多个线程同时执行 counter++
,就会出现竞态:
这类问题通常被称为 数据竞争(Data Race),我们将在后面详细讨论。
4. 竞态条件的检测
竞态条件通常难以复现、调试和消除,我们常把由竞态引起的 bug 称为 Heisenbug(在调试时可能消失)。
由于竞态条件与程序语义强相关,目前没有通用的检测手段。多线程单元测试虽然有助于发现部分问题,但无法保证 100% 捕获所有竞态。
不过,我们可以借助一些工具进行检测,例如:
- RV-Predict
- ThreadSanitizer
- Intel Inspector
这些工具可以在运行时检测数据竞争和竞态条件,是排查问题的好帮手。
5. 竞态条件的解决策略
解决竞态问题主要有两种思路:
✅ 避免共享状态
❌ 使用同步和原子操作
5.1 避免共享状态
既然竞态是由于共享状态导致的,那么最根本的解决办法就是避免共享状态。
- 不可变对象(Immutable Objects):对象一旦创建就不能修改,天然线程安全。
- 线程本地变量(ThreadLocal):每个线程拥有自己的变量副本,互不干扰。
- 异常代替检查:比如在转账逻辑中,与其先检查余额是否足够,不如直接尝试操作并捕获异常。即“请求原谅比请求许可更容易”(EAFP)。
- Actor 模型:完全禁止共享状态的并发模型,如 Akka。
5.2 使用同步和原子操作
这是最常见的解决方案。
- 同步机制(Synchronization):如 synchronized 关键字、Lock、Mutex 等,保证临界区代码一次只被一个线程执行。
- 原子操作(Atomic Operations):利用硬件提供的原子指令,如
AtomicInteger
、AtomicReference
等。 - 无锁结构(Lock-free):如使用 CAS(Compare and Swap)实现的并发数据结构。
- STM(Software Transactional Memory):类比数据库事务,对内存操作提供事务支持。
⚠️ 注意:虽然同步机制可以有效解决竞态问题,但也带来了性能损耗、死锁风险和组合复杂度。
6. 数据竞争(Data Race)
前面提到的 counter++
示例,除了是竞态条件外,也是典型的 数据竞争。
数据竞争的定义是:两个线程同时访问同一个变量,其中至少有一个是写操作。
数据竞争是更底层的概念,通常与并发模型和平台定义有关。不同平台对数据竞争的定义可能略有不同。
关键区别在于:
✅ 竞态条件依赖程序语义
✅ 数据竞争不依赖程序逻辑,仅关注内存访问行为
也就是说,数据竞争可以被自动检测工具识别,而竞态条件则不行。
比如在银行转账的例子中,除了 Check-Then-Act 的逻辑问题,balance 的增减操作也存在数据竞争。即使我们用锁保护了这些操作,仍然可能因为整体逻辑没有同步而存在竞态。
7. 小结
本文我们讨论了多线程编程中常见的竞态条件问题,包括:
- 什么是竞态条件
- 常见的两种竞态模式:
- Check-Then-Act(TOCTOU)
- Read-Modify-Write(数据竞争)
- 如何检测竞态条件
- 解决竞态的两大策略:
- 避免共享状态
- 使用同步和原子操作
最后,我们还区分了竞态条件和数据竞争之间的区别。
在编写多线程程序时,理解这些概念能帮助我们写出更健壮、更安全的代码。避免踩坑,从理解竞态开始。