并发缺陷是所有缺陷里最难测的一类,因为它们不是"在某条执行路径上必现",而是"在某个极其罕见的线程交错下才现"。 一段代码可能在单核机器上、在开发者的笔记本上、在 99.99% 的运行里都正确,却会在生产环境高并发下每月崩溃一次。更糟的是,竞态往往是"读到旧值"“丢失更新"“死锁"这类静默故障——没有异常、没有日志,只有偶尔错乱的数据。本文要回答的是:如何用数据竞争检测器、确定性调度、模型检查、压力扰动这四类手段,把这类"靠运气"的缺陷变成"可复现、可回归"的缺陷。
并发测试的核心矛盾是:要触发竞态,就必须制造交错;要保证测试可重复,就必须消除随机。 解决这个矛盾的路径有两条——要么用检测器(如 race detector)在真实执行中自动发现竞争,要么用确定性调度器(如 Loom)穷举交错。两者互补,构成本文的主线。
一、并发缺陷为什么难测
1.1 三类并发缺陷
| 类别 | 定义 | 典型表现 | 检测手段 |
|---|---|---|---|
| 数据竞争(Data Race) | 两个线程并发访问同一内存,至少一个写,且无同步 | 读到撕裂的值、结果不确定 | race detector / TSan |
| 原子性违反(Atomicity Violation) | 本该原子的复合操作被交错打断 | check-then-act 竞态、丢失更新 | 模型检查 / 压力测试 |
| 顺序违反(Order Violation) | 依赖两个事件的顺序,但顺序未强制 | 初始化未完成就用、通知先于注册 | 模型检查 / 事件注入 |
1.2 不确定性的来源
同一段并发代码,每次执行可能走不同的交错:
· CPU 调度:线程何时被抢占
· 内存可见性:写何时对其他核可见(缓存一致性)
· 编译器/CPU 重排:指令顺序被优化
· 系统负载:高负载时交错更密集,更易暴露
结论:并发缺陷的"复现"本质上是"控制随机性"。
单核机器几乎测不出,多核 + 高负载才容易触发。
⚠️ “在我机器上跑得好好的"在并发场景里毫无意义。开发机通常是高主频少核,生产是高并发多核,两者的交错分布完全不同。并发测试必须在"接近生产的核数 + 高负载"下进行,或者用检测器把交错空间显式覆盖。
二、数据竞争检测
2.1 Go race detector
Go 内置的 race detector 基于 ThreadSanitizer 算法,是最省心的数据竞争检测手段:
# 编译时启用,运行时自动检测
go test -race ./...
go run -race main.go
# 输出示例:
# ==================
# WARNING: DATA RACE
# Write at 0x00c0000b4018 by goroutine 7:
# main.(*Counter).Inc()
# counter.go:12 +0x38
# Previous read at 0x00c0000b4018 by goroutine 8:
# main.(*Counter).Get()
# counter.go:18 +0x44
# ==================
触发它的测试只需要让读写真正并发:
func TestCounterConcurrent(t *testing.T) {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() { defer wg.Done(); c.Inc() }()
go func() { defer wg.Done(); _ = c.Get() }()
}
wg.Wait()
}
// go test -race 会捕获 Inc 与 Get 之间的无同步竞争
// 修复:用原子操作或互斥锁
type Counter struct {
mu sync.Mutex
n int64
}
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
func (c *Counter) Get() int64 { c.mu.Lock(); defer c.mu.Unlock(); return c.n }
ℹ️ race detector 的检测原理是"happens-before 分析”:它为每次内存访问维护向量时钟,若两个访问之间没有 happens-before 关系且至少一个是写,就报告竞争。它的局限是只报告"实际发生"的竞争——没被触发的交错不会被发现,且运行开销约 5
10 倍、内存 510 倍。因此它适合放进 CI 的独立 job,而不是每次本地运行。
2.2 C/C++/Rust 的 ThreadSanitizer
# 编译时插桩
clang++ -fsanitize=thread -g -O1 counter.cpp -o counter
./counter
# CMake 集成
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=thread -g -O1")
# Rust:TSan 需要 nightly 与 -Zsanitizer
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-Zsanitizer=thread"]
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test --target x86_64-unknown-linux-gnu
Rust 更推荐用类型系统在编译期消除数据竞争——Send/Sync trait 让大部分竞争根本无法编译通过。TSan 用于检测 unsafe 代码或 FFI 边界引入的竞争。
2.3 JVM 生态
# Java:用 jcstress 做并发正确性测试(OpenJDK 官方工具)
mvn test -Dtest=CounterStressTest
@JCStressTest
@Outcome(id = "100", expect = Expect.ACCEPTABLE, desc = "正确累加")
@Outcome(expect = Expect.FORBIDDEN, desc = "丢失更新")
@State
public class CounterStress {
private int n;
@Actor public void actor1() { for (int i = 0; i < 50; i++) n++; }
@Actor public void actor2() { for (int i = 0; i < 50; i++) n++; }
}
jcstress 通过大量 JVM 变体(不同的 JIT 优化、内存屏障组合)穷举执行,暴露普通测试永远触发不了的竞争。
三、确定性复现
3.1 从"偶发"到"必现”
检测器只能发现竞争,但定位根因需要复现。复现的关键是把随机交错变成可控交错:
复现三要素:
1. 固定种子:随机 sleep/调度都从同一种子派生
2. 记录交错:把线程切换点记录下来,可回放
3. 注入延迟:在关键点插入 sleep 放大竞争窗口
3.2 手动调度扰动
最简单的复现手法是在竞争窗口里注入随机延迟,把"窄窗口"撑大:
// 在被测代码里留出可注入的 hook
var randDelayHook func()
func transfer(from, to *Account, amount int) {
if randDelayHook != nil { randDelayHook() } // 注入点:读取与写入之间
if from.balance >= amount {
from.balance -= amount
to.balance += amount
}
}
func TestTransferRace(t *testing.T) {
rand := mrand.New(mrand.NewSource(42)) // 固定种子,可复现
randDelayHook = func() {
if rand.Intn(2) == 0 { time.Sleep(time.Duration(rand.Intn(100)) * time.Microsecond) }
}
defer func() { randDelayHook = nil }()
acc := &Account{balance: 100}
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); transfer(acc, &Account{}, 80) }()
}
wg.Wait()
// 若发生丢失更新/双花,最终余额会异常
if acc.balance < 0 {
t.Fatalf("竞态触发:余额为负 %d", acc.balance)
}
}
⚠️ 注入点的位置是复现的关键。在"读取共享状态"和"写入共享状态"之间插入延迟,几乎必然放大 check-then-act 竞态。这个 hook 在生产代码里用条件编译或接口注入,避免污染热路径。
3.3 确定性调度器:Loom
Rust 的 Loom 是最成熟的"确定性并发测试"库——它不跑真实线程,而是在单线程里穷举所有可能的线程交错:
#[cfg(loom)]
use loom::sync::atomic::{AtomicUsize, Ordering};
#[cfg(not(loom))]
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn concurrent_increment() {
loom::model(|| {
let counter = Arc::new(AtomicUsize::new(0));
let c1 = counter.clone();
let c2 = counter.clone();
let t1 = loom::thread::spawn(move || { c1.fetch_add(1, Ordering::SeqCst); });
let t2 = loom::thread::spawn(move || { c2.fetch_add(1, Ordering::SeqCst); });
t1.join().unwrap();
t2.join().unwrap();
assert_eq!(counter.load(Ordering::SeqCst), 2);
});
}
# Cargo.toml
[dev-dependencies]
loom = "0.7"
[target.'cfg(loom)'.dev-dependencies]
loom = "0.7"
# 运行:用 loom 配置替换标准库
# RUSTFLAGS="--cfg loom" cargo test --release
// 把 Ordering::Relaxed 改成 SeqCst 之前,Loom 会报出失败的交错:
// assertion failed: left == right
// left: 1, right: 2
// 并给出导致该结果的具体线程切换序列
ℹ️ Loom 的价值在于"穷举"而非"抽样”:普通压力测试是随机抽样交错空间,Loom 是系统遍历所有交错(受状态空间剪枝限制)。它能在几秒内找到压力测试跑一年也碰不到的交错。代价是状态空间爆炸,所以 Loom 只适合测试小规模的同步原语和数据结构,不适合整个业务服务。
3.4 事件驱动的确定性测试
对于依赖"事件顺序"的代码,可以用手动屏障(barrier)精确控制交错:
func TestOrderViolation(t *testing.T) {
aReached, bReached := make(chan struct{}), make(chan struct{})
var shared int
go func() {
shared = 1
close(aReached) // 通知:我写完了
<-bReached
_ = shared
}()
go func() {
<-aReached
shared = 2 // 与上面的读交错
close(bReached)
}()
// 用通道精确编排两个 goroutine 的交错顺序
}
四、压力测试与调度扰动
4.1 压力测试:用规模放大概率
竞态的概率随并发度与迭代次数上升,压力测试就是"用数量换概率":
# Go:-count 重复运行 + 高并发
go test -race -count=100 -cpu=1,2,4,8 -run TestConcurrent ./...
# JVM:多轮 + 不同 JIT 状态
for i in $(seq 1 50); do mvn -q test -Dtest=ConcurrentTest || break; done
# 用 stress-ng 给系统加压,改变调度时序
stress-ng --cpu $(nproc) --timeout 60s &
go test -race -count=200 ./internal/concurrent/...
4.2 随机 sleep 注入框架
把 sleep 注入做成可复用的测试工具:
import random, threading, time, functools
class ChaosScheduler:
def __init__(self, seed=0):
self.rand = random.Random(seed)
self.enabled = True
def maybe_pause(self):
if self.enabled and self.rand.random() < 0.3: # 30% 概率暂停
time.sleep(self.rand.uniform(0, 0.005))
def patch(self, cls, methods):
for m in methods:
orig = getattr(cls, m)
@functools.wraps(orig)
def wrapper(*a, _orig=orig, **kw):
self.maybe_pause()
return _orig(*a, **kw)
setattr(cls, m, wrapper)
def test_account_transfer_race():
chaos = ChaosScheduler(seed=12345)
chaos.patch(Account, ["balance", "withdraw", "deposit"])
# 固定 seed 后,失败可复现:改 seed 直到复现,再保留该 seed 作为回归用例
for _ in range(1000):
run_transfer_scenario()
4.3 模型检查:TLA+ 与 JPF
对于协议级、状态机级的并发正确性,用模型检查做"形式化验证":
---- MODULE Transfer ----
VARIABLES balance, pending
Init == balance = 100 /\ pending = {}
Transfer(amt) ==
/\ balance >= amt
/\ balance' = balance - amt
/\ pending' = pending \cup {amt}
Invariant == balance >= 0 \* 不变式:余额永不为负
====
# TLC 模型检查器穷举所有状态,验证 Invariant 在所有可达状态下成立
java -cp tla2tools.jar tlc2.TLC Transfer.tla
ℹ️ 模型检查与压力测试的分工:模型检查(TLA+、JPF、Loom)在小状态空间上给出"证明级"保证,适合锁协议、共识算法、状态机;压力测试在大状态空间上给出"概率级"信心,适合业务代码。不要把模型检查当压力测试用,反之亦然。
五、死锁与活锁检测
5.1 死锁检测
// 死锁的最小复现:两个锁的获取顺序相反
func TestDeadlock(t *testing.T) {
var mu1, mu2 sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); mu1.Lock(); time.Sleep(time.Millisecond); mu2.Lock(); mu2.Unlock(); mu1.Unlock() }()
go func() { defer wg.Done(); mu2.Lock(); time.Sleep(time.Millisecond); mu1.Lock(); mu1.Unlock(); mu2.Unlock() }()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("检测到死锁:2s 内未完成")
}
}
# 运行时死锁检测:Go 在全部 goroutine 阻塞时会 panic
# fatal error: all goroutines are asleep - deadlock!
# JVM:用 jstack 抓线程转储,查找 "Found one Java-level deadlock"
jstack $(pgrep -f MyApp) | grep -A20 "deadlock"
5.2 活锁与饥饿
活锁(livelock)比死锁更隐蔽——线程还在跑,但没人推进:
def test_no_starvation_under_contention():
import threading, time
served = []
lock = threading.Lock()
def worker(wid):
for _ in range(100):
with lock:
served.append(wid)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
start = time.time()
for t in threads: t.start()
for t in threads: t.join(timeout=10)
assert all(not t.is_alive() for t in threads), "存在线程饥饿"
assert len(served) == 1000
⚠️ 公平性是可测的性质:如果用非公平锁,某个线程可能长期抢不到锁(饥饿)。测试可以断言"在高竞争下,所有线程都能在超时前完成"——这是活锁/饥饿的可测代理指标。
六、CI 中的稳定性
6.1 种子复现纪律
jobs:
race-test:
runs-on: ubuntu-latest # 核数影响交错,固定 runner 规格
steps:
- run: go test -race -count=50 -cpu=4 ./...
- name: Repeat to catch flaky races
run: |
for i in $(seq 1 10); do
go test -race -run TestConcurrent ./... || exit 1
done
种子管理规则:
· 所有随机 sleep/调度都从显式 seed 派生
· 失败时打印 seed,把它固化为回归用例
· 回归用例用固定 seed + 增大迭代次数,确保稳定触发
6.2 与 flaky 测试治理的衔接
并发测试天然 flaky,治理策略和普通 flaky 测试一致——但要区分"真并发缺陷"与"测试自身不稳定":
判断流程:
1. 失败是否可复现?(换 seed 是否稳定触发)
· 可复现 → 真实并发缺陷,修复生产代码
· 不可复现 → 可能是测试自身问题(共享状态、顺序依赖)
2. 测试是否隔离?(每个用例独立的状态、独立的进程)
· 不隔离 → 修测试(用 t.Parallel 时尤其注意共享变量)
· 隔离 → 继续排查生产代码
七、常见陷阱
| 陷阱 | 现象 | 对策 |
|---|---|---|
| 单核跑并发测试 | 永远测不出竞争 | 多核 + 高负载 + -cpu 多值 |
| race detector 当常规测试 | CI 慢 5~10 倍 | 独立 job,仅关键包启用 |
| 随机无种子 | 失败无法复现 | 所有随机从显式 seed 派生 |
| 压力测试无注入点 | 窗口太窄,触发不了 | 在读写之间注入延迟 |
| 用模型检查测业务代码 | 状态空间爆炸 | 模型检查只用于原语/协议 |
| 死锁测试无超时 | CI 挂死 | select + time.After 兜底 |
| 忽略活锁/饥饿 | 系统变慢但"没挂" | 公平性/超时断言 |
八、总结
并发竞态测试的核心,是承认"随机交错"是敌人,然后用四种武器分别应对:race detector / TSan 在真实执行中自动发现数据竞争;确定性调度(Loom、屏障、种子化注入) 把偶发变成必现;压力测试与调度扰动 用规模放大触发概率;模型检查(TLA+、JPF) 在小状态空间上给出证明级保证。延伸阅读可参考 https://plumephp.com/parallel-flaky-tests/ 了解并行与 flaky 测试的治理框架,https://plumephp.com/property-based-testing/ 了解如何用属性断言表达"不变式"以替代穷举用例,https://plumephp.com/unit-testing-deep-dive/ 了解如何把并发测试组织进单元测试体系。一句话收尾:并发缺陷不是"测不出来",而是"没有用对工具去测"——把检测、复现、扰动、验证四件事各就各位,那些"每月崩一次"的幽灵才终于能被关进回归用例里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。