设计取舍
主线位置:四条主线的汇总。「一次 join」上的取舍是第 2、3、4、9 条,「一次 par_iter」上的是第 6、10 条,「一个线程睡下又醒来」上的是第 5 条,「设置之后不能再碰」横线是第 3 条的代价;第 1、7、8 条是进程级的选择。机制都在前面各章,这里每条只写做法、换来什么、代价,证据链到有数字的那一页。
flowchart TB two["1 两层 crate + links"] --> c1["rayon-core 的 API 难改"] join["2 join 是唯一原语"] --> c2["粒度只能间接控制"] stack["3 栈上任务 + 手写 JobRef"] --> c3["unsafe 与「set 后不碰」纪律"] work["4 等待时干活"] --> c4["持锁重入、TLS、install 插队"] sleep["5 先转后睡 + JEC"] --> c5["唤醒延迟、要 SeqCst fence 才能证明"] split["6 自适应切分"] --> c6["叶子数不确定、浮点和不确定"] panic["7 spawn panic 默认 abort"] --> c7["第三方库能杀进程"] once["8 全局池懒初始化一次"] --> c8["谁先调用谁决定配置"] fixed["9 固定大小的池"] --> c9["阻塞的任务会饿死池"] sort["10 排序抄 std 旧版"] --> c10["小 n 比 std 慢"]
1. 两层 crate,rayon-core 全进程唯一
做法:线程池和 join / scope / spawn 等原语放在 rayon-core,迭代器、slice、排序放在 rayon,2017-02-28 拆开(6b70f1b)。rayon-core 用 links = "rayon-core" 加一个空 build.rs,让 Cargo 拒绝在一张依赖图里出现两份;CI 的 ci/highlander 专门验证「链接两份会失败」(源码地图)。
换来:全局池是 rayon-core 里的一个 static,只有一份 rayon-core 就只有一个全局池;依赖不同 rayon 版本的库共享同一组线程,不会各开一套核数大小的池。rayon 可以频繁加 API(1.12.0 加了 par_array_windows),rayon-core 很少动(1.13.0 到 v1.12.0 零差异)。
代价:rayon-core 的公共 API 一旦改成不兼容的 2.0,整个生态要同时升级,否则 links 冲突直接构建失败;所以它基本只能加不能改,旧的 Configuration、initialize 只能标废弃留着(ThreadPoolBuilder)。
2. join 是唯一的并行原语
做法:rayon 本体不开线程,所有并行迭代都翻译成 join_context 的二分递归;scope / spawn 是另一组 API,迭代器不用(并行迭代器)。
换来:调度器只需要做好一件事:一个 push、一个 pop、偶尔一次窃取。1 线程下每次 join 约 6.6 ns(E1,join 与 join_context);递归结构让窃取者总是拿到最老、也最大的那块。
代价:任务粒度由切分器决定,用户只能经 with_min_len / with_max_len 间接控制。E4:10⁶ 个轻量元素默认 0.32 ms,with_min_len(10⁴) 0.11 ms,with_max_len(10) 0.49 ms(bridge 与自适应切分)。递归到底的 fib 在 12 线程上只有 1.8 倍(E1)。
3. 栈上任务 + 手写胖指针 JobRef
做法:join 的 B 是调用方栈帧上的 StackJob,推进 deque 的是 16 字节的 JobRef{pointer, execute_fn},类型和生命周期都擦掉了(JobRef 与几种 Job)。
换来:join 不分配堆内存;deque 里一种元素类型装下所有任务;id() 让 join 一次比较就认出弹回来的是不是自己的 B。
代价:正确性全靠纪律。JobRef::new 是 unsafe fn,「每个 JobRef 恰好执行一次」「执行前数据一直有效」都不在类型里。最隐蔽的是「设置之后不能再碰」:Latch::set 之后等待方可能立刻返回、销毁 latch 所在的栈帧,所以 set 收 *const Self 而不是 &self,每个实现都先读完要用的字段再 set(a46e1f8,#1011,miri 发现;Latch 家族)。这些顺序写错了,绝大多数运行照样正常。
4. 等待时干活,而不是阻塞
做法:worker 等一个 latch 时进 wait_until_cold,先弹本地、再偷、再看注入队列,拿到什么执行什么,就在当前栈帧之上(WorkerThread 主循环)。跨池 install 也一样(in_worker:从哪进池)。
换来:嵌套多深的 join 都不会耗尽线程,不需要为等待额外开线程;等待的时间被用来干活,吞吐不受嵌套影响。
代价:任意任务可能跑在任意等待者的栈上,「这段代码执行期间本线程不跑别的」这个假设不成立:
- 持锁调 rayon,偷来的任务再加同一把锁:确定性复现 20/20,普通写法 10 次 7 次卡死(E7,常见坑);
- 跨池
install的等待让本池的任务插进来:1 线程全局池上 200/200 次one one two two(E6,常见坑); - 按任务设置的 thread_local 被嵌套执行的任务改掉:200 个元素里 9–18 个(常见坑)。
5. 先转后睡,JEC 奇偶编码
做法:找不到活的线程先 thread::yield_now() 32 轮,宣布 sleepy,再找一轮,然后在自己的 Condvar 上睡;发布任务的一方看打包在一个 AtomicUsize 里的计数器决定叫醒几个,JEC 的奇偶表示「最近一次是有人要睡,还是有人发了任务」(状态机与计数器、睡眠与唤醒的握手)。
换来:池空闲 1 秒,12 个线程合计只花 7–14 µs CPU;忙的时候每次 push 只付一次原子读,没人睡就不叫(唤醒策略与实测)。
代价:两头都有。刚空闲时线程还在转,从池外连续调 1000 次空 join,12 线程期间花的 CPU 是墙钟的 3–9 倍;空闲 10 ms 以上线程都睡了,唤醒延迟升到 15–36 µs(E3,同一页)。正确性要靠一套协议:先拿 is_blocked 锁再登记、注入方和睡前检查各一个 SeqCst fence,才能证明外部注入的任务不会被漏看;内部任务则允许被漏看(睡眠与唤醒的握手)。广播队列曾在睡前检查里被漏掉(#992,1.10.1 修,broadcast)。
6. 自适应切分:被偷才多切
做法:Splitter 初值为线程数,不被偷就每层减半,被偷(migrated())就重置为 max(线程数, splits/2)(bridge 与自适应切分)。
换来:不需要用户给块大小;有空闲线程时自动切细,忙时少切。重计算(每元素约 170 ns)在 12 线程上默认就有 9.97 倍,各种 min_len / max_len 手调都没有明显更好(E4,差别在 ±10% 以内)。
代价:叶子数每次运行不同,从池外调用还会多切一层(12 线程最少 64 片,池内 16 片;E2)。依赖树形的结果因此不确定:f32 求和 100 次 4–5 个不同结果(常见坑),reduce 的 identity 调用次数、fold 的输出个数都随之变化(fold、reduce 与 sum)。filter 之后的 collect 每个叶子一个 Vec,叶子越多分配越多(E8,常见坑)。
7. detached 任务的 panic 默认 abort
做法:spawn / spawn_fifo / spawn_broadcast 的闭包 panic 交给 panic_handler,没有 handler(或 handler 自己 panic)就打印一行后 process::abort()(spawn 与 spawn_fifo)。
换来:panic 不会悄悄丢掉。join / scope 能把 panic 传回调用方,spawn 没有调用方,文档的原则是宁可让进程退出。
代价:依赖链里任何一个库的 rayon::spawn 闭包 panic,在没设 handler 的进程里都会让整个进程退出,退出码 134,外层 catch_unwind 拦不住(E11,常见坑)。对照 tokio:spawn 的 panic 进 JoinError,没人看就没人知道。两种默认各有各的失败方式。
8. 全局池懒初始化,只建一次
做法:static mut THE_REGISTRY + Once;第一次隐式使用全局池就用默认配置建好,build_global 只是抢先用自己的配置跑那一次 call_once(全局池与 Registry)。
换来:零配置可用:par_iter() 第一次调用时池就起来了,不需要任何初始化代码;线程数可以用 RAYON_NUM_THREADS 在部署时调。
代价:「谁先调用谁决定配置」。库代码或测试先碰了全局池,main 里的 build_global 返回 GlobalPoolAlreadyInitialized(E10,常见坑);隐式创建失败没有地方返回 Err,只能 panic(ThreadPoolBuilder 里 RAYON_NUM_THREADS=100000 的那次)。环境变量写错被静默忽略(常见坑)。
9. 固定大小的计算池,不为阻塞补线程
做法:线程数在建池时定死;rayon 自己的等待(第 4 条)从不阻塞 OS 线程,用户代码里的阻塞它不管。tokio 卷 阻塞池 的建议正是「大量 CPU 计算用固定大小的池(比如 rayon)」。
换来:线程数等于核数,CPU 密集的任务之间不互相抢;没有「池子突然涨到几百个线程」的情况。
代价:任务里阻塞(Condvar、recv、IO)就少一个线程,阻塞的任务数达到线程数、被等的任务还在队列里时整个池卡死:E12 确定性复现 1 / 2 / 4 / 12 线程都卡死,par_iter 写法 12 线程 10 次里 5 次(常见坑)。从池外(包括 tokio worker)进池是阻塞的:E5 里 current_thread 运行时停了 245 ms(常见坑)。从池外进池一次 17–21 µs,小任务不划算(E13,常见坑)。
10. 排序从 std 抄过来,然后停在那一版
做法:src/slice/sort.rs「mostly copied from core::slice::sort」,只把 FnMut 改成 Fn + Sync,在三处插入 join;最近一次同步是 2024 年的 #1156,随 1.11.0 发布(并行排序)。
换来:算法本身经过 std 的长期验证,rayon 只维护并行化那几处;par_sort_unstable 不分配。
代价:std 之后换成了 driftsort / ipnsort,rayon 保留的是旧的 pdqsort 和类 TimSort 归并。1 线程时只有 std 的 0.3 / 0.5 倍;n ≤ 2000 不进池,10³ 时慢 1.6–3.3 倍;12 线程在 10⁷ 上对 std 也只剩约 3 倍(E9,常见坑)。
小结
- 两层 crate 加
links换来全进程一个全局池,代价是 rayon-core 只能加不能改。 join是唯一原语、栈上任务、手写JobRef:调度快路径只有十纳秒量级,代价是unsafe和「set 之后不碰」的纪律,以及只能间接控制粒度。- 等待时干活让嵌套不耗尽线程,代价是持锁重入、thread_local 被改、跨池插队;用户自己阻塞时这层保护就没了,固定大小的池会饿死。
- 先转后睡加 JEC:空闲几乎不花 CPU,代价是十几到几十微秒的唤醒和一套要 fence 才能证明的协议。
- 自适应切分省掉了块大小参数,代价是树形不确定(浮点和、identity 次数、分配次数)。
spawn的 panic 默认 abort、全局池只建一次、排序停在 std 旧版:都是「零配置、少维护」的选择,代价落在没读文档的调用方身上。