承接的问题
前两节给了两个发现竞态的工具:JCStress 用概率采样,Lincheck 用模型检验。但工具只负责「发现」,工程上还有三步没人替你做:
- 把大现场缩成小用例。线上是 1024 并发、库存 500、三个下游通知;你不可能把这一整套塞进测试。
- 把偶发变成可重复的证据。一次运行没命中,不代表修好了;一次命中,也不代表下次还能命中。
- 把用例固化成门禁。修完就删测试,是并发 bug 最常见的复发方式。
这一节用一个构造场景走完这三步。场景不冒充任何真实事故:把 libraryd-conc 的 PooledLoanService 放到 1024 并发下,库存 500,压测脚本观察到库存变成负数、成功借出数大于库存。这个现象在 1.3 的并发阶梯实验里就能复现,不需要线上流量。
症状、证据与第一刀
症状:压测结束后,库存字段为 -3,成功借出次数为 503。业务约束是 stock >= 0 且成功次数不得超过初始库存。
证据:把压测时的服务实现从 PooledLoanService 换成 AtomicLoanService,同样的并发档位不再出现负库存;换回 UnsafeLoanService 的扣减逻辑,负库存重现。这把范围缩到「库存扣减的读改写」这一小段。
根因假设:扣减是「读—判断—写」,不是原子操作。两个线程可以同时读到 1、同时通过判断、同时写回 0 和 -1。
第一刀:砍掉一切与竞态无关的东西。 仓储、通知、参数校验、指标上报全部删掉,只留下一个 AtomicInteger stock 和两个调用者。这一步的判据是:删完之后 bug 仍然出现。如果删掉某样东西后 bug 消失,那它才是根因的一部分,不能删。
最小化的目标不是「代码少」,而是让每一次运行都在回答同一个问题:这段读改写有没有窗口?仓储慢了、通知失败了、GC 暂停了,都不该影响这个问题的答案。
四把剪刀:状态、线程、循环、种子
最小化可以拆成四个独立动作,每个动作都有明确的收益和边界:
| 剪刀 | 从 | 到 | 收益 | 边界 |
|---|---|---|---|---|
| 缩状态 | 库存 500、3 个会员、3 个下游 | 库存 1、2 个 actor | 结果空间从上千种降到 2 种 | 库存不能为 0,否则没有可借的书 |
| 减线程 | 1024 并发 | 2 个 actor | 交错从天文数字降到可枚举 | 少于 2 个就没有并发 |
| 缩循环 | 压测 10 分钟 | -m quick,约 7 秒/测试 | 反馈从小时级降到秒级 | 样本变少,命中率估计变粗 |
| 固定种子 | 每次随机 JIT 配置 | 记录 -XX:StressSeed | 失败可回放同一组编译随机化 | 种子只管 JIT,不管线程调度 |
缩状态的关键是「让非法结果只有一个」。库存 500 时,负库存可能是 -1 到 -500 的任何值,每个值都要一条 @Outcome;库存 1 时,非法结果只有 stock=-1, success=2 这一种,合法结果只有 stock=0, success=1 这一种。结果空间越小,报告越好读。
减线程要抵抗「线程越多越容易复现」的直觉。两个 actor 已经能构造「读、读、写、写」的完整交错;加到 8 个 actor,只是让结果表多出许多无关组合,并不会让窗口变宽。真正需要 3 个以上 actor 的场景是「三方竞争下的 ABA」这类问题,普通的检查再动作两个就够。
缩循环用 JCStress 的模式预设。本机 8.1 的实测:-m quick -c 8 跑两个测试约 14.7 秒,平均每个测试 7 秒多。这个时间足够放进 PR 门禁;-m default 和 -m tough 的迭代更多,本节没有实测,接入 nightly 时要先用一次完整运行校准预算,不要照抄别人的分钟数。
固定种子要理解它固定的是什么。JCStress 的失败报告会打印出问题的 JVM 配置,本机真实的一行是:
com.plume.conc.lab.StockRaceTest [-XX:+StressLCM, -XX:+StressGCM, -XX:+StressIGVN, -XX:+StressCCP, -XX:StressSeed=858442236]: Observed forbidden state: stock=-1, success=2
把这一串参数通过 -jvmArgs 传给 JCStress,可以复现同一组 JIT 随机化配置。但它不控制操作系统的线程调度,所以同一个种子不保证命中同一个交错。这一点必须写进复现记录,否则下一个接手的人会以为「种子一样就一定能复现」。
从概率到确定:两条复现路径
拿到最小用例后,复现方式有两种,适用场景不同:
路径一:JCStress 多轮采样。 8.1 的两轮实测:
第一轮:FORBIDDEN 18,299,285 / 221,769,994 = 8.25%
第二轮:FORBIDDEN 16,420,052 / 214,714,634 = 7.65%
两轮合计约 4.36 亿个样本,命中率相差 0.6 个百分点。这说明两件事:窗口确实存在,且触发频率不是常数。报告里要写「两轮、每轮约 2.1 亿样本、命中率 7.65%–8.25%」,而不是「触发概率 8%」。
如果两轮都没命中,不能直接判「无竞态」。可行的做法是:先确认测试类真的被 TestList 收录(8.1 的 <proc>full</proc> 问题),再逐步提高 -iters 或换 -m default,并记录总共跑了多少样本。样本数不够的「没复现」是无效结论。
路径二:Lincheck 的最小场景。 8.2 的模型检验会直接给出「线程 1 borrow(): 0、线程 2 borrow(): 0」这样一个确定的失败序列。它不依赖概率,适合做「修没修好」的判据;代价是需要 Kotlin 工具链,本节所在环境没有执行过。
两条路径的分工可以记成一句话:JCStress 证明「在真实硬件上会发生」,Lincheck 证明「在某个合法交错下必然发生」。 回归测试优先用后者做判据,用前者做补充证据。
固化成回归测试
回归测试的第一条要求是「失败必须让构建失败」。JCStress 的退出码已经满足:出现 FORBIDDEN 时进程退出码为 1(本机实测),全部通过时为 0。所以不需要自己解析报告,直接把命令接进 CI 即可。
先给一个完整的、可直接提交的测试类。它把 8.1 的读改写与修复版放在同一个文件里,用两个 @JCStressTest 类分别验证「反例必须失败、修复必须通过」:
package com.plume.conc.lab;
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import java.util.concurrent.atomic.AtomicInteger;
public final class StockRegression {
@JCStressTest
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE,
desc = "库存 1 只允许成功借出一次")
@Outcome(id = "-1, 2", expect = Expect.FORBIDDEN,
desc = "两位会员借到同一本书")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间态")
@State
public static class Unsafe {
final AtomicInteger stock = new AtomicInteger(1);
final AtomicInteger success = new AtomicInteger();
@Actor
public void alice() {
if (stock.get() > 0) {
stock.decrementAndGet();
success.incrementAndGet();
}
}
@Actor
public void bob() {
if (stock.get() > 0) {
stock.decrementAndGet();
success.incrementAndGet();
}
}
@Arbiter
public void arbiter(II_Result r) {
r.r1 = stock.get();
r.r2 = success.get();
}
}
@JCStressTest
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE,
desc = "CAS 版本只允许成功借出一次")
@Outcome(id = "-1, 2", expect = Expect.FORBIDDEN,
desc = "CAS 版本不应超卖")
@Outcome(expect = Expect.ACCEPTABLE_INTERESTING, desc = "其他中间态")
@State
public static class CasFixed {
final AtomicInteger stock = new AtomicInteger(1);
final AtomicInteger success = new AtomicInteger();
@Actor
public void alice() {
borrow();
}
@Actor
public void bob() {
borrow();
}
private void borrow() {
int cur;
do {
cur = stock.get();
if (cur == 0) {
return;
}
} while (!stock.compareAndSet(cur, cur - 1));
success.incrementAndGet();
}
@Arbiter
public void arbiter(II_Result r) {
r.r1 = stock.get();
r.r2 = success.get();
}
}
}
注意两个 @State 都是 public static 嵌套类。JCStress 要求状态类不是 final、且是 public;嵌套类写成 private 会在注解处理阶段报错。文件名 StockRegression.java 与公开类名一致,javac 才会生成正确的 class 文件。
第二块是把命令接进 Maven。用 exec-maven-plugin 在 verify 阶段跑 org.openjdk.jcstress.Main:
<profile>
<id>jcstress</id>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>jcstress-quick</id>
<phase>verify</phase>
<goals><goal>java</goal></goals>
<configuration>
<mainClass>org.openjdk.jcstress.Main</mainClass>
<arguments>
<argument>-m</argument>
<argument>quick</argument>
<argument>-c</argument>
<argument>4</argument>
<argument>-t</argument>
<argument>CasFixed</argument>
</arguments>
<classpathScope>test</classpathScope>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
这段配置没有在本机以 Maven 插件方式执行过,但同一份 StockRegression 类用等价的命令行实跑过:
java -cp "$CP" org.openjdk.jcstress.Main -m quick -c 8 -t 'StockRegression'
本机结果:14.6 秒;Unsafe 在 253,913,354 个样本中命中 22,807,168 次 FORBIDDEN(8.98%),CasFixed 通过。整体退出码是 1——因为反例按设计失败。这条命令验证了两件事:嵌套的 @JCStressTest 类能被 -t 选择器匹配到,正例和反例在同一个文件里可以各自独立判定。
第一次接入时,先用 mvn verify -Pjcstress 单独确认两件事:插件能启动 JCStress、失败时 Maven 退出码非零。不要直接把它塞进主干流水线。
CI 脚本里再加一层保险:记录时间、JDK 版本、样本数,并把报告目录作为构建产物保留。
#!/bin/sh
set -eu
report="target/jcstress-$(date +%Y%m%d-%H%M%S)"
{
date
uptime
java -version 2>&1
} > "${report}-env.txt"
mvn -q -DskipTests package
mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
CP="target/classes:$(cat target/cp.txt)"
java -cp "$CP" org.openjdk.jcstress.Main \
-m quick -c 4 -t 'CasFixed' \
-r "$report"
-r 让 JCStress 把 HTML 报告写到指定目录,失败时可以直接把目录上传为 CI 产物。脚本本身没有做容错,set -eu 会让 JCStress 的非零退出码直接终止流水线——这正是我们要的「失败即阻断」。
CI 的成本控制
并发测试的成本不是线性增长的:线程数每加一,交错数就上一个量级。CI 里要按「反馈速度」和「覆盖强度」分两层:
| 层级 | 触发 | 模式 | 测试范围 | 预算 | 判据 |
|---|---|---|---|---|---|
| PR 门禁 | 每次提交 | quick | 每个竞态测试一个最小用例 | 单测试约 7 秒,10 个测试约 1–2 分钟 | 任何 FORBIDDEN 即失败 |
| nightly | 每天一次 | default / tough | 全部 JCStress + Lincheck 场景 | 本节未实测,需单独校准 | 命中率趋势、样本数、种子 |
| 发布前 | 手动 | quick + 压测 | 回归集 + 15.3 验收 | 按发布窗口 | 双重通过 |
三条成本纪律:
- PR 只跑最小用例。最小用例的状态空间已经被剪刀剪过,
quick模式下 7 秒能跑完;不要把压测脚本搬进 PR。 - 种子和样本数必须进日志。失败报告里的
-XX:StressSeed、总样本数、每轮命中数,缺一样就无法判断「这次失败是新问题还是老问题」。 - nightly 跑全量,但不阻塞 PR。全量跑得慢是正常的,它的价值是发现「PR 用例没覆盖到的交错」,失败后第二天再补最小用例。
关于 flaky:并发测试的「偶发失败」不能按普通测试的方式重跑三次就放过。正确做法是保留失败报告、记录样本数、在同一台机器上用同一命令复跑,并把复跑结果一并写进 issue。一次 FORBIDDEN 就是一次真实的执行历史,它已经证明存在一条非法交错,不管下一次能不能再撞上。
验证修复
修复的判据分两层:
第一层是可线性化论证。 把 borrow() 改成 CAS 循环后,线性化点明确落在「CAS 成功」那一瞬间;CAS 失败意味着操作没有生效,重试不会产生额外副作用。这一段论证不依赖测试工具。
第二层是测试证据。 本机 FixedStockTest 用 -v 跑了一轮:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1, 2 0 0.00% Forbidden CAS 版本不应出现超卖
0, 1 234,815,754 100.00% Acceptable 一本书只被一位会员借走,库存归零
2.35 亿个样本、0 次 FORBIDDEN、合法结果 100%。这个数字的正确读法是「在这台机器、这个负载、这个模式下没出现」,不是「绝对正确」。它的价值在于:如果哪天有人把 CAS 改回读改写,同一个测试会在几秒内重新变红。
修复后还要做一件事:把反例测试保留下来,但不要让它进 PR 门禁。StockRegression 里的 Unsafe 类按设计必然失败,它的用途是演示和教学;门禁用 CasFixed 或单独的 FixedStockTest。把「必然失败的反例」和「必须通过的正例」混在同一个 -t 选择器里,流水线会永远红着。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 最小化时把库存改成 0 | 没有操作能执行,测试永远通过 | 库存保持 1,正好允许一次成功 |
| 只留 1 个 actor | 没有交错,测不出竞态 | 至少 2 个 |
把 StressSeed 当成完整复现 | 换一台机器仍不复现 | 种子只固定 JIT,交错仍靠采样 |
| 一轮没命中就判定修复 | 把概率当确定性 | 记录样本数,多轮或换模式 |
| 反例测试放进 PR 门禁 | 流水线永远失败 | 反例只做教学,门禁用正例 |
-t 选择器写类名简写 | 匹配不到,0 个测试却退出 0 | 用全限定名或先 -l 确认 |
什么时候不要做最小化:当症状本身依赖外部系统时,比如「数据库连接池耗尽后线程池排队」。这类问题的根因不在某一段读改写,硬缩成两个线程会得到一个「通过」的假象。此时应该用 12.1 的指标和 2.3 的压测,而不是 JCStress。
小结
竞态处理可以压成一条流水线:从大现场里切出最小状态,用两个 actor 制造交错,用 quick 模式在秒级拿到命中率,用种子和样本数记录证据,最后把正例放进 CI 门禁、把全量留给 nightly。
本机的数字只说明这条流水线跑得通:两轮约 4.36 亿样本、命中率 7.65%–8.25%,修复版 2.35 亿样本、0 命中。它们不是这段代码的固有参数,换机器、换负载、换 JDK 都会变。真正可复用的,是「样本数 + 命中率 + 种子 + 退出码」这四件套——它让一次偶发失败变成一条可交接的证据。
第 8 章到此结束。下一章回到事故现场,从「过期读与无限循环」开始,看可见性问题如何在没有明显竞态的情况下让服务卡死。
阅读导航:上一节 8.2 Lincheck 的线性一致性检查 | 下一节 9.1 过期读与无限循环
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。