死锁、分支上限与位置信息
主线位置:主线 1 里
Execution::schedule与Path的两个断言。死锁在「选下一个线程」时发现,分支上限在「新建一个分支」时发现;两者都和内存模型无关,只看线程状态与路径长度。
死锁:没有可运行的线程
rt/execution.rs:209-226,在 Path::branch_thread 选出下一个线程之后:
self.threads.set_active(next);
// There is no active thread. Unless all threads have terminated, the
// test has deadlocked.
if !self.threads.is_active() {
let terminal = self.threads.iter().all(|(_, th)| th.is_terminated());
assert!(
terminal,
"deadlock; threads = {:?}",
self.threads
.iter()
.map(|(i, th)| { (i, th.state) })
.collect::<Vec<_>>()
);
return true;
}
没有可运行的线程、又不是所有线程都结束了,就是死锁。线程什么时候「不可运行」由各原语决定:锁被占时 branch_acquire 把自己标成 Blocked、park / Condvar::wait / Notify::wait / join / 空 channel 的 recv 也是(05 同步原语的模型)。所以 loom 的死锁不限于锁的环:只要所有活着的线程都在等,不管等的是什么,都会报出来,例如 wait_timeout 没人 notify(loom 里永不超时)、AtomicWaker 丢了唤醒。
报错里每个线程是 (Id(n), 状态)。Blocked 带着阻塞点的 Location;不开 LOOM_LOCATION 时是 Location(None)。
实验 E10(exp-loom/checkers/tests/deadlock.rs):
| 场景 | 结果 |
|---|---|
同一线程对一把 loom::sync::Mutex 连续 lock 两次 | 第 1 个交错「deadlock; threads = [(Id(0), Blocked(Location(None)))]」 |
| 两个线程以相反顺序拿两把锁(AB-BA) | 第 2 个交错「deadlock; threads = [(Id(0), Blocked(Location(None))), (Id(1), Blocked(Location(None)))]」 |
开 LOOM_LOCATION=1 后,自锁那一行变成:
deadlock; threads = [(Id(0), Blocked(Location(Some(Location { file: "tests/deadlock.rs", line: 17, column: 18 }))))]
第 17 行第 18 列是第二次 m.lock()。AB-BA 报出两个线程各自卡住的那一行。LOOM_LOCATION 的判断是 env::var("LOOM_LOCATION").is_ok()(model.rs:90),设成任何值都打开,LOOM_LOCATION=0 也一样(实测)。
死锁之后为什么会 abort
上面两行之外,E10 还有一组:同样的 AB-BA,锁放在 loom::sync::Arc 里(模型代码通常就是这么写的)。结果:
deadlock; threads = [(Id(0), Blocked(Location(None))), (Id(1), Blocked(Location(None)))]
called `Option::unwrap()` on a `None` value (rt/thread.rs:276:39)
panic in a destructor during cleanup
thread caused non-unwinding panic. aborting.
进程退出码 134(SIGABRT)。过程是:
- 死锁断言之前,
set_active(next)已经把「当前线程」设成None。 - 断言 panic,开始展开当前这个模型线程的栈:栈上的
MutexGuard、Arc依次 drop。 MutexGuard的 drop 调release_lock,它在 0.5.3(#236)之后有一个防护,见下面的代码,直接返回。- loom
Arc的 drop 调ref_dec,ref_dec先branch(RefDec),branch里的set_action要取当前线程(rt/object.rs:380-392),active_mut()对None调unwrap(rt/thread.rs:275-277),第二次 panic。RUST_BACKTRACE=1的回溯正是Arc::drop→ref_dec→branch_action→set_action→active_mut。 - 析构函数在展开过程中 panic,Rust 直接 abort。
flowchart TB sch["schedule:branch_thread 选不出线程,set_active(None)"] --> as["assert!(terminal) 失败:deadlock; threads = ..."] as --> unw["展开当前模型线程的栈"] unw --> mg["MutexGuard::drop → release_lock:is_active() 为假,直接返回"] unw --> arc["loom Arc::drop → ref_dec → branch → set_action → active_mut()"] arc --> un["None.unwrap():析构中的第二次 panic"] un --> ab["panic in a destructor during cleanup → abort(134)"]
第 3 步的防护(rt/mutex.rs:54-64):
pub(crate) fn release_lock(&self) {
super::execution(|execution| {
let state = self.state.get_mut(&mut execution.objects);
// Release the lock flag
state.lock = None;
// Execution has deadlocked, cleanup does not matter.
if !execution.threads.is_active() {
return;
}
Arc 没有类似 release_lock 的防护。反向验证:把锁从 loom Arc 里拿出来,其余不变。
| 两把锁放在哪 | 结果 | 退出码 |
|---|---|---|
loom::sync::Arc | 报死锁,然后 abort | 134 |
std::sync::Arc | 报死锁,正常 panic | 0 |
Box::leak 得到的 &'static | 报死锁,正常 panic | 0 |
自锁,但栈上还有一个 loom Arc(锁放在 Arc<Mutex<_>> 里) | 报死锁,然后 abort | 134 |
在上面这几组里,abort 只在模型线程的栈上有 loom Arc 时出现,和死锁的形状无关。这不是唯一的触发方式:任何 panic(不只是死锁)发生在 spawn 之后、第一次切换之前时,排队中的 spawn 闭包里如果有 loom Arc,也会 abort,见常见坑panic 之后 abort。影响有三:
#[should_panic]写的死锁测试,在锁放在 loomArc里时会因为 abort 而失败(进程直接没了,测试框架收不到 panic);- 同一个测试二进制里排在后面的测试都不会再跑;
- 真正有用的信息是第一条 panic,后面两条是噪音。
绕法:死锁类的测试一个文件只放一个,或者用 --exact 逐个跑;模型里共享锁用 Box::leak 或 std Arc(死锁检测只依赖锁本身是 loom 类型,不依赖 Arc)。
分支上限
Path 的 branches 用 Vec::with_capacity(max_branches) 创建(path.rs:128-137)。每次在路径末尾新建分支(调度分支、load 分支、伪唤醒分支)之前都检查一次(path.rs:109-123):
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.",
);
}};
}
上限默认 1000,由 LOOM_MAX_BRANCHES 或 Builder.max_branches 设置(02 model 与 Builder)。panic 期间跳过检查是 0.5.5(#245)加的,注释里举的例子正是 Arc 的 Drop:展开时 drop 一个 loom Arc 也会新建分支,不跳过就会二次 panic。
这个上限限制的是一轮执行的分支数,不是交错总数。撞上它几乎总是因为某一轮里有个循环不会结束:
实验 E11(exp-loom/checkers/tests/deadlock.rs):两个线程用 compare_exchange(false, true, Acquire, Relaxed) 自旋抢锁,拿到后 with_mut 改一个 UnsafeCell,再 store(false, Release) 解锁。
| 自旋循环体 | 结果 |
|---|---|
| 空 | 第 28 个交错「Model exceeded maximum number of branches. This is often caused by an algorithm requiring the processor to make progress, e.g. spin locks.」 |
loom::thread::yield_now() | 189 个交错通过 |
loom::hint::spin_loop() | 189 个交错通过 |
把不 yield 的版本的上限依次设成 LOOM_MAX_BRANCHES=100、1000、10000,三次都是第 28 个交错报同一句话:第 28 轮里调度器一直让自旋的线程继续跑(02 DPOR 的「尽量不抢占」),持锁线程永远轮不到,路径无限变长,调大上限只是撞得晚一点。反过来,yield_now 版本把上限压到 10、20 时第 1 个交错就撞上,30 时 189 个交错通过:它每一轮需要的分支数在 20 到 30 之间。
yield_now 有两个作用:把当前线程标成 Yield,调度器下一次优先选别的线程(02 DPOR);之后这个线程不会再读到它 yield 前已经读过的旧值(03 load 的候选 的规则二)。lib.rs 的「Yielding」一节(lib.rs:167-175)说的就是这件事:loom 的调度器有意不公平,依赖「别的线程总会前进」的算法,循环里必须调 yield_now。该节引的文章是「Spinlocks Considered Harmful」。
yield_now、hint::spin_loop、sync::atomic::spin_loop_hint 是同一个函数:后两者都直接调 thread::yield_now(hint.rs:3-6、sync/atomic/mod.rs:20-23),实验里交错数也完全一样。所以用 std::hint::spin_loop 写的自旋,切换层里换成 loom 的同名函数就能在 loom 下结束。
位置信息
location!() 宏
loom 对外的几乎每个方法都标了 #[track_caller],内部用 location!() 取调用位置:
macro_rules! location {
() => {{
let enabled = crate::rt::execution(|execution| execution.location);
if enabled {
let location = crate::rt::Location::from(std::panic::Location::caller());
location
} else {
crate::rt::Location::disabled()
}
}};
}
- 关闭时
Location(None),所以报错里看到的是Location(None)。 - 宏本身就要进执行状态(
rt::execution),这是 loom 类型不能在模型外构造的原因之一(02 协程调度器)。 - 每个对象为每个线程记「最后一次」的位置:
LocationSet是[Location; MAX_THREADS](location.rs:21-44)。报错里打印的是冲突双方各自最后一次访问的位置,不是完整的访问历史。
PanicBuilder 的格式
竞争检测的报错由 PanicBuilder::fire 拼出:没有任何位置被捕获时只有一行消息;有的话消息前加一个换行,每个位置一行,键名右对齐,线程相关的位置写成「thread #n @ 文件:行:列」。E7 的两个例子:
Causality violation: Concurrent write accesses to `UnsafeCell`.
created: tests/cell.rs:15:24
write one: thread #0 @ tests/cell.rs:18:9
write two: thread #1 @ tests/cell.rs:17:41
Causality violation: Concurrent atomic store and `unsync_load` accesses.
created: tests/cell.rs:183:24
unsync_load: thread #0 @ tests/cell.rs:186:26
atomic store: thread #1 @ tests/cell.rs:185:41
死锁和分支上限的报错不经过 PanicBuilder,是普通的 assert! 消息;泄漏报错自己拼「Allocated:」一行(泄漏检测)。
位置信息不是免费的:打开后每次操作都要多取一次 Location::caller() 并写进 LocationSet。Builder.location 的文档说它「very expensive」,建议先用 LOOM_CHECKPOINT_FILE 定位到失败的那一轮,再打开位置跟踪(model.rs:59-66;怎么做见 06 checkpoint、日志与位置)。
hint::unreachable_unchecked
hint.rs:25-28:loom 版的 unreachable_unchecked 无条件 unreachable!("unreachable_unchecked was reached!"),签名仍是 unsafe fn。实测报错「internal error: entered unreachable code: unreachable_unchecked was reached!」,因为有 #[track_caller],panic 位置是调用处。std 版到达这里是未定义行为,loom 版把它变成一次可见的失败。
小结
- 死锁 = 调度时没有可运行线程、又不是全部结束;任何形式的「都在等」都算。E10:自锁第 1 个交错、AB-BA 第 2 个交错。
- 死锁 panic 之后展开当前模型线程的栈,栈上的 loom
Arc在 drop 时对None调unwrap,二次 panic,进程 abort;锁放在 stdArc或Box::leak里就正常 panic(反向验证)。MutexGuard有防护,Arc没有。 - 分支上限限制一轮的分支数,默认 1000;E11:不 yield 的自旋第 28 个交错撞上限,上限 100、1000、10000 结果相同;
yield_now或hint::spin_loop后 189 个交错通过。 LOOM_LOCATION设成任何值都打开;报错里给出冲突双方最后一次访问的位置;死锁报错给出每个阻塞线程卡住的位置。hint::unreachable_unchecked在 loom 里是一次普通 panic。