并发竞态测试:数据竞争检测、确定性复现、TSan/Loom 与调度扰动

系统讲解并发竞态测试的工程化实践:数据竞争与原子性违反、顺序违反三类并发缺陷的识别、Go race detector 与 ThreadSanitizer 的检测原理与局限、确定性复现与调度扰动注入、Loom/Coyote 等模型检查工具、压力测试与随机 sleep 注入、死锁与活锁检测,以及 CI 中的稳定性与种子复现治理。

并发缺陷是所有缺陷里最难测的一类,因为它们不是"在某条执行路径上必现",而是"在某个极其罕见的线程交错下才现"。 一段代码可能在单核机器上、在开发者的笔记本上、在 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 关系且至少一个是写,就报告竞争。它的局限是只报告"实际发生"的竞争——没被触发的交错不会被发现,且运行开销约 510 倍、内存 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/ 了解如何把并发测试组织进单元测试体系。一句话收尾:并发缺陷不是"测不出来",而是"没有用对工具去测"——把检测、复现、扰动、验证四件事各就各位,那些"每月崩一次"的幽灵才终于能被关进回归用例里。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「testing」更多文章

  1. 国际化与本地化测试:文案抽取、复数与性别规则、RTL 布局、时区与伪本地化
  2. 智能合约测试:Foundry 单元与集成、Fork 主网、模糊与不变量、Gas 与升级验证
  3. 实时通信与 WebSocket 测试:连接生命周期、消息时序、断线重连与并发压测