Rust in Everything
基于 loom 0.7.2

Mutex 与 RwLock

主线位置:主线 1「一次 model 的一生」里 branch → Execution::schedule 的一个具体实例:锁操作怎么变成调度点、怎么让线程进入和离开 Blocked;以及主线 4「看得到什么、看不到什么」里关于锁语义的几条。

sequenceDiagram
  participant A as 线程 A
  participant L as rt::Mutex(lock = 持有者)
  participant S as Execution::schedule
  participant B as 线程 B
  A->>L: lock:branch_acquire(is_locked = false)
  L->>S: 调度点(可能切到 B)
  A->>L: post_acquire:lock = A,sync_load(Acquire)
  L->>B: B 的下一步也是这把锁 → B 标成 Blocked
  B->>L: lock:branch_acquire(is_locked = true) → 自己标成 Blocked
  A->>L: 守卫 drop:release_lock,lock = None,sync_store(Release)
  L->>B: 等这把锁的线程改回 Runnable(不是调度点)
  S->>B: 下一个调度点选中 B,post_acquire 成功

Mutex:两个字段

sync/mutex.rs:8-11:

pub struct Mutex<T: ?Sized> {
    object: rt::Mutex,
    data: std::sync::Mutex<T>,
}

object 是对象仓库里的一个下标(rt::Mutex { state: object::Ref<State> }),状态在 rt/mutex.rs:13-26:

字段作用
seq_cst: bool对外 Mutex::new 传 true,AtomicWaker 内部的锁传 false
lock: Option<thread::Id>当前持有者
last_access: Option<Access>DPOR 用的「上一次访问」:锁上的任意两次操作都算相关
synchronize: Synchronize解锁时 release、加锁时 acquire 的同步点

数据放在 std 的 Mutex<T> 里。lock() 先走 rt 那一套,确定轮到自己了,再 self.data.lock().unwrap() 把守卫拿出来(37-45 行)。所有 loom 线程都在同一个 OS 线程上,rt 层已经保证同一时刻只有一个模型线程持有,std 锁这一步不会等待。

seq_cst 字段:加锁和解锁时 if state.seq_cst { execution.threads.seq_cst() },而 seq_cst() 在 0.7.2 里是空函数(rt/thread.rs:336-341)。注释写的「Establish sequential consistency between the lock's operations」因此不起作用;锁的同步效果只有 acquire / release 这一对。原因见 fence 与 SeqCst。

lock:调度点在「判断锁是否被占」之后

rt/mutex.rs:44-52:

pub(crate) fn acquire_lock(&self, location: Location) {
    self.state.branch_acquire(self.is_locked(), location);
    assert!(self.post_acquire(), "expected to be able to acquire lock");
}

pub(crate) fn try_acquire_lock(&self, location: Location) -> bool {
    self.state.branch_opaque(location);
    self.post_acquire()
}

branch_acquire 做三件事:把「我下一步要对这把锁做 Opaque 操作」写进当前线程的 operation;锁已被占就把自己标成 Blocked;然后调 Execution::schedule。schedule 可能挑别的线程继续(DPOR),也可能因为当前线程阻塞而必须换人。等这个线程再次被选中,branch_acquire 返回,post_acquire 一定能成功,所以那里是 assert! 而不是重试循环:只有锁空了,线程才会被改回可运行。

post_acquire:

// Set the lock to the current thread
state.lock = Some(thread_id);

dbg!(state.synchronize.sync_load(&mut execution.threads, Acquire));
...
// Block all **other** threads attempting to acquire the mutex
for (id, thread) in execution.threads.iter_mut() {
    if id == thread_id {
        continue;
    }

    if let Some(operation) = thread.operation.as_ref() {
        if operation.object() == self.state.erase() {
            let location = operation.location();
            trace!(state = ?self.state, thread = ?id,
                "Mutex::post_acquire");
            thread.set_blocked(location);
        }
    }
}

最后这个循环是必要的:别的线程可能在锁还空着时已经走到了 branch_acquire(当时 is_locked() 为假,没有把自己标成阻塞),只是还没被调度到 post_acquire。现在锁被拿走了,它们必须补标成阻塞,否则调度器会选中一个拿不到锁的线程,assert! 就会失败。

