Rust in Everything
基于 loom 0.7.2

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 给了三步:

  1. Cargo.toml 里把 loom 放在 [target.'cfg(loom)'.dependencies](或 dev-dependencies)下,平时不编译它。
  2. 建一个 mod sync,每个类型写两行 #[cfg(loom)] pub(crate) use loom::...; / #[cfg(not(loom))] pub(crate) use std::...;,库里其他地方只 use crate::sync::...。
  3. 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 侧的原因。

stdloom 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_stdCoerceUnsized 不稳定(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.1cfg(all(test, loom))dev-dependency不能
bytes 1.12.1cfg(all(test, loom))dev-dependency不能
crossbeam-epoch 0.9.21cfg(crossbeam_loom)普通可选依赖 + loom feature能,用 crossbeam 自己的 cfg 名
concurrent-queue 2.5.0、event-listener 5.4.x、parking 2.2.1、async-lock 3.4.2cfg(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 会编译失败,再开 loom feature 才换。
  • --cfg loom 对整个依赖图生效,换 flags 要整图编一遍;Rust 1.80 起要在 [lints.rust] 里登记 check-cfg = ["cfg(loom)"],否则每处 #[cfg(loom)] 一条警告。

💬 关联讨论

发起讨论
加载中...