睡眠与唤醒的握手
主线位置:一个线程睡下又醒来的前半。
wait_until_cold找不到活 →start_looking→no_work_found前 32 轮yield_now→ 第 32 轮announce_sleepy→ 再找一轮 →sleep():get_sleepy→ 锁is_blocked→fall_asleep→ 看 JEC → 登记 sleeping →fence(SeqCst)→ 看注入队列 →Condvar::wait。
sequenceDiagram
participant S as 要睡的线程 S
participant C as AtomicCounters
participant J as 注入队列 / 广播队列
participant P as 外部发布方 P
S->>C: 第 32 轮:announce_sleepy,记下 JEC = x(偶数)
S->>J: 再找一轮,没找到
S->>S: latch.get_sleepy,锁住 is_blocked,latch.fall_asleep
S->>C: 读计数:JEC 仍是 x?是则 CAS:sleeping +1
P->>J: push 任务
P->>P: fence(SeqCst)
P->>C: new_jobs:JEC 偶则 +1,读 sleeping
S->>S: fence(SeqCst)
S->>J: has_injected_jobs?
alt 看到任务
S->>C: 自己 sleeping -1,回去找活
else 没看到
S->>S: is_blocked = true,Condvar::wait(放锁)
P->>S: 看到 sleeping > 0:wake_any_threads,锁 is_blocked,置 false,notify_one,sleeping -1
end
入口:wait_until_cold 的内层循环
registry.rs 第 780–817 行(WorkerThread 主循环逐行讲过):
let mut idle_state = self.registry.sleep.start_looking(self.index);
while !latch.probe() {
if let Some(job) = self.find_work() {
self.registry.sleep.work_found();
self.execute(job);
// The job might have injected local work, so go back to the outer loop.
continue 'outer;
} else {
self.registry
.sleep
.no_work_found(&mut idle_state, latch, || self.has_injected_job())
}
}
每一「轮」= 一次 find_work(本地 deque、自己的广播队列、随机起点轮询所有别的线程、全局注入队列)+ 一次 no_work_found。latch 是这个线程正在等的那个 latch:join 里等 B 时是 B 的 SpinLatch,主循环里是自己的 terminate。最后一个参数是一个闭包,sleep 模块不认识 WorkerThread,「有没有注入的任务」要由调用方告诉它。
轮数:32 次让出,宣布,再一轮,睡
pub(super) fn no_work_found(
&self,
idle_state: &mut IdleState,
latch: &CoreLatch,
has_injected_jobs: impl FnOnce() -> bool,
) {
if idle_state.rounds < ROUNDS_UNTIL_SLEEPY {
thread::yield_now();
idle_state.rounds += 1;
} else if idle_state.rounds == ROUNDS_UNTIL_SLEEPY {
idle_state.jobs_counter = self.announce_sleepy();
idle_state.rounds += 1;
thread::yield_now();
} else if idle_state.rounds < ROUNDS_UNTIL_SLEEPING {
idle_state.rounds += 1;
thread::yield_now();
} else {
debug_assert_eq!(idle_state.rounds, ROUNDS_UNTIL_SLEEPING);
self.sleep(idle_state, latch, has_injected_jobs);
}
}
ROUNDS_UNTIL_SLEEPY = 32,ROUNDS_UNTIL_SLEEPING = 33。数一下:
rounds0–31:32 次yield_now。rounds32:announce_sleepy(JEC 奇变偶,记下值),rounds变 33,再yield_now一次。rounds33:进sleep()。
所以一个线程从开始找活到尝试睡下,做了 34 次 find_work、33 次 thread::yield_now()。第三个分支(32 < rounds < 33)在现在的常量下永远走不到,宣布 sleepy 之后实际只多找了一轮,和 README「宣布后再搜索一次」的描述一致。
本机一次 std::thread::yield_now() 的耗时(sleep-idle yield-cost,单线程连续调用 10 万次,三次,1 / 5 分钟负载约 1.7 / 2.95)是 113–118 ns,33 次合计约 4 µs。find_work 在 12 线程的池里要轮询 11 个别人的 deque 加注入队列。两者加起来,一个找不到活的线程在睡前转多久,唤醒策略与实测按线程量了 CPU 时间。
和 crossbeam 的 Backoff 对比:Backoff 是「同一件事再试一次」之间的等待,前几步用 spin_loop 自旋 1、2、4…64 次,之后才 yield_now,第 11 次后提示调用方去阻塞;rayon 这里每一轮都是一次完整的找活,两轮之间无条件 yield_now,没有自旋,轮数固定。rayon 不依赖 Backoff。
sleep():五道关卡
fn sleep(
&self,
idle_state: &mut IdleState,
latch: &CoreLatch,
has_injected_jobs: impl FnOnce() -> bool,
) {
let worker_index = idle_state.worker_index;
if !latch.get_sleepy() {
return;
}
let sleep_state = &self.worker_sleep_states[worker_index];
let mut is_blocked = sleep_state.is_blocked.lock().unwrap();
debug_assert!(!*is_blocked);
// Our latch was signalled. We should wake back up fully as we
// will have some stuff to do.
if !latch.fall_asleep() {
idle_state.wake_fully();
return;
}
loop {
let counters = self.counters.load(Ordering::SeqCst);
// Check if the JEC has changed since we got sleepy.
debug_assert!(idle_state.jobs_counter.is_sleepy());
if counters.jobs_counter() != idle_state.jobs_counter {
// JEC has changed, so a new job was posted, but for some reason
// we didn't see it. We should return to just before the SLEEPY
// state so we can do another search and (if we fail to find
// work) go back to sleep.
idle_state.wake_partly();
latch.wake_up();
return;
}
// Otherwise, let's move from IDLE to SLEEPING.
if self.counters.try_add_sleeping_thread(counters) {
break;
}
}
// Successfully registered as asleep.
// We have one last check for injected jobs to do. This protects against
// deadlock in the very unlikely event that
//
// - an external job is being injected while we are sleepy
// - that job triggers the rollover over the JEC such that we don't see it
// - we are the last active worker thread
std::sync::atomic::fence(Ordering::SeqCst);
if has_injected_jobs() {
// If we see an externally injected job, then we have to 'wake
// ourselves up'. (Ordinarily, `sub_sleeping_thread` is invoked by
// the one that wakes us.)
self.counters.sub_sleeping_thread();
} else {
// If we don't see an injected job (the normal case), then flag
// ourselves as asleep and wait till we are notified.
//
// (Note that `is_blocked` is held under a mutex and the mutex was
// acquired *before* we incremented the "sleepy counter". This means
// that whomever is coming to wake us will have to wait until we
// release the mutex in the call to `wait`, so they will see this
// boolean as true.)
*is_blocked = true;
while *is_blocked {
is_blocked = sleep_state.condvar.wait(is_blocked).unwrap();
}
}
// Update other state:
idle_state.wake_fully();
latch.wake_up();
}
| 关卡 | 检查 | 失败时 | 挡住的是 |
|---|---|---|---|
| 1 | latch.get_sleepy():UNSET → SLEEPY | 直接返回(latch 已 SET,外层循环 probe 后退出) | 等的事已经发生 |
| 2 | 拿 is_blocked 锁 | — | 为下面所有步骤建立「叫醒方必须等我」的前提 |
| 3 | latch.fall_asleep():SLEEPY → SLEEPING | wake_fully,返回 | 1、3 之间 latch 被设置 |
| 4 | JEC 是否还是宣布时记下的值;没变就 CAS 登记 sleeping | wake_partly(rounds = 32,回到宣布之前),latch.wake_up,返回 | 宣布之后有人发布了任务(内部或外部) |
| 5 | fence(SeqCst) 后 has_injected_jobs():全局注入队列或本线程广播队列非空 | 自己减 sleeping,wake_fully,回去找活 | 外部注入的任务,包括 JEC 回绕恰好掩盖了它的情况 |
关卡 4 失败时只回到「宣布之前」:下一次 no_work_found 时 rounds == 32,立刻重新宣布 sleepy、再找一轮,而不是从头转 32 轮。README 的解释:JEC 变了说明有新任务,再找一次多半能找到;找不到就马上再试着睡。
关卡 5 失败时 README 说的是「回到 sleepy 之前的状态,就像 JEC 变了一样」,代码却是 wake_fully()(轮数归零)。这是上游文档与代码的出入,以代码为准:发现注入任务的线程会从头开始找活,找到就执行,找不到要再转满 33 轮。
关卡都过了才 *is_blocked = true 进 Condvar::wait,while *is_blocked 循环挡住虚假唤醒:只有叫醒方把它置回 false 才算真的被叫醒。醒来后 wake_fully(轮数归零,回到 idle)、latch.wake_up(SLEEPING → UNSET,latch 已被设置则不动)。
为什么先拿锁再登记
关卡 2 在关卡 3、4 之前拿锁,一直持有到 Condvar::wait 才放。叫醒方(wake_specific_thread)必须拿同一把锁才能看 is_blocked:
fn wake_specific_thread(&self, index: usize) -> bool {
let sleep_state = &self.worker_sleep_states[index];
let mut is_blocked = sleep_state.is_blocked.lock().unwrap();
if *is_blocked {
*is_blocked = false;
sleep_state.condvar.notify_one();
self.counters.sub_sleeping_thread();
true
} else {
false
}
}
于是叫醒方只会看到两种状态之一:
- S 还没拿锁,或者已经在某道关卡放弃、放了锁:
is_blocked == false,叫醒方什么也不做。S 醒着,后面的关卡(或下一轮find_work)会自己发现新情况。 - S 已经过了关卡 4(登记了 sleeping):叫醒方被挡在锁外,直到 S 进
wait放锁,此时is_blocked已经是true,叫醒方把它改成false、notify_one、替 S 减 sleeping。
不存在「S 登记了 sleeping、叫醒方看到 is_blocked == false 走了、S 再置 true 睡下」这种交错。源码注释(第 180–184 行)说的就是这一点。
三种丢唤醒,各由哪里挡
要防的总是同一个形状(README「The jobs event counter and deadlock」一节):S 找活没找到;P 发布了任务,但没看到有人在睡,没叫;S 睡下。按任务的来源分三种:
1. S 等的 latch 被设置
join 的 B 被偷走后,主人 S 在 B 的 latch 上 wait_until,窃取者跑完 B 后 Latch::set。这种情况不经过计数器,靠的是 latch 自身的四态:swap(SET) 和 S 的 get_sleepy / fall_asleep 两个 CAS 在同一个原子变量上排成全序,swap 读到 SLEEPING 才去 wake_specific_thread,再加上关卡 2 的锁。三种交错在 Latch 家族的 CoreLatch 一节逐个列过。
2. 内部任务:别的 worker 往自己 deque 里 push
WorkerThread::push 之后调 new_internal_jobs,直接进 new_jobs,没有 fence。
- P 的
new_jobs发生在 S 宣布之后:S 宣布时把 JEC 变成偶数,P 看到偶数就加一,S 在关卡 4 看到 JEC 变了,回去再找。JEC 是同一个原子变量上的读-改-写,这一步不会漏。 - P 的
new_jobs发生在 S 宣布之前:P 看到的 JEC 可能是奇数,不写;也没看到 sleeping,不叫。S 宣布后再找的那一轮可能看不到 P 刚推的任务(P 推任务和 S 偷任务之间没有 fence)。S 会睡下。
第二种情况是允许发生的。README:「如果新任务来自线程池内部,这种竞态危害不大,意味着处理任务的 worker 比应有的少,但不会死锁」。P 是一个活着的 worker,它推了任务,之后自己总会弹出来执行(或者在 join 里等它的时候执行)。代价只是少了一个帮手。
3. 外部任务:池外线程注入
Registry::inject 把任务推进全局 Injector,调 new_injected_jobs:
pub(super) fn new_injected_jobs(&self, num_jobs: u32, queue_was_empty: bool) {
// This fence is needed to guarantee that threads
// as they are about to fall asleep, observe any
// new jobs that may have been injected.
std::sync::atomic::fence(Ordering::SeqCst);
self.new_jobs(num_jobs, queue_was_empty)
}
这里不能接受「少一个帮手」:注入方 P 不是 worker,不会自己执行这个任务。如果所有 worker 都按第 2 种情况的交错睡下,任务就永远留在队列里,in_worker_cold 的调用方在 LockLatch 上永远等下去。
挡住它的是关卡 5 和这里的 fence。README「Using seq-cst fences to prevent deadlock」一节的证明梗概:注入方依次做 PushJob(写 J,Release)、PushFence、ReadSleepers(读计数 C);要睡的线程依次做 IncSleepers(CAS 写 C)、SleepFence、ReadJob(读 J,Acquire)。两个 SeqCst fence 在全序里总有先后:
- PushFence 在前:SleepFence 之后的 ReadJob 一定看到 PushJob,S 在关卡 5 发现任务,不睡。
- SleepFence 在前:PushFence 之后的 ReadSleepers 一定看到 IncSleepers,P 知道有人在睡,
new_jobs去叫(叫几个的规则见 唤醒策略与实测)。
两者至少发生一个,所以不会出现「P 没看到有人睡、S 也没看到任务」。这正是 C++ 内存模型里 SeqCst fence 的典型用法(crossbeam 卷的内存序一章讲过同一个「两边各一个 fence」的形状,crossbeam-deque 的 pop / steal 也是它)。只用 Acquire / Release 做不到:README 指出内存模型给的保证是「如果你看到了 A,就一定看到 A 之前的那些」,但不保证「你一定看到 A」。
为什么有了 JEC 还要关卡 5
在大多数交错里,JEC 就够了:P 在 S 宣布之后发布,S 在关卡 4 看到 JEC 变了。源码注释把关卡 5 的作用说成防一种「非常不可能」的情况:外部注入发生在 S sleepy 期间,这次注入恰好使 JEC 回绕到 S 记下的值,而 S 又是最后一个活着的 worker。JEC 在 64 位平台上有 32 位,回绕到同一个值需要 2³² 次递增,而奇偶编码下每次递增都要求「上一次是对方写的」,也就是 2³¹ 轮「有线程宣布 sleepy、有人发布任务」的交替,要在 S 两次读 JEC 之间(关卡 4 前后几百纳秒)完成,实际不可能;32 位平台上 JEC 只有 16 位,难度小得多。按上面的证明梗概,fence 配对对外部任务的保证不依赖 JEC,所以关卡 5 实际兜住的是所有「外部任务 + JEC 没帮上忙」的交错,回绕只是其中最极端的一种(这是按代码和证明推出来的,注释本身只提回绕)。
关卡 5 是 2020-04 的 1222cbe「check one last time for injected jobs before sleep」加的,和「允许 JEC 在任意点回绕」(908e137)同一天前后。2022 年 be12615 把它扩展到本线程的广播队列,那次修复的分析见 broadcast。
内部任务为什么不加 fence
new_internal_jobs 比 new_injected_jobs 少一个 fence(SeqCst),而它在每次 WorkerThread::push 时都会调用,包括每一次 join。这是 README 明确的取舍:「推到内部队列的任务不需要 fence,因为在那种情况下漏看任务是可以接受的」。热路径上的每次 join 只多一次 SeqCst 的读(JEC 是奇数时)或一次 CAS(偶数时),不付 fence 的代价。
醒来之后
被叫醒的线程从 sleep() 返回时 rounds = 0,回到 wait_until_cold 的内层循环:先 probe 自己的 latch,再 find_work。找到活就 work_found,找不到就重新转 33 轮再睡。它不知道自己为什么被叫醒,也不需要知道:叫醒只是「去看一眼」的提示。唤醒策略与实测里实测的「被叫醒却没活干的线程各自空转几十微秒再睡回去」,就是这一段。
小结
- 找不到活的线程做 34 次
find_work、33 次thread::yield_now()后进sleep();第 32 轮宣布 sleepy,之后只多找一轮。 sleep()五道关卡:latch 已设置(两处)、JEC 变了、注入队列或广播队列非空;全部通过才is_blocked = true进Condvar::wait。- 关卡 2 先拿
is_blocked锁再登记,叫醒方要拿同一把锁,所以只会看到「还醒着」或「已经在等」两种状态。 - 内部任务允许被漏看(发布者自己会执行,只是少个帮手),不加 fence;外部注入不能漏,注入方与睡前检查各一个 SeqCst fence,保证至少一方看到另一方。
- JEC 回绕是关卡 5 注释里写的理由;fence 配对对外部任务的保证并不依赖 JEC。