怎么验证并发代码
主线位置:内存序横线的另一半。本库里的内存序讲每个
Ordering为什么这么选;本页讲 crossbeam 用什么工具检查这些选择,以及为了让工具能检查,源码里多出来的那些cfg。
flowchart LR code["同一份源码"] --> t["test:14 个平台组合,--test-threads=1"] code --> loom["loom:--cfg crossbeam_loom,只跑 epoch 的 tests/loom.rs"] code --> miri["Miri:解释执行,按 crate 调参数"] code --> san["ASan / MSan / TSan:--cfg crossbeam_sanitize,只在 Linux"] code --> careful["cargo careful:-Z randomize-layout"]
普通的单元测试对并发代码帮助有限:一个错误的 Relaxed 在 x86_64 上可能永远跑不出问题(上一页「动脑筋」的答案),在 aarch64 上一百万次也许才出一次。crossbeam 的办法是「多换几种跑法」:换硬件(不同内存模型的 CPU)、换执行器(Miri、loom)、换编译器插桩(sanitizer)。本页的内容全部来自读 .github/workflows/ci.yml 和 ci/*.sh,没有在本机执行这些脚本。
CI 一览
.github/workflows/ci.yml 在 PR、推到 main 和每天 02:00(UTC)触发,共 9 个 job:
| job | 脚本 | 做什么 |
|---|---|---|
docs | 外部复用工作流 | 文档构建 |
test | ci/test.sh | 14 个组合:Rust 1.65(Linux x86_64、Linux arm64)、1.71(Windows)、stable 和 nightly(各三个系统),nightly 交叉到 i686、armv7、powerpc64le、s390x,stable 交叉到 sparc64;所有测试 --test-threads=1;nightly 上加 --all-features 并检查 benches |
features | ci/check-features.sh | cargo hack --feature-powerset;MSRV 用 --rust-version;nightly 上再为 thumbv7m-none-eabi(有 CAS)、thumbv6m-none-eabi(有原子无 CAS)、riscv32i-unknown-none-elf(没有原子)编译 no_std |
dependencies | ci/dependencies.sh | cargo tree --duplicate,cargo minimal-versions build |
codegen | ci/no_atomic.sh | 用 rustc --print all-target-specs-json 重新生成 no_atomic.rs,git diff --exit-code 确认没变 |
miri | ci/miri.sh | 见下 |
careful | ci/careful.sh | cargo careful test,-Z randomize-layout |
san | ci/san.sh | 见下 |
loom | ci/crossbeam-epoch-loom.sh | 见下 |
test 矩阵里的 arm64(ubuntu-24.04-arm)、armv7 和 powerpc64le 是关键:它们的内存模型比 x86 弱,一个本该是 Acquire 却写成 Relaxed 的读,在 x86 上被硬件兜住,在这些机器上才有机会暴露。i686 覆盖 32 位指针,s390x、sparc64 覆盖大端序。
loom:只覆盖 epoch
loom 是 tokio 组织下的模型检查器:把 std::sync::atomic 换成它的实现,在一个测试里穷举(有上限地)线程交错和弱内存序下允许的读值。要用 loom,被测代码必须通过一个切换层拿原子类型,而不是直接 use std::sync::atomic。
crossbeam 的切换层是 utils 和 epoch 里各一份的 mod primitive,crossbeam-utils/src/lib.rs:45:
#[cfg(crossbeam_loom)]
#[allow(unused_imports)]
mod primitive {
pub(crate) mod hint {
pub(crate) use loom::hint::spin_loop;
}
pub(crate) mod sync {
pub(crate) mod atomic {
pub(crate) use loom::sync::atomic::{
AtomicBool, AtomicI16, AtomicI32, AtomicI64, AtomicI8, AtomicIsize, AtomicU16,
AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering,
};
// FIXME: loom does not support compiler_fence at the moment.
// https://github.com/tokio-rs/loom/issues/117
// we use fence as a stand-in for compiler_fence for the time being.
// this may miss some races since fence is stronger than compiler_fence,
// but it's the best we can do for the time being.
pub(crate) use loom::sync::atomic::fence as compiler_fence;
}
pub(crate) use loom::sync::{Arc, Condvar, Mutex};
}
}
#[cfg(not(crossbeam_loom))]
#[allow(unused_imports)]
mod primitive {
// core::hint、core::sync::atomic、std::sync 的同名项
}
和 tokio 的 crate::loom 是同一个思路(tokio 的源码地图最后一节)。区别在覆盖面:
- 只有 epoch 和 utils 走这个切换层。 deque、channel、queue 直接
use std::sync::atomic(或core::sync::atomic),--cfg crossbeam_loom对它们不起作用,loom 也就查不到它们。 - utils 在 loom 下砍掉一部分:
AtomicCell(loom 的原子类型和底层类型内存表示不同,没法 transmute)、ShardedLock和thread模块整个不编译。剩下的Backoff、Parker、WaitGroup换成 loom 原语,但 utils 自己没有 loom 测试;它在 loom 下编译,是为了让 epoch 能用。 - loom 不支持
compiler_fence,所以用fence代替(更强,可能漏报)。load_consume在 loom 下退回Acquire。 - epoch 的默认收集器在 loom 下用
loom::lazy_static!代替OnceLock(default.rs:13-30)。
CI 只跑一个脚本,ci/crossbeam-epoch-loom.sh:
cd "$(dirname "$0")"/../crossbeam-epoch
export RUSTFLAGS="${RUSTFLAGS:-} --cfg crossbeam_loom --cfg crossbeam_sanitize"
# With MAX_PREEMPTIONS=2 the loom tests (currently) take around 11m.
# If we were to run with =3, they would take several times that,
# which is probably too costly for CI.
env LOOM_MAX_PREEMPTIONS=2 cargo test --test loom --release --features loom -- --nocapture
被测的是 crossbeam-epoch/tests/loom.rs(157 行)里的两个模型:it_works(一个线程 defer_destroy、另一个线程读)和 treiber_stack(用 epoch 实现的无锁栈,两个线程并发 push / pop)。抢占上限 2,注释说跑一次约 11 分钟,上限 3 要再翻几倍。同时打开 crossbeam_sanitize,让袋子容量从 64 降到 4,少几步就能触发封袋和回收。
deque 的 Chase-Lev 没有 loom 测试。本卷 05 deque 的实验 E5 用 loom 原子自己写了一个小模型来补这一块。
Miri:每个 crate 一组参数
ci/miri.sh 对所有 crate 都加 -Z randomize-layout(打乱结构体字段顺序,抓依赖默认布局的代码)和 -Zmiri-symbolic-alignment-check,之后按 crate 分开跑:
| crate | 额外的 MIRIFLAGS | 脚本里写的原因 |
|---|---|---|
| queue、utils | -Zmiri-disable-isolation | — |
| channel | 加 -Zmiri-ignore-leaks | 测试和文档里有不 join 的线程(Miri issue 1371) |
| epoch | 加 -Zmiri-disable-stacked-borrows、-Zmiri-ignore-leaks | crossbeam #545(Stacked Borrows)、#579(泄漏) |
| deque | 不加 disable-isolation;加 -Zmiri-disable-stacked-borrows、-Zmiri-ignore-leaks、-Zmiri-compare-exchange-weak-failure-rate=0.0、-Zmiri-preemption-rate=0 | 弱 CAS:有些顺序执行的测试(例如 Stealer::steal 的文档测试)错误地假定弱 CAS 不会伪失败;抢占率 0:「this code technically has UB and Miri catches that」 |
门面 crossbeam | -Zmiri-ignore-leaks | #579 |
deque 那一行是最直白的承认:Miri 打开抢占就会抓到 deque 的数据竞争,所以关掉抢占。那个竞争就是 Buffer::read / write 的 volatile 访问(扩容与回收)。Miri 下 epoch 的袋子也降到 4 个对象(MAX_OBJECTS 的 cfg(any(crossbeam_sanitize, miri))),x86 的 CAS-代替-fence 也关掉。
Sanitizer:两个 cfg
ci/san.sh 不是 Linux 就直接 exit 0。在 Linux 上依次:
- ASan:
-Z sanitizer=address --cfg crossbeam_sanitize,跑全部测试,再跑 epoch 的examples/sanitize.rs(多线程随机 pin、flush、swap、defer 的压力程序)。 - MSan:
-Z sanitizer=memory,-Z build-std。 - TSan:
-Z sanitizer=thread,-Z build-std,TSAN_OPTIONS=suppressions=ci/tsan。
crossbeam_sanitize:让竞争更容易出现
这个 cfg 由 RUSTFLAGS 手工传入(工作区 Cargo.toml 的 check-cfg 里登记了它和 crossbeam_loom),只改 epoch 的两处:
| 位置 | 正常 | crossbeam_sanitize(或 Miri) |
|---|---|---|
MAX_OBJECTS(每袋对象数) | 64 | 4 |
collect 每次弹几袋 | COLLECT_STEPS = 8 | usize::MAX(弹到没有过期的为止) |
注释说「Makes it more likely to trigger any potential data races」:袋子小,封袋和回收就频繁,析构和读者交错的机会多。另有几个测试在这个 cfg 下 ignore 或跳过(tick 的计时断言「due to tsan is slow」,一个不稳定的 select! 测试)。
crossbeam_sanitize_thread:TSan 不认 fence
TSan 只跟踪「原子访问」之间的同步关系,不理解独立的 fence。「Relaxed 访问 + fence」这种写法在 TSan 看来没有同步,会报一堆假阳性。crossbeam 的处理是在 TSan 下把它们改写成等价的 Acquire / Release 访问。
这个 cfg 不用手传:utils、epoch、deque 三个 crate 的 build.rs 看到 CARGO_CFG_SANITIZE 含 thread,或者目标是 x86_64-unknown-linux-gnutsan(注释说这个目标 1.96 起在 stable 上可用),就自动打开(crossbeam-epoch/build.rs)。改写的地方:
- deque 五处
push类的发布:fence(Release)+back.store(Relaxed)改成back.store(Release)(deque.rs 第 438、927、1180、1727、1937 行附近,每处变量都叫store_order)。 load_consume退回Acquire。- epoch 的
try_advance:正常是「Relaxed读全局纪元 →fence(SeqCst)→Relaxed读每个参与者 →fence(Acquire)」;TSan 下全局纪元改成Acquire读,最后那个fence(Acquire)换成「把遍历到的参与者先收进一个Vec,遍历完再逐个Acquire读一遍」。注释说分配这个Vec「很遗憾」,但不这么做,同步可能在不该发生的地方发生,造成假阴性。
#[cfg(crossbeam_sanitize_thread)]
for local in locals {
local.epoch.load(Ordering::Acquire);
}
#[cfg(not(crossbeam_sanitize_thread))]
atomic::fence(Ordering::Acquire);
这就是 08 设计取舍 里说的「可验证性的代价」:同一处同步,源码里有两种写法,靠 cfg 选。
抑制文件 ci/tsan
# Push and steal operations in crossbeam-deque may cause data races, but such
# data races are safe. If a data race happens, the value read by `steal` is
# forgotten and the steal operation is then retried.
race:crossbeam_deque*push
race:crossbeam_deque*steal
# Non-lock-free AtomicCell uses SeqLock which uses fences.
race:crossbeam_utils::atomic::atomic_cell::atomic_compare_exchange_weak
race:<crossbeam_channel::flavors::tick::Channel>::try_recv
前两条是 deque 的 volatile 读写:steal 可能读到正被主人覆盖的槽,读到的值如果后面 CAS 失败就被丢弃(MaybeUninit,不 drop)。注释称之为「safe」的数据竞争,和 Miri 那边「technically has UB」说的是同一件事。后两条是 AtomicCell 的 seqlock 路径:乐观读本身就是和写者的竞争(AtomicCell),而 tick channel 的 delivery_time 正是一个走锁的 AtomicCell<Instant>。
本卷的实验用了什么
本机是 macOS(M2 Max),ci/san.sh 在这里直接退出,TSan 跑不了。本卷的实验只用三种办法:
- release 编译、多线程重复跑的正确性检查(计数、求和、析构计数),配反向对照;
- 性能测量(中位数,注明负载);
- loom:在
exp-crossbeam里用 loom 0.7 的原子自己写小模型(E5),因为 deque 和 channel 不走 crossbeam 的切换层。
小结
- CI 的并发检查分五路:14 个平台组合的普通测试(含 armv7、powerpc64le 等弱内存序机器)、loom、Miri、三种 sanitizer、
cargo careful。 - loom 只覆盖 epoch:只有 utils 和 epoch 通过
mod primitive拿原子类型,CI 只跑tests/loom.rs的两个模型,抢占上限 2。deque、channel、queue 不在 loom 的视野里。 - Miri 按 crate 调参数;deque 关掉抢占和 Stacked Borrows,脚本直说「这段代码技术上有 UB」。
crossbeam_sanitize把 epoch 的袋子缩到 4、回收不限步数;crossbeam_sanitize_thread由build.rs自动打开,把「Relaxed + fence」改写成 TSan 认识的 Acquire / Release 访问。- TSan 抑制文件四行:deque 的 push / steal、
AtomicCell的 seqlock、tick 的try_recv。