Rust in Everything
基于 crossbeam 0.8.5

怎么验证并发代码

主线位置:内存序横线的另一半。本库里的内存序讲每个 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外部复用工作流文档构建
testci/test.sh14 个组合: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
featuresci/check-features.shcargo hack --feature-powerset;MSRV 用 --rust-version;nightly 上再为 thumbv7m-none-eabi(有 CAS)、thumbv6m-none-eabi(有原子无 CAS)、riscv32i-unknown-none-elf(没有原子)编译 no_std
dependenciesci/dependencies.shcargo tree --duplicate,cargo minimal-versions build
codegenci/no_atomic.sh用 rustc --print all-target-specs-json 重新生成 no_atomic.rs,git diff --exit-code 确认没变
mirici/miri.sh见下
carefulci/careful.shcargo careful test,-Z randomize-layout
sanci/san.sh见下
loomci/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-leakscrossbeam #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 上依次:

  1. ASan:-Z sanitizer=address --cfg crossbeam_sanitize,跑全部测试,再跑 epoch 的 examples/sanitize.rs(多线程随机 pin、flush、swap、defer 的压力程序)。
  2. MSan:-Z sanitizer=memory,-Z build-std。
  3. 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(每袋对象数)644
collect 每次弹几袋COLLECT_STEPS = 8usize::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。

💬 关联讨论

发起讨论
加载中...