sleep 模块
工作窃取线程池的空闲线程有两种坏结局:一直转着找活,白白烧 CPU;睡得太死,有活了没人叫醒,任务卡在队列里,或者整个池死锁。rayon-core/src/sleep/ 两个文件 597 行(mod.rs 324 行,counters.rs 273 行),外加一份 219 行的 README.md,就在回答这一个问题。
stateDiagram-v2 [*] --> active: 执行任务 active --> idle: 找不到活,start_looking(inactive +1) idle --> idle: no_work_found:yield_now,32 轮 idle --> sleepy: 第 32 轮 announce_sleepy(JEC 变偶数) sleepy --> idle: 再找一轮前,JEC 变了(wake_partly) sleepy --> sleeping: 登记 sleeping +1,在 Condvar 上等 sleeping --> idle: 被叫醒(叫醒方 sleeping -1) idle --> active: 找到活,work_found(inactive -1,最多再叫醒 2 个) sleepy --> active: 找到活
三章:
- 状态机与计数器:active / idle / sleepy / sleeping 四种状态;
AtomicCounters把睡眠线程数、空闲线程数和「任务事件计数器」(JEC)打包进一个AtomicUsize;JEC 的奇偶编码:偶数表示「最近一次递增来自要睡的线程」,奇数表示「来自发布任务的一方」,发布任务时只有看到偶数才需要写;CachePadded包着的每线程Mutex<bool>+Condvar。 - 睡眠与唤醒的握手:主线 3 的前半逐行读。
no_work_found的轮数;sleep()里的五道关卡;为什么先拿is_blocked锁再登记;丢唤醒的三种场景(latch 被设置、内部任务、外部注入),各由哪一步挡住;两道 SeqCst fence 的配对和 README 里的证明梗概;JEC 回绕时的最后一道防线。 - 唤醒策略与实测:
new_jobs决定叫醒几个、wake_any_threads从 0 号线程开始叫、work_found让每个醒来的线程再叫醒至多两个;实测一个任务注入睡着的池后恰好 0、1、2 号三个线程醒来、每个空转约几十微秒,空闲池的 CPU、唤醒延迟、从池外调一次join的往返时间,以及 candle 卷里 softmax「几十微秒」的来源。
和 03 任务与 Latch 的关系:线程睡觉时是「睡在某个 latch 上」——wait_until(latch) 的 latch 就是 sleep() 的参数,CoreLatch 的 SLEEPY / SLEEPING 两个状态是给这里用的(Latch 家族)。线程池主循环本身就是 wait_until(terminate),所以「池空闲时线程睡在哪」的答案是:睡在自己的 terminate latch 上。
不重讲的部分
CachePadded的对齐值与伪共享:crossbeam 卷的 CachePadded。- 「先转后睡」的另一种曲线:crossbeam 的 Backoff(自旋 2 的幂次、第 7 步起改让出、第 11 次后提示该阻塞)。rayon 不用
Backoff:它每轮都thread::yield_now(),32 轮后进入 sleepy,再一轮后睡。两者的对照在 睡眠与唤醒的握手。 - 内存序的分类方法:crossbeam 卷的内存序一章。
- tokio 的同类机制:任务窃取与空闲(一个原子里打包 searching 与 unparked 两个计数、「最后一个 searcher 叫醒下一个」)。rayon 的「
work_found时再叫醒至多两个」是同一种链式扩散,规则不同。
上游 README 与代码的出入
sleep/README.md 开头说它的内容「摘自 RFC,并打算保持更新」,同时承认会过时。对照代码,有三处不一致,页面一律以代码为准:
| README 的说法 | 代码 |
|---|---|
| 睡前最后检查发现注入任务时,「回到 sleepy 之前的状态,就像 JEC 变了一样」 | sleep() 第 171–193 行:自己减掉 sleeping 计数,然后 wake_fully(),轮数归零,而不是 wake_partly()(JEC 变了的那条路径才是回到 sleepy 之前) |
counters.rs 的注释以「THREADS_BITS 等于 10」为例:0–10 位睡眠数、10–20 位空闲数 | THREADS_BITS 在 64 位平台是 16、32 位平台是 8(第 55–60 行) |
latch.rs 的文档链到 README 的「tickle-then-get-sleepy」一节 | 当前 README 没有这一节;它属于 2020 年以前的旧 README(见下面「演进」) |
演进
只列 git log 可查的事实;RFC 5 原文不在本地,不引用其中的论证。
| 时间 | 提交 / PR | 版本 | 变化 |
|---|---|---|---|
| 2017-05 | 233540d,合并于 #348 | rayon 0.7.1 / rayon-core 1.0.2 | 修 #343:线程有时陷入无法前进的自旋,直到被 OS 抢占(发布说明原话:「a spin loop where they would be unable to make progress until they are pre-empted」) |
| 2017–2019 | — | — | 旧方案:全池一个 AtomicUsize state(最低位表示「有人在睡」,高位存「哪个线程 sleepy」,同一时刻只允许一个 sleepy 线程),一个 Mutex<()> + 一个 Condvar,发布任务时 tickle 一律 notify_all;32 轮后 sleepy,64 轮后睡(09428ec^1 版本的 sleep/mod.rs) |
| 2020-01-18 | 2803c59「add new thread-pool」 | — | 新调度器开始;SpinLatch::new 从无参数改为接收当前 WorkerThread,latch 开始记录主人是谁 |
| 2020-04-11 至 05-15 | d511364、e460cc4、908e137、1222cbe、71d214e、a0efb4a、c3a5304 等 | — | 计数从 u16 改成打包进 AtomicUsize;允许 JEC 在任意点回绕;1222cbe 加「睡前最后看一次注入队列」;a0efb4a「transition to even/odd scheme」,提交说明:「这样不必在每个新任务上都递增 JEC」;c3a5304 修正 is_sleepy 的位测试 |
| 2020-08-13 | 合并于 #746(09428ec) | rayon 1.4.0 / rayon-core 1.8.0(2020-08-24) | 发布说明:实现 RFC 5 的新调度器,「对新任务和被偷任务完成的通知使用定向唤醒,减少空闲线程浪费的 CPU」 |
| 2020-09-15 | e768237「Use wrapping addition for JEC」,#800 | rayon-core 1.8.1 | JEC 递增改为 wrapping_add。发布说明:高竞争负载下一个「本该回绕」的计数器溢出 panic,只在开启 debug 断言时出现,32 位目标上更容易触发 |
| 2022-04-06 | d8e9059,#926 | rayon 1.5.2 / rayon-core 1.9.2 | 线程数软上限 THREADS_MAX(计数器位宽决定:64 位 65535、32 位 255),公开为 max_num_threads() |
| 2022-11-18 | be12615,#992 | rayon-core 1.10.1 | 睡前最后检查同时看本线程广播队列(broadcast) |
| 2023-09 | 191ade2「Remove the semi-secret logging」 | rayon 1.8.0 / rayon-core 1.12.0 | 去掉 sleep 模块里散布的 log! 事件(发布说明:「内部日志设施已移除」) |
| 2023-12 | c9628ca,#1110 | rayon 1.8.1 / rayon-core 1.12.1 | crate::sync::{Mutex, Condvar} 在 web_spin_lock feature 下换成 wasm_sync 的实现(浏览器主线程上不能 atomics.wait) |
之后到 v1.12.0 只有 clippy、注释用词这类改动。
本部分的实验
都在 exp-rayon/sleep-idle/(E3)下,按模式区分;机器 M2 Max(8P + 4E),macOS,rustc 1.99.1。计时类数字在 1 分钟与 5 分钟负载都低于 3 时取得,负载写在对应表格旁。
| 模式 | 看什么 | 章 |
|---|---|---|
wakers | 一个任务注入睡着的池后,哪些 worker 的 CPU 时间变了、各用了多少(mach thread_info 按线程读) | 唤醒策略与实测 |
idle / spin | 空闲 1 秒的进程 CPU;反向:一个任务忙转 1 秒 | 同上 |
who / spread | 单个任务落在哪个线程;轻负载下任务在各线程上的分布 | 同上 |
latency / outside-join | 空闲不同时长后 spawn 到开始执行的延迟;从池外调一次空 join 的往返 | 同上 |
burst | 池外连续 1000 次小 join 期间和之后 1 秒的 CPU | 同上 |
yield-cost | 本机一次 std::thread::yield_now() 的耗时 | 睡眠与唤醒的握手 |
💬 关联讨论
发起讨论加载中...