cfg(loom) 切换层
主线位置:主线 4「loom 看到什么」的起点。loom 只看得到换成 loom 类型的那部分代码(「loom 通过」意味着什么),切换层决定了哪些代码会被换掉。
flowchart TB rf["RUSTFLAGS="--cfg loom""] --> all["整个依赖图都带上 cfg(loom),按新 flags 整图编译一遍"] all --> own["本 crate:mod sync 里 #[cfg(loom)] 用 loom::*,否则 std::*"] all --> t["tokio、bytes:cfg(all(test, loom)),只有它们自己的单元测试才换"] all --> cq["concurrent-queue、event-listener、parking 等:裸 cfg(loom) + 可选依赖,下游开 loom feature 才换"] all --> cb["crossbeam:看 cfg(crossbeam_loom),不看 cfg(loom),不换"] own --> m["loom::model(|| ...) 里的代码被检查"] cq --> m
文档推荐的写法
lib.rs:98-165 给了三步:
Cargo.toml里把 loom 放在[target.'cfg(loom)'.dependencies](或dev-dependencies)下,平时不编译它。- 建一个
mod sync,每个类型写两行#[cfg(loom)] pub(crate) use loom::...;/#[cfg(not(loom))] pub(crate) use std::...;,库里其他地方只use crate::sync::...。 - loom 的 API 和 std 不一致的地方,给 std 那一侧补同名方法。文档的例子是
UnsafeCell:
#![cfg(not(loom))]
#[derive(Debug)]
pub(crate) struct UnsafeCell<T>(std::cell::UnsafeCell<T>);
impl<T> UnsafeCell<T> {
pub(crate) fn new(data: T) -> UnsafeCell<T> {
UnsafeCell(std::cell::UnsafeCell::new(data))
}
pub(crate) fn with<R>(&self, f: impl FnOnce(*const T) -> R) -> R {
f(self.0.get())
}
pub(crate) fn with_mut<R>(&self, f: impl FnOnce(*mut T) -> R) -> R {
f(self.0.get())
}
}
跑测试用 RUSTFLAGS="--cfg loom" cargo test --release(179-190 行)。
loom 的类型还要求模型必须确定(100-103 行):随机数、系统调用这类不确定来源,loom 建模不了,必须在切换层里 mock 掉。下面 tokio 的例子里能看到具体做法。
API 差别清单
被测代码在两种 cfg 下都要编译,所以切换层要抹平下面这些差别。每一项都给出 loom 侧的原因。
| std | loom 0.7.2 | 原因 |
|---|---|---|
AtomicUsize::get_mut(&mut self) | 没有;用 with_mut(&mut self, f) | loom 的原子只是一个对象下标,值存在 Execution 的写历史里,没法交出一个随 &mut self 存活的 &mut usize;with_mut 只在闭包期间借出,并在开始和结束时做竞争检查(UnsafeCell 与原子的非同步访问) |
const fn AtomicUsize::new | 普通 fn new;不能用在 static / const 里 | 构造时要往当前 Execution 的对象仓库里插一项,model 外调用就 panic「cannot access Loom execution state from outside a Loom model…」(协程调度器) |
| — | unsafe fn unsync_load(&self) | 非原子地读,loom 检查它与并发写的竞争;std 侧要自己补 |
UnsafeCell::get 返回裸指针 | with / with_mut 闭包,或 get / get_mut 返回守卫 | 每次访问都要记录读写时钟 |
Mutex::lock 返回 LockResult | 同签名,恒 Ok | 不毒化(Mutex 与 RwLock) |
Arc<[T]>、Arc<dyn Trait> 由强制转换得到 | 只能 Arc::from_std | CoerceUnsized 不稳定(Arc、mpsc 与 future) |
compiler_fence | 没有 | sync::atomic 只导出 fence;crossbeam 用 fence 代替(更强,可能漏报) |
thread::scope、OnceLock、Weak、sync_channel | 没有 | 未实现 |
Barrier、recv_timeout | 有,调用即 panic | 只为能编译 |
第二条最常见:任何 static FOO: AtomicUsize = AtomicUsize::new(0); 都要给 loom 另写一份。实测(exp-loom/cfg-check):同一行在默认编译下正常,--cfg loom 时报
error[E0015]: cannot call non-const associated function `AtomicUsize::new` in statics
这就是 bytes 的 Bytes::new 在 loom 下要另写一份非 const 版本(clone 与提升)、concurrent-queue 2.5.0 用 const_fn!(const_if: #[cfg(not(loom))]; ...) 宏在 loom 下去掉 const 的原因。
三个真实的切换层
tokio:cfg(all(test, loom))
tokio 的切换层在 src/loom/mod.rs,源码地图 最后一节已经讲过结构。这里看 loom 侧的 mocked.rs(78 行),它回答了「切换层除了 use 还要写什么」:
- 把 API 差别抹平到 tokio 想要的形状:tokio 的 std 侧
Mutex::lock不返回LockResult(内部不关心毒化),于是 loom 侧包一层Mutex<T>(loom::sync::Mutex<T>),lock()里.unwrap();RwLock同理(3-61 行)。 - 去掉不确定性:
rand::seed()恒为 1,sys::num_cpus()恒为 2(63-73 行)。随机种子不固定,每轮重放时 tokio 内部的选择就会变,Path 重放会对不上。 - std 侧反过来补 loom 的 API:
std/unsafe_cell.rs就是上面文档例子的样子,只多了const fn new和#[inline(always)]。
cfg(all(test, loom)) 里的 test 意味着:只有 tokio 自己的单元测试(cargo test 编译 tokio 本身时)才换成 loom。下游 crate 用 --cfg loom 编译时,tokio 作为依赖不带 cfg(test),内部仍是 std 类型。所以 tokio 源码里看到 loom::sync::Mutex,不代表在跑 loom(tokio 还是 std 的 Mutex),下游也没法用 loom 检查「自己的代码 + tokio 内部」的组合。tokio 的 Cargo.toml 把 loom 放在 [target.'cfg(loom)'.dev-dependencies],开 checkpoint、futures 两个 feature。
bytes:同样只在自己的测试里
bytes 1.12.1 的 src/loom.rs 33 行,同样是 cfg(all(test, loom)) 二选一,只换 AtomicPtr、AtomicUsize、Ordering。std 侧补了一个 AtomicMut trait,给 AtomicPtr 加上 with_mut,loom 侧这个 trait 是空的(loom 原子自带 with_mut)。细节见 bytes 的源码地图。
crossbeam:自己的 cfg 名字
crossbeam 不用 cfg(loom),而用 cfg(crossbeam_loom),再配一个 loom feature;loom 是 [target.'cfg(crossbeam_loom)'.dependencies] 下的可选依赖(crossbeam-epoch 0.9.21 里改名为 loom-crate)。这样设计的结果正好和 tokio 相反:下游可以开 --cfg crossbeam_loom 加 features = ["loom"],让作为依赖的 crossbeam-epoch 也换成 loom 类型。crossbeam 卷的 E5 就是这么跑发布版 epoch 代码的(自定义收集器)。切换层原文和 compiler_fence 用 fence 代替的取舍见 怎么验证并发代码。
对照
| 条件 | loom 依赖 | 下游能否让它换成 loom | |
|---|---|---|---|
| tokio 1.54.1 | cfg(all(test, loom)) | dev-dependency | 不能 |
| bytes 1.12.1 | cfg(all(test, loom)) | dev-dependency | 不能 |
| crossbeam-epoch 0.9.21 | cfg(crossbeam_loom) | 普通可选依赖 + loom feature | 能,用 crossbeam 自己的 cfg 名 |
| concurrent-queue 2.5.0、event-listener 5.4.x、parking 2.2.1、async-lock 3.4.2 | cfg(loom) | [target.'cfg(loom)'.dependencies],optional = true | 能,要同时开 --cfg loom 和 loom feature;只开 --cfg loom 时编译失败 |
最后一行是 registry 里 [target."cfg(loom)".dependencies] 的 crate(按 Cargo.toml 搜到的),四个包里 loom 都是 optional = true。代码按裸 cfg(loom) 切换,依赖却要 feature 才拉进来,所以下游只开 --cfg loom、不开它们的 loom feature 时,它们编译失败:本站锁定的 concurrent-queue 2.5.0 报 21 个错误(exp-loom/site-deps,见在本站中)。两个都开之后才换成 loom 类型;这时只要在 loom::model 外面用到它们(比如测试 setup 里建一个队列),就会 panic「cannot access Loom execution state from outside a Loom model…」。
--cfg loom 改变整个依赖图
RUSTFLAGS 对所有 crate 生效:
- 实测(
exp-loom/usage,loom 是普通依赖,cargo 随 rustc 1.99.1):在已有--cfg loom产物的 target 目录里不带 flags 构建,依赖图里 37 个 crate 全部重新编译;接着再带上--cfg loom构建,0 个重编。两套产物按 flags 分开存在同一个 target 目录里,代价是每换一种 flags 整图编一遍、目录体积成倍增长。本卷的 loom 实验各用单独的CARGO_TARGET_DIR,不和其他实验的产物混在一起(crossbeam 卷 自定义收集器 的 E5 也是这样做的)。 - 依赖里凡是写裸
cfg(loom)的都会被切换,见上表。
unexpected_cfgs 与 check-cfg
Rust 1.80 起,编译器检查 cfg 名字是否已知,loom 不是内置名字。实测(exp-loom/cfg-check,rustc 1.99.1),src/lib.rs 里写 #[cfg(loom)] 而 Cargo.toml 不声明,默认构建就有两条警告(cfg(loom) 与 cfg(not(loom)) 各一条):
warning: unexpected `cfg` condition name: `loom`
--> src/lib.rs:1:7
|
1 | #[cfg(loom)]
| ^^^^
|
= help: expected names are: `docsrs`, `feature`, and `test` and 34 more
= help: consider using a Cargo feature instead
= help: or consider adding in `Cargo.toml` the `check-cfg` lint config for the lint:
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] }
Cargo.toml 里 [target.'cfg(loom)'.dependencies] 这种写法本身不触发警告。按提示在 Cargo.toml 加上
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] }
警告数降到 0。bytes 1.12.1 与 tokio 1.54.1 发布的 Cargo.toml 里都有这一段(tokio 的 check-cfg 列表里还有 tokio_unstable 等);crossbeam 登记的是 cfg(crossbeam_loom)。
小结
- 推荐写法:loom 放在
cfg(loom)依赖下,mod sync两套use,std 侧补 loom 独有的 API(with/with_mut/unsync_load)。 - 主要差别:原子没有
get_mut、不能const构造(static里写AtomicUsize::new在 loom 下是 E0015)、锁恒Ok、Arc不能 unsize、没有compiler_fence。 - 不确定性要在切换层里去掉:tokio 把随机种子固定为 1、CPU 数固定为 2。
- tokio、bytes 用
cfg(all(test, loom)):只有它们自己的单元测试换,下游开--cfg loom不影响它们;crossbeam 用crossbeam_loom+ feature,下游可以显式打开;concurrent-queue 等用裸cfg(loom)加可选依赖,下游只开--cfg loom会编译失败,再开loomfeature 才换。 --cfg loom对整个依赖图生效,换 flags 要整图编一遍;Rust 1.80 起要在[lints.rust]里登记check-cfg = ["cfg(loom)"],否则每处#[cfg(loom)]一条警告。