Jakob Jenkov 2020-10-28
竞态条件(Race Condition)是一种并发问题,可能出现在临界区(Critical Section)中。所谓临界区,是指一段被多个线程执行的代码区域,并且线程执行的顺序会影响这段代码并发执行的结果。
当多个线程执行某段临界区代码时,如果最终结果依赖于这些线程的执行顺序,那么我们就说该临界区存在竞态条件。"竞态条件"这个术语来源于一个比喻:多个线程在“竞速”通过临界区,而这场“比赛”的结果会影响临界区执行后的最终状态。
这听起来可能有些抽象,接下来我会更详细地解释竞态条件和临界区的概念。
两种类型的竞态条件
当两个或更多线程按照以下两种模式之一读写同一个变量时,就可能发生竞态条件:
- 读-修改-写(Read-modify-write)
- 检查后执行(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 作为一个原子指令执行的,而是被分解为若干小指令,大致如下:
- 从内存中读取
this.count到寄存器。 - 将
value加到寄存器中。 - 将寄存器中的值写回内存。
考虑以下线程 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.util.concurrent.atomic.AtomicInteger
临界区的吞吐量
对于较小的临界区,直接将其整个包裹在 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() 方法对两个独立的成员变量 sum1 和 sum2 进行累加。为防止竞态条件,整个操作被放在一个 synchronized 块中。这意味着同一时间只能有一个线程执行该方法。
然而,由于 sum1 和 sum2 彼此独立,我们可以将同步块拆开:
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。因为这两个同步块锁定的是不同的对象,所以它们可以并行执行,从而减少了线程间的等待时间,提升了吞吐量。
⚠️ 注意:此例非常简单。在真实系统中,拆分临界区通常要复杂得多,需要仔细分析各种执行顺序的可能性。