Rust in Everything
基于 loom 0.7.2

死锁、分支上限与位置信息

主线位置:主线 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)。过程是:

  1. 死锁断言之前,set_active(next) 已经把「当前线程」设成 None。
  2. 断言 panic,开始展开当前这个模型线程的栈:栈上的 MutexGuard、Arc 依次 drop。
  3. MutexGuard 的 drop 调 release_lock,它在 0.5.3(#236)之后有一个防护,见下面的代码,直接返回。
  4. 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。
  5. 析构函数在展开过程中 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报死锁,然后 abort134
std::sync::Arc报死锁,正常 panic0
Box::leak 得到的 &'static报死锁,正常 panic0
自锁,但栈上还有一个 loom Arc(锁放在 Arc<Mutex<_>> 里)报死锁,然后 abort134

在上面这几组里,abort 只在模型线程的栈上有 loom Arc 时出现,和死锁的形状无关。这不是唯一的触发方式:任何 panic(不只是死锁)发生在 spawn 之后、第一次切换之前时,排队中的 spawn 闭包里如果有 loom Arc,也会 abort,见常见坑panic 之后 abort。影响有三:

  • #[should_panic] 写的死锁测试,在锁放在 loom Arc 里时会因为 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;锁放在 std Arc 或 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。

💬 关联讨论

发起讨论
加载中...