Rust in Everything
基于 loom 0.7.2

Condvar、Notify 与 park

主线位置:主线 1 里「线程阻塞与唤醒」的另一种写法(不是等锁,而是等通知),以及主线 4 里两条方向相反的限制:Condvar 没有虚假唤醒(假阴性),wait_timeout 不超时(假阳性)。Notify 的伪唤醒是 Path 里第三种分支 Spurious 的唯一来源。

flowchart TB
  w0["Condvar::wait(guard)"] --> w1["branch_opaque:调度点"]
  w1 --> w2["waiters.push_back(自己)"]
  w2 --> w3["mutex.release_lock():不是调度点"]
  w3 --> w4["rt::park:Runnable{unparked:true} 就消费掉直接返回,否则 Blocked + schedule"]
  w4 --> w5["mutex.acquire_lock():重新加锁,又一个调度点"]
  n0["notify_one"] --> n1["branch_opaque"] --> n2["waiters.pop_front() → threads.unpark(id)"]
  n2 --> n3["被唤醒者并入唤醒者的 causality,改回 Runnable"]
  n3 -.-> w4

Condvar:一个等待者队列

rt::Condvar 的状态只有两项(rt/condvar.rs:15-22):DPOR 用的 last_access,和一个 VecDeque<thread::Id> 等待队列。

wait:

pub(crate) fn wait(&self, mutex: &Mutex, location: Location) {
    self.state.branch_opaque(location);

    rt::execution(|execution| {
        trace!(state = ?self.state, ?mutex, "Condvar::wait");

        let state = self.state.get_mut(&mut execution.objects);

        // Track the current thread as a waiter
        state.waiters.push_back(execution.threads.active_id());
    });

    // Release the lock
    mutex.release_lock();

    // Disable the current thread
    rt::park(location);

    // Acquire the lock again
    mutex.acquire_lock(location);
}

顺序和真实条件变量的契约一致:先登记、再放锁、再睡,所以「放锁之后、睡之前」来的通知不会丢:通知者能拿到锁时,等待者已经在队列里了。这三步之间只有第一行和 park 里的 schedule 是调度点,push_back 与 release_lock 不会被别的线程插进来。

对外的 Condvar::wait 先 guard.unborrow() 把 std 守卫放掉(数据还在 std Mutex 里),等完再 reborrow() 拿回来;返回的 LockResult 恒为 Ok。

notify_one 弹出队首、notify_all 清空队列,各自对弹出的线程调 threads.unpark(id)(62-93 行)。两者都只是一个 Opaque 调度点。

没有虚假唤醒

被 park 的线程只有一种办法回到可运行:有人 unpark 它。Condvar 里调 unpark 的只有 notify_*。所以 loom 的 Condvar::wait 只会在被通知后返回,没有虚假唤醒。std 文档明确说 wait 可能虚假唤醒,要求在循环里重新检查条件。

实验 E9(exp-loom/survey/tests/misc.rs,e_condvar_if_not_while):一个线程持锁置 ready = true 再 notify_one;主线程持锁,用 if !ready { wait } 而不是 while,醒来后断言 ready。

写法结果
if !*g { g = cv.wait(g) }8 个交错,全部通过
while !*g { g = cv.wait(g) }(exp-loom/primitives,e9_condvar_while)8 个交错,全部通过

两种写法的交错数一样:loom 里 while 的第二次判断永远为假。这是假阴性:if 写法在真实环境里是错的,loom 证明不了它错。

下面「Notify」一节用同一个形状做了反向验证:换成会伪唤醒的原语,if 写法就被抓到。

两个等待者

exp-loom/primitives/tests/prim.rs 里两个对照(两个线程用 while 等同一个条件,主线程置条件后通知):

通知方式结果
notify_all558 个交错,通过
notify_one报「deadlock; threads = [(Id(0), Blocked(Location(None))), (Id(1), Terminated), (Id(2), Blocked(Location(None)))]」,之后 abort

