Rust in Everything
基于 loom 0.7.2

Path:路径与深度优先

主线位置:主线 1 的记忆。loom 不保存程序状态的快照,只保存「每个分支点选了什么」。每轮从头重放闭包,已记录的分支按记录走,走到记录的尽头再新建分支;一轮结束后 step 把最后一个还有余地的分支换成下一个选项、截掉它后面的记录。整个探索就是这棵分支树上的深度优先遍历。

flowchart TB
  start["新一轮:pos = 0"] --> br{"遇到分支点"}
  br -->|"pos < branches.len()(重放)"| rep["按 branches[pos] 的记录选;类型对不上 → panic"]
  br -->|"pos == branches.len()(走到头)"| new["新建分支;默认选第一个选项"]
  rep --> inc["pos += 1"]
  new --> inc
  inc --> br
  inc -.->|一轮结束| step["step:从末尾往前,找第一个还有选项的分支,换成下一个选项,截掉它之后的记录"]
  step -->|找到| start
  step -->|找不到| fin["结束"]

三种分支

rt/path.rs:6-107:

pub(crate) struct Path {
    preemption_bound: Option<u8>,
    pos: usize,
    branches: object::Store<Entry>,
    exploring: bool,
    skipping: bool,
    exploring_on_start: bool,
}

pub(crate) struct Schedule {
    preemptions: u8,
    initial_active: Option<u8>,
    threads: [Thread; MAX_THREADS],
    prev: Option<object::Ref<Schedule>>,
    exploring: bool,
}

pub(crate) struct Load {
    values: [u8; MAX_ATOMIC_HISTORY],
    pos: u8,
    len: u8,
    exploring: bool,
}

pub(crate) struct Spurious {
    spur: bool,
    exploring: bool,
}
分支在哪里产生选项是什么选项从哪来
Schedule每次 Execution::schedule():每个 loom 操作之前、park、yield_now、线程结束下一步让哪个线程跑;threads[i] 记每个线程在这个分支上的状态新建时只有「默认线程」是 Active;其他线程要等 DPOR 的 backtrack 把它从 Skip 改成 Pending,才会成为选项(DPOR)
Load每次原子 load(以及 RMW)读哪一次写:values 是候选写在环形数组里的下标,最多 7 个新建时由 match_load_to_stores 一次算好全部候选(load 的候选)
SpuriousNotify::wait 里 might_spur() 为真时这次等待是否伪唤醒:先 false,后 true固定两个

Schedule.threads 的六种状态(path.rs:87-107):

状态含义
Disabled这个线程此刻不能跑(阻塞、已结束或还不存在)
Skip能跑,但目前不打算在这里试它
Yield刚 yield_now 过
Pending能跑,已被登记「以后要在这里试它」
Active这一轮在这里选中的线程
Visited之前某一轮在这里选过它

一个 Schedule 的一生就是 Skip → Pending → Active → Visited 这几步,每一步在哪里发生,下文和 DPOR 一章分别讲。preemptions、initial_active、prev 三个字段只为抢占计数服务,见 抢占上限与剪枝;三个 exploring 与 skipping 为 explore() / stop_exploring() / skip_branch() 服务,同一章讲。

重放:pos 与 is_traversed

Path.pos 是这一轮已经走过几个分支。step 把它归零,于是每轮都从第 0 个分支开始。is_traversed 判断「记录用完了没有」:

pub(super) fn is_traversed(&self) -> bool {
    self.pos == self.branches.len()
}

三种分支的取法都是同一个模式,以 branch_thread 为例(省略新建分支的细节):

pub(super) fn branch_thread(
    &mut self,
    execution_id: execution::Id,
    seed: impl ExactSizeIterator<Item = Thread>,
) -> Option<thread::Id> {
    if self.is_traversed() {
        assert_path_len!(self.branches);

        // 用 seed 新建一个 Schedule:调用方算好的「默认线程」是 Active,
        // 其余按能不能跑分成 Skip / Yield / Disabled
        // …
    }

    let schedule = object::Ref::from_usize(self.pos)
        .downcast::<Schedule>(&self.branches)
        .expect("Reached unexpected exploration state. Is the model fully deterministic?")
        .get(&self.branches);

    self.pos += 1;

    schedule
        .threads
        .iter()
        .enumerate()
        .find(|&(_, th)| th.is_active())
        .map(|(i, _)| thread::Id::new(execution_id, i))
}
  • 记录没用完:branches[pos] 一定得是 Schedule,返回其中 Active 的那个线程,调用方传进来的 seed 被忽略。
  • 记录用完了:用 seed 新建一个分支,再按同样的方式读它。

Load 的 push_load 由调用方在 is_traversed() 时单独调用(原子 load 先算候选再 push),branch_load 返回 values[pos]。Spurious 的 branch_spurious 新建时 spur: false。

模型必须是确定的

