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

看起来没有问题,但如果两个线程同时执行这个函数,可能会导致账户余额不一致:

Race Accounts

两个线程同时检查余额是否足够(都看到 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++ 在底层实际上是三个操作:

  1. 读取当前值(Read)
  2. 修改为新值(Modify)
  3. 写回内存(Write)

如果多个线程同时执行 counter++,就会出现竞态:

Race Increment

这类问题通常被称为 数据竞争(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):利用硬件提供的原子指令,如 AtomicIntegerAtomicReference 等。
  • 无锁结构(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(数据竞争)
  • 如何检测竞态条件
  • 解决竞态的两大策略:
    • 避免共享状态
    • 使用同步和原子操作

最后,我们还区分了竞态条件和数据竞争之间的区别。

在编写多线程程序时,理解这些概念能帮助我们写出更健壮、更安全的代码。避免踩坑,从理解竞态开始。


原始标题:What Is a Race Condition?

« 上一篇: 检测有向图中的环
» 下一篇: 自平衡二叉搜索树