一个线程睡下又醒来
主线位置:主线 3 的全程。本页只画状态和关键行,逐行的论证在 05 sleep 模块;latch 的四个状态在 Latch 家族。
一个 worker 什么时候会去睡?任何「等一个 latch」的地方:主循环在等自己的 terminate latch(WorkerThread 主循环),join 在等 B 的 latch(一次 join)。两者都进 wait_until_cold,找不到活时交给 sleep 模块。睡眠是每次等待的一部分,不是线程的全局状态:latch 被 set 时,等待者必须被叫醒并离开。
两套状态
sleep 模块同时维护两样东西:
全池共享的计数器 AtomicCounters:一个 AtomicUsize 里打包三段(64 位平台:低 16 位 sleeping 线程数、中 16 位 inactive 线程数、高 32 位 JEC,counters.rs:56-77)。inactive = 正在找活的(idle)+ 睡着的;JEC(jobs event counter)是一个只增不减的计数器,偶数表示最近一次递增来自「将要睡的线程」,奇数表示来自「发布任务的一方」。
每个等待者自己的 latch:CoreLatch 的四个状态 UNSET → SLEEPY → SLEEPING,以及任何时候都可能被别人置为 SET(latch.rs:58-69)。
stateDiagram-v2 [*] --> active: 执行任务 active --> idle: start_looking,inactive+1 idle --> active: 找到活或 latch 已 SET,work_found,inactive-1,再叫醒至多 2 个 idle --> sleepy: 第 32 轮 announce_sleepy,JEC 奇数则 +1 变偶数 sleepy --> idle: JEC 变了,wake_partly 回到 sleepy 前 sleepy --> sleeping: sleep,latch SLEEPY 再 SLEEPING,sleeping+1,Condvar wait sleeping --> idle: 被 notify,wake_fully,latch 回 UNSET sleepy --> active: latch 已 SET,等待结束
睡下去
wait_until_cold 的内层循环每找一轮没找到,就调一次 no_work_found:
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(sleep/mod.rs:56-57)。按轮数走一遍:
第 0–31 轮:每轮
find_work一次(本地 → 偷 → 注入队列),没有就std::thread::yield_now()。共 32 次让出 CPU,没有用 crossbeam 的Backoff(对照 Backoff)。第 32 轮:
announce_sleepy:JEC 若是奇数(最近有人发过任务)就 +1 变偶数,记下这个值。轮数变成 33。第 33 轮:再
find_work一次。这一轮是关键:宣布 sleepy 之后发布的任务,要么在这一轮被找到,要么会让 JEC 变化。进入
sleep,依次过五道关:latch.get_sleepy():CASUNSET → SLEEPY。失败说明 latch 已被 set,直接返回,等待结束。- 锁住自己的
is_blocked: Mutex<bool>。 latch.fall_asleep():CASSLEEPY → SLEEPING。失败同上。- 读计数器:JEC 和第 32 轮记下的不一样,说明期间有人发过任务而自己没看到,
wake_partly把轮数退回 32,回去再找。否则 CAS 把 sleeping +1。 fence(SeqCst)后最后看一眼全局注入队列和本线程的广播队列;有东西就自己把 sleeping -1,不睡。
全部通过,才
*is_blocked = true并condvar.wait。
源码里第三个分支(rounds < ROUNDS_UNTIL_SLEEPING)按上面的轮数永远走不到:第 32 轮之后轮数直接是 33;wake_partly 把轮数设回 32,下一轮又进第二个分支。这是按代码推出来的,没有实测。
叫醒
发布任务的一方有两个入口:worker 往本地 deque push 时调 new_internal_jobs,外部 inject 时调 new_injected_jobs(多一个 fence(SeqCst))。两者都进 new_jobs:
- JEC 若是偶数(有线程正在变 sleepy)就 +1 变奇数。这一步让那个线程在
sleep的第四道关看到「JEC 变了」。 - 没有睡着的线程:直接返回。这是常见情况,代价只是一次原子读(或一次 CAS)。
- 有睡着的线程:队列原本非空,叫醒
min(新任务数, 睡着数)个;队列原本为空,只在「醒着但空闲的线程数 < 新任务数」时叫醒差额。 wake_any_threads从 0 号线程开始逐个试wake_specific_thread:锁住对方的is_blocked,是 true 就置 false、notify_one,并由唤醒方把 sleeping -1(注释说这样做是为了不让后来者误以为还有人睡着、白叫一次)。
还有一个定向的入口:latch 被 set 时,CoreLatch::set 发现旧状态是 SLEEPING,就 notify_worker_latch_is_set(index),只叫醒那一个等待者(一次 join 第 8 步)。
醒来的线程回到 idle:wake_fully 把轮数清零,latch.wake_up() 把 SLEEPING 改回 UNSET(若 latch 还没被 set),然后重新开始 32 轮找活。离开 idle 时(找到活,或者等的 latch 已被 set),work_found 把 inactive -1,并按「还在睡的线程数,最多 2」再叫醒几个(sleep/mod.rs:80-85)——一个线程醒来干活,就再拉起至多两个,唤醒像链式反应一样扩散。
为什么要这么复杂
如果发布任务时每次都去 notify,那么 join 的每次 push 都要碰一把锁;如果从不 notify,睡着的线程就永远醒不来。rayon 的做法是用 JEC 的奇偶把两边的「意图」记在同一个原子变量里:
- 发布方只在有人正在变 sleepy(JEC 为偶数)时才需要改 JEC,只在有人已经睡着(sleeping > 0)时才需要拿锁。
- 将要睡的一方在宣布 sleepy 之后、真正睡之前,再检查一次 JEC:期间有任何发布,JEC 一定变过。
剩下的漏洞(例如 JEC 恰好回绕、外部注入和睡眠同时发生)由两个 SeqCst fence 和睡前最后一次看注入队列堵上。这套论证是 05 sleep 模块 的主体。空闲时的 CPU 占用、余温和唤醒延迟的实测(E3)也在那里。
每一步在哪一章
| 步 | 本卷 |
|---|---|
wait_until_cold 的双层循环、find_work | WorkerThread 主循环 |
CoreLatch 四态、get_sleepy / fall_asleep / wake_up / set | Latch 家族 |
| 计数器布局、JEC 奇偶、五道关、两个 fence、唤醒策略、实测 | 05 sleep 模块 |
CachePadded<WorkerSleepState> | crossbeam:CachePadded |
小结
- 睡眠属于「一次等待」:等
terminate的主循环和等 B 的join走同一条路。 - 找不到活:32 轮
yield_now,第 32 轮宣布 sleepy(JEC 奇变偶),再找一轮,然后经五道检查在自己的Condvar上睡下。 - 发布任务:JEC 偶变奇;没人睡就返回;有人睡就按队列原来是否为空决定叫醒几个,从 0 号线程叫起;唤醒方负责把 sleeping 计数减一。
- latch 被 set 时只定向叫醒那一个等待者;醒来干活的线程再叫醒至多两个。