三处 downcast 都 .expect("Reached unexpected exploration state. Is the model fully deterministic?")(path.rs:211、232、323)。重放靠的是「同样的分支选择 → 同样的执行」:第 k 个分支点在这一轮里必须和上一轮是同一种分支。如果闭包的行为取决于 loom 看不见的东西,同一个位置上这一轮是 Schedule、上一轮是 Load,就在这里 panic。

实测(exp-loom/runtime/tests/nondet.rs):用一个 std 的 static 计数器数「第几轮」,只在第 0 轮多做一次原子 load:

let round = ROUND.fetch_add(1, SeqCst);   // std 原子,跨轮保留
let a: &'static AtomicUsize = Box::leak(Box::new(AtomicUsize::new(0)));
if round == 0 {
  a.load(Relaxed);                         // 只有第 0 轮多一个 Schedule + 一个 Load
}
let t = thread::spawn(move || a.store(1, Relaxed));
a.store(2, Relaxed);
t.join().unwrap();

第 0 轮正常结束,第 1 轮重放时在 path.rs:323 panic:

Reached unexpected exploration state. Is the model fully deterministic?

第 1 轮在第 1 个位置上要的是 Schedule,记录里那里是第 0 轮那次 load 的 Load。报错只说「不确定」,不说哪个位置、哪种分支。常见来源是跨轮保留的全局状态(std 的 static、OnceLock)、HashMap 的迭代顺序、时间、随机数,以及「分支类型对得上、内容对不上」这种更隐蔽的情况:重放时 Load 不重新计算候选,直接用记录里的写下标,如果这一轮的写历史和上一轮不同,读到的是另一次写,不会 panic。写模型时的检查办法见 写一个好模型。

step:深度优先的回溯

一轮结束、check_for_leaks 之后,Path::step:

pub(super) fn step(&mut self) -> bool {
    self.pos = 0;
    self.exploring = self.exploring_on_start;
    self.skipping = false;

    for last in (0..self.branches.len()).rev() {
        let last = object::Ref::from_usize(last);

        // Remove all objects that were created **after** this branch
        self.branches.truncate(last);

        if let Some(schedule_ref) = last.downcast::<Schedule>(&self.branches) {
            let schedule = schedule_ref.get_mut(&mut self.branches);

            if !schedule.exploring {
                continue;
            }

            // Transition the active thread to visited.
            if let Some(thread) = schedule.threads.iter_mut().find(|th| th.is_active()) {
                *thread = Thread::Visited;
            }

            // Find a pending thread and transition it to active
            let rem = schedule
                .threads
                .iter_mut()
                .find(|th| th.is_pending())
                .map(|th| {
                    *th = Thread::Active;
                })
                .is_some();

            if rem {
                return true;
            }
        } else if let Some(load_ref) = last.downcast::<Load>(&self.branches) {
            let load = load_ref.get_mut(&mut self.branches);

            if !load.exploring {
                continue;
            }

            load.pos += 1;

            if load.pos < load.len {
                return true;
            }
        } else if let Some(spurious_ref) = last.downcast::<Spurious>(&self.branches) {
            let spurious = spurious_ref.get_mut(&mut self.branches);

            if !spurious.exploring {
                continue;
            }

            if !spurious.spur {
                spurious.spur = true;
                return true;
            }
        } else {
            unreachable!();
        }
    }

    false
}

从最后一个分支往前,每看一个先把它后面的全部截掉(truncate(last) 保留下标 0..=last),然后:

分支还有选项的条件换成什么
Schedule有 Pending 线程原来的 Active 改 Visited,第一个 Pending 改 Active
Loadpos + 1 < len下一个候选写
Spuriousspur == falsetrue

有余地就返回 true,下一轮从头重放到这个分支、在这里走新的选项,之后的部分全新生成。所有分支都用尽就返回 false,Execution::step 返回 None,模型结束。

几个细节:

  • Schedule 的 Active 无论如何都改成 Visited,即使没有 Pending 可换。这无关紧要,因为没有 Pending 时这个分支会在下一次循环被截掉。
  • 只有一个候选的 Load 也占一个位置。原子 load 不管有几个候选都 push_load,len = 1 的 Load 在 step 时 pos 变 1、不小于 len,直接跳过。
  • exploring == false 的分支直接跳过:既不换选项,也不阻止继续往前找。这是 stop_exploring() / skip_branch() 的实现方式。
  • 回溯位置之后的状态全部丢弃,包括 DPOR 在那些位置上登记的 Pending。下一轮重新走到那里时,会重新新建分支、重新登记。

一个完整的例子在 一次 model 的一生:消息传递模型 6 轮,逐轮列出了路径上每个分支的状态和 step 回退到了哪里。例如第 2 轮结束时,路径末尾的几个 Schedule 都没有 Pending,往前到下标 8 是一个有两个候选(false 与 true)、正在用第一个的 Load,于是 pos 加一、截掉 9 以后的记录,第 3 轮只换了 flag 读到的值。

