Rust in Everything
基于 rayon-core 1.13.0

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: 找到活

三章:

  1. 状态机与计数器:active / idle / sleepy / sleeping 四种状态;AtomicCounters 把睡眠线程数、空闲线程数和「任务事件计数器」(JEC)打包进一个 AtomicUsize;JEC 的奇偶编码:偶数表示「最近一次递增来自要睡的线程」,奇数表示「来自发布任务的一方」,发布任务时只有看到偶数才需要写;CachePadded 包着的每线程 Mutex<bool> + Condvar。
  2. 睡眠与唤醒的握手:主线 3 的前半逐行读。no_work_found 的轮数;sleep() 里的五道关卡;为什么先拿 is_blocked 锁再登记;丢唤醒的三种场景(latch 被设置、内部任务、外部注入),各由哪一步挡住;两道 SeqCst fence 的配对和 README 里的证明梗概;JEC 回绕时的最后一道防线。
  3. 唤醒策略与实测: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-05233540d,合并于 #348rayon 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-182803c59「add new thread-pool」—新调度器开始;SpinLatch::new 从无参数改为接收当前 WorkerThread,latch 开始记录主人是谁
2020-04-11 至 05-15d511364、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-15e768237「Use wrapping addition for JEC」,#800rayon-core 1.8.1JEC 递增改为 wrapping_add。发布说明:高竞争负载下一个「本该回绕」的计数器溢出 panic,只在开启 debug 断言时出现,32 位目标上更容易触发
2022-04-06d8e9059,#926rayon 1.5.2 / rayon-core 1.9.2线程数软上限 THREADS_MAX(计数器位宽决定:64 位 65535、32 位 255),公开为 max_num_threads()
2022-11-18be12615,#992rayon-core 1.10.1睡前最后检查同时看本线程广播队列(broadcast)
2023-09191ade2「Remove the semi-secret logging」rayon 1.8.0 / rayon-core 1.12.0去掉 sleep 模块里散布的 log! 事件(发布说明:「内部日志设施已移除」)
2023-12c9628ca,#1110rayon 1.8.1 / rayon-core 1.12.1crate::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() 的耗时睡眠与唤醒的握手

💬 关联讨论

发起讨论
加载中...