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_all | 558 个交错,通过 |
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 永不超时
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_cst | 0.7.2 里不起作用(同 Mutex) |
notified | 有一个尚未被消费的通知 |
last_access、synchronize | DPOR 与 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 onNotify")(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」 |
while | 23 个交错,通过 |
和 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_all558 个交错通过。Notify:最多存一个通知;只允许一个等待者;每个对象一轮最多伪唤醒一次,伪唤醒是Spurious分支。同样的if写法换成Notify,第 5 个交错被抓到,while写法 23 个交错通过(E9 反向)。park消费预存的 unpark(#250),不会无故返回;unpark传递 happens-before。join是一个不伪唤醒的Notify,带 release / acquire。