竞态条件与临界区

更新于 2025-12-28

Jakob Jenkov 2020-10-28

竞态条件(Race Condition)是一种并发问题,可能出现在临界区(Critical Section)中。所谓临界区,是指一段被多个线程执行的代码区域,并且线程执行的顺序会影响这段代码并发执行的结果。

当多个线程执行某段临界区代码时,如果最终结果依赖于这些线程的执行顺序,那么我们就说该临界区存在竞态条件。"竞态条件"这个术语来源于一个比喻:多个线程在“竞速”通过临界区,而这场“比赛”的结果会影响临界区执行后的最终状态。

这听起来可能有些抽象,接下来我会更详细地解释竞态条件和临界区的概念。

两种类型的竞态条件

当两个或更多线程按照以下两种模式之一读写同一个变量时,就可能发生竞态条件:

  1. 读-修改-写(Read-modify-write)
  2. 检查后执行(Check-then-act)

读-修改-写模式

该模式指的是:两个或多个线程首先从某个变量中读取值,然后修改该值,再将新值写回变量。要使这种情况引发问题,新值必须以某种方式依赖于旧值。

可能出现的问题是:两个线程都把变量值读入 CPU 寄存器,然后各自在寄存器中修改值,最后再写回内存。这种情形将在下文详细说明。

检查后执行模式

该模式指的是:两个或多个线程先检查某个条件(例如,判断一个 Map 是否包含某个键),然后基于该信息采取行动(比如从 Map 中取出该值)。

问题可能出现在:两个线程同时检查 Map 是否包含某个键,发现存在,于是都尝试移除该键对应的值。但事实上只有一个线程能成功移除,另一个线程会得到 null。这种情况也可能发生在使用 Queue 而非 Map 的场景中。


读-修改-写的临界区

如前所述,读-修改-写的临界区可能导致竞态条件。下面我们深入分析其原因。

以下是一个 Java 示例,展示了在多线程并发执行时可能失败的读-修改-写临界区:

public class Counter {
    protected long count = 0;

    public void add(long value) {
        this.count = this.count + value;
    }
}

假设有两个线程 A 和 B 同时对同一个 Counter 实例调用 add() 方法。操作系统在线程之间切换的时机是不可预测的。而且,add() 方法中的代码并不是由 JVM 作为一个原子指令执行的,而是被分解为若干小指令,大致如下:

  1. 从内存中读取 this.count 到寄存器。
  2. value 加到寄存器中。
  3. 将寄存器中的值写回内存。

考虑以下线程 A 和 B 的交错执行顺序:

this.count = 0;

A: 从内存读取 this.count 到寄存器(值为 0)
B: 从内存读取 this.count 到寄存器(值为 0)
B: 将值 2 加到寄存器
B: 将寄存器的值(2)写回内存 → this.count = 2
A: 将值 3 加到寄存器
A: 将寄存器的值(3)写回内存 → this.count = 3

两个线程原本希望分别加上 2 和 3,最终结果应为 5。但由于线程执行顺序交错,最终结果却是 3。

在这个例子中,两个线程都读到了初始值 0,各自加上自己的值后再写回。最终留在 this.count 中的值,取决于最后一个写入的线程(此处是线程 A,但也可能是 B)。


读-修改-写临界区中的竞态条件

前面示例中 add() 方法的代码就构成了一个临界区。当多个线程并发执行该临界区时,就会出现竞态条件。

更正式地说,当两个线程竞争访问同一资源,且访问顺序会影响结果时,就称为竞态条件。导致竞态条件的代码段被称为临界区


检查后执行的临界区

如前所述,检查后执行的临界区也可能导致竞态条件。如果多个线程检查同一条件,然后根据该条件采取会改变条件本身的操作,就可能出错。

例如,若两个线程同时检查某个条件为真,然后其中一个线程改变了条件,另一个线程仍基于旧条件执行操作,就会导致逻辑错误。

下面是一个具体示例:

public class CheckThenActExample {
    public void checkThenAct(Map<String, String> sharedMap) {
        if (sharedMap.containsKey("key")) {
            String val = sharedMap.remove("key");
            if (val == null) {
                System.out.println("Value for 'key' was null");
            }
        } else {
            sharedMap.put("key", "value");
        }
    }
}

如果两个或更多线程同时调用同一个 CheckThenActExample 对象的 checkThenAct() 方法,它们可能同时进入 if 语句块(因为都看到 "key" 存在)。随后,多个线程都会尝试移除 "key",但只有第一个能成功,其余线程将得到 null


如何防止竞态条件

要防止竞态条件,必须确保临界区的执行是原子的(atomic)——即一旦一个线程开始执行临界区,其他线程必须等待,直到该线程退出临界区。

实现这一点的方法是对临界区进行线程同步(Thread Synchronization)。在 Java 中,可以通过以下方式实现:


临界区的吞吐量

对于较小的临界区,直接将其整个包裹在 synchronized 块中通常是可行的。但对于较大的临界区,将其拆分为多个更小的临界区可能更有利——这样允许多个线程并行执行不同的小临界区,从而减少对共享资源的竞争,提高整体吞吐量。

下面是一个非常简化的 Java 示例,用于说明这一思想:

public class TwoSums {
    private int sum1 = 0;
    private int sum2 = 0;

    public void add(int val1, int val2) {
        synchronized(this) {
            this.sum1 += val1;
            this.sum2 += val2;
        }
    }
}

注意:add() 方法对两个独立的成员变量 sum1sum2 进行累加。为防止竞态条件,整个操作被放在一个 synchronized 块中。这意味着同一时间只能有一个线程执行该方法。

然而,由于 sum1sum2 彼此独立,我们可以将同步块拆开:

public class TwoSums {
    private int sum1 = 0;
    private int sum2 = 0;
    private Object sum1Lock = new Object();
    private Object sum2Lock = new Object();

    public void add(int val1, int val2) {
        synchronized(sum1Lock) {
            this.sum1 += val1;
        }
        synchronized(sum2Lock) {
            this.sum2 += val2;
        }
    }
}

现在,两个线程可以同时执行 add() 方法:一个线程在第一个 synchronized 块中操作 sum1,另一个线程在第二个块中操作 sum2。因为这两个同步块锁定的是不同的对象,所以它们可以并行执行,从而减少了线程间的等待时间,提升了吞吐量。

⚠️ 注意:此例非常简单。在真实系统中,拆分临界区通常要复杂得多,需要仔细分析各种执行顺序的可能性。