分支数上限

assert_path_len!:

macro_rules! assert_path_len {
    ($branches:expr) => {{
        assert!(
            // if we are panicking, we may be performing a branch due to a
            // `Drop` impl (e.g., for `Arc`, or for a user type that does an
            // atomic operation in its `Drop` impl). if that's the case,
            // asserting this again will double panic. therefore, short-circuit
            // the assertion if the thread is panicking.
            $branches.len() < $branches.capacity() || std::thread::panicking(),
            "Model exceeded maximum number of branches. This is often caused \
             by an algorithm requiring the processor to make progress, e.g. \
             spin locks.",
        );
    }};
}
  • 上限就是 branches 这个 Vec 的容量,由 Path::new 按 max_branches 预留(默认 1 000)。从 checkpoint 载入的 Path 容量不确定,所以 check 载入后要 set_max_branches 补一次 reserve_exact(model.rs:152-157)。
  • 每次新建分支前检查,重放不检查。一轮里 Schedule、Load、Spurious 合计到 1 000 个就 panic。
  • 提示文案说「常常是自旋锁」:一个不 yield_now 的自旋循环在 loom 里可以一直被调度、一直产生新的 load 分支,永远不会结束。实测和解法在 死锁、分支上限与位置信息。
  • 正在 panic 时跳过检查,是 0.5.5 的修复(9a546f6「fix double panic when exceeding the branch limit in Drop」,#245):超限 panic 之后展开过程中 drop 的 loom Arc 还会再调 branch,再断言一次就是 panic 中再 panic。

object::Store(对象仓库)也按 max_branches 预留容量,但它没有上限断言,对象数超过只是 Vec 扩容。

checkpoint:把 Path 存成 JSON

开了 checkpoint feature,Path 和它包含的所有类型都 derive(Serialize, Deserialize)(#[cfg_attr(feature = "checkpoint", …)]),check 在 i % checkpoint_interval == 0 时把整个 Path 用 serde_json 写进文件、启动时读回(model.rs:246-265)。因为每轮都从头重放,一个 Path 就足以复现一轮执行,不需要保存任何程序状态。

下面是 一次 model 的一生 里第 6 轮开始时写出的文件(LOOM_CHECKPOINT_INTERVAL=1,743 字节),格式化后去掉了四个 Disabled:

{
  "preemption_bound": null,
  "pos": 0,
  "branches": { "entries": [
    { "Schedule": { "preemptions": 0, "initial_active": 0, "threads": ["Active", …], "prev": null, "exploring": true } },
    { "Schedule": { "preemptions": 0, "initial_active": 0, "threads": ["Active", …], "prev": { "index": 0, "_p": null }, "exploring": true } },
    { "Schedule": { "preemptions": 0, "initial_active": 0, "threads": ["Visited", "Active", …], "prev": { "index": 1, "_p": null }, "exploring": true } },
    { "Schedule": { "preemptions": 1, "initial_active": 1, "threads": ["Active", "Visited", …], "prev": { "index": 2, "_p": null }, "exploring": true } }
  ] },
  "exploring": true,
  "skipping": false,
  "exploring_on_start": true
}

这正是 step 之后、截断过的路径:4 个分支,最后一个刚把 0 号线程从 Pending 改成 Active。第 6 轮重放这 4 个分支,从第 5 个分支起全新生成。

文件写在这一轮开始之前,所以它是「下一轮要走的前缀」,不是「上一轮完整走过的路径」。这对调试有直接影响:默认间隔 2 万下,失败发生在前 2 万轮里就不会有文件;用 checkpoint 定位失败交错的可行步骤在 checkpoint、日志与位置。不开 feature 时设了 LOOM_CHECKPOINT_FILE,load_execution_path / store_execution_path 直接 panic「not compiled with checkpoint feature」(model.rs:267-278)。

小结

  • Path 是一串分支:Schedule(选线程)、Load(选读哪次写,最多 7 个候选)、Spurious(Notify 是否伪唤醒)。
  • pos < len 时按记录重放,pos == len 时用调用方的 seed 新建;重放时类型对不上就 panic「Reached unexpected exploration state. Is the model fully deterministic?」(实测:靠 std static 跨轮改变行为,第 1 轮即报)。
  • step 从末尾往前截断,找第一个还有选项的分支:Schedule 换下一个 Pending 线程,Load 换下一个候选,Spurious 从 false 换 true;exploring == false 的跳过。
  • 分支上限是 branches 的容量(max_branches,默认 1 000),只在新建分支时检查,panic 期间跳过(#245)。
  • checkpoint feature 把 Path 存成 JSON,写的是「下一轮的前缀」。

💬 关联讨论

发起讨论
加载中...