第二行是一个真实的错误:两个等待者都已经在睡时,notify_one 只叫醒一个,另一个永远等下去。loom 把它报成死锁:主线程在 join 上阻塞,2 号线程在 wait 里阻塞。报死锁之后进程 abort 的原因见 死锁、分支上限与位置信息。

wait_timeout 永不超时

sync/condvar.rs:44-53:

pub fn wait_timeout<'a, T>(
    &self,
    guard: MutexGuard<'a, T>,
    _dur: Duration,
) -> LockResult<(MutexGuard<'a, T>, WaitTimeoutResult)> {
    // TODO: implement timing out
    self.wait(guard)
        .map(|guard| (guard, WaitTimeoutResult(false)))
        .map_err(|err| PoisonError::new((err.into_inner(), WaitTimeoutResult(false))))
}

时长被忽略,就是一次 wait,timed_out() 恒为 false。E9 的另一半(e_condvar_wait_timeout):没有任何线程通知,主线程 wait_timeout(1 ms) 后断言 timed_out()。结果不是断言失败,而是第 1 个交错报「deadlock; threads = [(Id(0), Blocked(Location(None)))]」,随后 abort(退出码 134)。

这是假阳性:依赖超时作为兜底的代码,在 loom 下会被报成死锁;超时分支本身永远测不到。loom 的 Condvar 也没有 std 的 wait_while、wait_timeout_while。

Notify:loom 独有的原语

loom::sync::Notify 不是 std 类型。文档说它「Implements the park / unpark pattern directly using Loom's internal primitives」,给写 mock 用:被测库里有自己的等待机制(比如基于 futex 或平台 API),在 loom 下没法用,就换成 Notify。

rt/notify.rs:15-34 的状态:

字段作用
spurious是否允许伪唤醒。对外的 Notify::new() 传 true
did_spur这个对象是否已经伪唤醒过一次
seq_cst0.7.2 里不起作用(同 Mutex)
notified有一个尚未被消费的通知
last_access、synchronizeDPOR 与 release / acquire

notify(54-85 行):调度点,release,notified = true,把正等在这个对象上的线程 unpark。没人在等时通知也会留着:exp-loom/primitives 的 notify_before_wait(先 notify 再 wait)2 个交错通过。它是一个「最多存一个许可」的信号量,不是条件变量。

wait:

pub(crate) fn wait(self, location: Location) {
    let (notified, spurious) = rt::execution(|execution| {
        let spurious = if self.state.get(&execution.objects).might_spur() {
            execution.path.branch_spurious()
        } else {
            false
        };

        let state = self.state.get_mut(&mut execution.objects);

        if spurious {
            state.did_spur = true;
        }
        ...
        dbg!((state.notified, spurious))
    });

    if spurious {
        rt::yield_now();
        return;
    }

    if notified {
        self.state.branch_opaque(location);
    } else {
        // This should become branch_disable
        self.state.branch_acquire(true, location)
    }
    ...
    state.synchronize.sync_load(&mut execution.threads, Acquire);
    ...
    state.notified = false;
}
  • 伪唤醒是路径上的一个分支:might_spur() 为真(137-139 行:self.spurious && !self.did_spur)时,branch_spurious 在 Path 里建一个 Spurious 分支,先走 false,回溯时再走 true(Path:路径与深度优先)。走 true 的那一轮,wait 让出一次后直接返回,不消费通知。
  • 每个 Notify 对象一轮里最多伪唤醒一次。这是为了让「循环里 wait」的模型能结束:要是每次都可能伪唤醒,while !ready { wait() } 就会产生无界长的路径。
  • 只能一个线程等:对外的 wait 先 waiting.compare_exchange(false, true, SeqCst, SeqCst).expect("only a single thread may wait on Notify")(sync/notify.rs:35-44)。实测两个线程同时等,第 1 个交错报「only a single thread may wait on `Notify`: true」。master 上的 #373 只是把这条限制补进了文档。

