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["结束"]
三种分支
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 的候选) |
Spurious | Notify::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 |
Load | pos + 1 < len | 下一个候选写 |
Spurious | spur == false | true |
有余地就返回 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 读到的值。
分支数上限
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 inDrop」,#245):超限 panic 之后展开过程中 drop 的 loomArc还会再调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?」(实测:靠 stdstatic跨轮改变行为,第 1 轮即报)。step从末尾往前截断,找第一个还有选项的分支:Schedule换下一个Pending线程,Load换下一个候选,Spurious从false换true;exploring == false的跳过。- 分支上限是
branches的容量(max_branches,默认 1 000),只在新建分支时检查,panic 期间跳过(#245)。 checkpointfeature 把Path存成 JSON,写的是「下一轮的前缀」。