dbg! 不会打印任何东西:loom 在 lib.rs:360-364 定义了一个同名宏,展开成表达式本身。rt/rwlock.rs、rt/mpsc.rs、rt/notify.rs、future/atomic_waker.rs 里的 dbg! 同理。

解锁:不是调度点

守卫 drop 时调 release_lock:

// Release the lock flag
state.lock = None;

// Execution has deadlocked, cleanup does not matter.
if !execution.threads.is_active() {
    return;
}

state
    .synchronize
    .sync_store(&mut execution.threads, Release);
...
for (id, thread) in execution.threads.iter_mut() {
    ...
    if obj == Some(self.state.erase()) {
        thread.set_runnable();
    }
}

这里没有 branch,也没有 schedule:解锁只是改状态、做一次 release,并把「下一步要碰这把锁」的线程改回可运行。它们什么时候真的运行,由解锁之后的下一个调度点决定(持锁线程的下一个 loom 操作,或者它结束)。

is_active() 那个提前返回是 0.5.3 加的(#236):死锁时 active 已经是 None,panic 展开过程中守卫照样会 drop,没有这个检查就会在 active_id() 上再 panic 一次。死锁后的展开见 死锁、分支上限与位置信息。

不毒化,也没有 poison API

sync/mutex.rs:20-45:lock 返回 LockResult,但永远是 Ok;into_inner 写的是 Ok(self.data.into_inner().unwrap());没有 is_poisoned、clear_poison。签名与 std 一致,只是为了让切换层(cfg(loom) 切换层)不改调用方代码。真实环境里「持锁线程 panic 后,其他线程看到 PoisonError」这条分支在 loom 下走不到;更直接的原因是子线程 panic 会让整个模型失败(线程、线程局部与 lazy_static)。

两个最小例子

(exp-loom/primitives/tests/prim.rs,mutex_two_threads;exp-loom/survey/tests/misc.rs,e_mutex_self_deadlock)

模型结果
两个线程各加锁一次、加一,主线程 join 后断言等于 24 个交错,通过
同一线程连续两次 lock()(E10)第 1 个交错报「deadlock; threads = [(Id(0), Blocked(Location(None)))]」

第二个是 branch_acquire(is_locked = true) 把唯一的线程标成阻塞,schedule 找不到可运行线程,触发死锁断言。不是可重入锁,和 std 一样;区别是 std 在这种情况下行为未指定(可能死锁也可能 panic),loom 一定报出来。Location(None) 是没开 LOOM_LOCATION 时的样子,开了会变成文件、行、列(checkpoint、日志与位置)。

try_lock 走 branch_opaque:只是一个调度点,不会阻塞,post_acquire 返回 false 就是 Err(WouldBlock)。

RwLock:读者集合

rt/rwlock.rs:12-16:

#[derive(Debug, PartialEq)]
enum Locked {
    Read(HashSet<thread::Id>),
    Write(thread::Id),
}

和 Mutex 同一个骨架,区别在阻塞条件(56-79 行):

操作什么时候把自己标成阻塞加锁成功后把谁标成阻塞
read已被写锁住下一步要拿写锁的线程(172-223 行)
write被读锁或写锁住下一步要碰这把锁的所有线程(225-260 行)

释放读锁时把自己从集合里删掉,集合空了才把锁置空并唤醒等待者(91-116 行)。read / write 是 branch_disable 加上动作 Read / Write;try_read / try_write 是 branch_action,不阻塞。

数据放在 std 的 RwLock<T> 里,用 try_read().expect("loom::RwLock state corrupt") 取出(sync/rwlock.rs:47-54)。rt 层既然说可以拿,std 层就必须立刻拿得到,try_* 失败说明两边状态不一致,所以叫「state corrupt」。

没有写者优先

读锁的阻塞条件只看「是否被写锁住」,不看有没有写者在等。实测(exp-loom/primitives/tests/prim.rs,rwlock_reader_overtakes_writer):主线程先持有读锁;写线程先置一个「我在等」的标志再 write();第二个读线程 read() 后检查标志。9 个交错里有 3 个交错,第二个读者在写者已经宣布等待之后照样进了读锁。

两个后果:

  • 写者饥饿、以及 parking_lot 卷的嵌套读死锁(同一线程两次 read,中间有写者排队,写者优先的实现会死锁),loom 都不会报,属于假阴性方向。std 的 RwLock 文档本身不保证读写优先级,用 loom 测「读写顺序」相关的代码时要记住这一点。
  • 对只依赖「读者之间共享、读写互斥」的代码,这个模型是够的。三线程模型(两个读线程、主线程写一次,rwlock_two_readers_writer)共 253 个交错。

同线程嵌套读:内部错误

读者集合是 HashSet<thread::Id>,同一个线程第二次 read 只是重复插入,集合里还是一个元素。内层守卫 drop 时把这个线程删掉,集合空了,锁被置成 None;外层守卫 drop 时再进 release_read_lock,匹配不到 Locked::Read:

let readers = match &mut state.lock {
    Some(Locked::Read(readers)) => readers,
    _ => panic!("invalid internal loom state"),
};

(103-106 行)

实验 E14(exp-loom/survey/tests/misc.rs,e_rwlock_recursive_read):主线程 read 里再 read,另一个线程 write。

  • 第 1 个交错:「invalid internal loom state」(loom-0.7.2/src/rt/rwlock.rs:105:22),紧接着在展开过程中报「cannot access Loom execution state from outside a Loom model…」(scheduler.rs:128:13),进程 abort。第二个 panic 来自 panic 之后仍要 drop 的 loom 对象:这时调度器的 STATE 已经不在作用域里。
  • 反向验证(exp-loom/primitives/tests/prim.rs,e14_two_separate_reads):同样三个操作,只是把两次 read 改成先后独立的两个块,9 个交错通过。说明报错来自「嵌套」,不是来自读写混用。

如果另一个线程恰好在内层守卫 drop 之后被调度,它会以为锁空了去拿写锁,try_write().expect(...) 会因为 std 层的外层读守卫还在而报「loom::RwLock state corrupt」。哪一个先出现取决于交错。

这是一个假阳性:std 的 RwLock 允许同线程重复读(只是在有写者排队的实现上可能死锁)。代码里确实需要嵌套读时,loom 测不了这一段;绕法是在模型里改成先后两次独立的读,或者把被测代码改成不嵌套。坑页见 09 常见坑。

历史上的修正

  • 0.6.0 的「Fixed corner case in RwLock (#300)」(16e5e9a):post_acquire_write_lock 原来对 Some(Locked::Write(_)) 也返回成功,try_write 在已被写锁住时会覆盖持有者;修正后两种已锁状态都返回 false。
  • tests/rwlock_regression1.rs(9e2dcec #135,2020)是两个线程各拿一次写锁的模型,用来防住早期合并时引入的回归。
  • master 上的 #401(6abd413)给 RwLock 加了 T: ?Sized;0.7.2 的 RwLock<T> 还要求 Sized,Mutex<T: ?Sized> 早就支持。

小结

  • Mutex = rt 对象(持有者 + Synchronize + 上次访问)+ std Mutex 存数据;RwLock 同理,持有者换成读者集合或写者。
  • 加锁是调度点:branch_acquire 在锁被占时把自己标成阻塞;加锁成功后把所有「下一步要碰这把锁」的线程补标成阻塞,所以 post_acquire 可以直接 assert!。
  • 解锁不是调度点:改状态、release、把等待者改回可运行,真正切换发生在下一个调度点。
  • 锁的同步只有 acquire / release;seq_cst 参数在 0.7.2 里不起作用;代码里的 dbg! 是 loom 自己定义的空宏。
  • 不毒化,lock 恒 Ok;同线程两次 lock 报死锁(E10)。
  • RwLock 没有写者优先:写者等待时新读者照样能进(9 个交错里 3 个);同线程嵌套读报「invalid internal loom state」后 abort(E14),改成不嵌套的两次读 9 个交错通过。

💬 关联讨论

发起讨论
加载中...