反向验证:同一个 if,换成 Notify

E9 反向(exp-loom/primitives/tests/prim.rs):ready 换成一个 loom AtomicBool,通知者 store(true, Release) 后 notify();等待者 if !ready.load(Acquire) { n.wait() },醒来断言 ready。

写法结果
if第 5 个交错断言失败「woke without ready」
while23 个交错,通过

和 Condvar 那一组放在一起看:模型的形状完全一样,Condvar 版本 if 也通过,是因为 loom 的 Condvar 根本不产生虚假唤醒,不是因为 if 写法在这个形状下正确。要让 loom 检查「等待者是否容忍虚假唤醒」,被测代码的等待原语在 loom 下得换成 Notify 或自己模拟伪唤醒。

park 与 unpark

thread::park() 直接调 rt::park,Thread::unpark() 调 threads.unpark(id)。rt/mod.rs:86-114:

pub(crate) fn park(location: Location) {
    let switch = execution(|execution| {
        use thread::State;
        let thread = execution.threads.active_id();
        let active = execution.threads.active_mut();

        trace!(?thread, ?active.state, "park");

        match active.state {
            // The thread was previously unparked while it was active. Instead
            // of parking, consume the unpark.
            State::Runnable { unparked: true } => {
                active.set_runnable();
                return false;
            }
            // The thread doesn't have a saved unpark; set its state to blocked.
            _ => active.set_blocked(location),
        };

        execution.threads.active_mut().set_blocked(location);
        execution.threads.active_mut().operation = None;
        execution.schedule()
    });
    ...
}

被 unpark 的线程如果正处于 Blocked 或 Yield,直接改回可运行;如果本来就在运行,就记成 Runnable { unparked: true },下次 park 时消费掉、不阻塞(rt/thread.rs:153-166)。这就是 std 文档里的「token」语义,由 0.5.5 的「Fix semantics of thread::park after Thread::unpark (#250)」补上。park 里连续两次 set_blocked(103、106 行)是冗余的,结果相同。

unpark 还会把唤醒者的 causality 并进被唤醒者(rt/thread.rs:153-156),相当于一次 release / acquire:Condvar 的通知者在 notify 之前写的数据,被唤醒者醒来后可见。自己 unpark 自己不需要合并(305-317 行)。

std 的 park 可能无故返回(文档:「A call to park does not guarantee that the thread will remain parked forever」,loom 自己的文档注释也照抄了这句)。loom 的 rt::park 不会无故返回,和 Condvar 一样是假阴性方向。

JoinHandle::join 也是一个 Notify

thread.rs:157-158 给每个线程建一个 rt::Notify::new(true, false):seq_cst = true(不起作用)、spurious = false(不伪唤醒)。子线程跑完后 notify,join 先 wait 再从 std Arc<Mutex<Option<Result>>> 里取结果(224-230 行)。所以 join 自带一次 release / acquire:子线程写的东西,join 之后在主线程可见,和 std 的保证一致。

小结

  • Condvar = 等待者队列;wait 先登记、再放锁、再 park、醒来重新加锁;notify_* 是 Opaque 调度点,弹出等待者并 unpark。
  • 没有虚假唤醒:if 写法和 while 写法都是 8 个交错通过(E9),假阴性;wait_timeout 忽略时长、timed_out() 恒假,无人通知时报死锁(E9),假阳性。
  • notify_one 配两个已睡的等待者会被正确报成死锁;notify_all 558 个交错通过。
  • Notify:最多存一个通知;只允许一个等待者;每个对象一轮最多伪唤醒一次,伪唤醒是 Spurious 分支。同样的 if 写法换成 Notify,第 5 个交错被抓到,while 写法 23 个交错通过(E9 反向)。
  • park 消费预存的 unpark(#250),不会无故返回;unpark 传递 happens-before。
  • join 是一个不伪唤醒的 Notify,带 release / acquire。

💬 关联讨论

发起讨论
加载中...