rayon 解决什么问题
主线位置:四条主线的出发点。本页不读代码细节,只说清楚 rayon 承诺了什么、靠什么兑现、哪些场合兑现不了。
README 的第一句是「Rayon is a data-parallelism library for Rust」,第一个例子是:
use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
input.par_iter() // <-- just change that!
.map(|&i| i * i)
.sum()
}
把 iter() 改成 par_iter(),结果不变,速度变快。这句承诺有三层:
- 结果不变:README 说并行迭代器「guaranteed to produce the same results as their sequential counterparts」,紧接着给出例外:有副作用(往 channel 里发、写磁盘)时,副作用的顺序可能不同。还有一个 README 没写的例外:浮点加法不满足结合律,
sum::<f32>()的结果随切分方式变化(fold、reduce 与 sum)。 - 不会出数据竞争:
par_iter()要求元素Sync、闭包Send + Sync,join要求两个闭包都Send。编译期挡住,不靠运行时检查。 - 变快:这一条没有保证。任务太小时并行的固定开销比计算本身还大(本卷 E13:从普通线程调一次 16 个元素的
par_iter().sum()要十几微秒,见 in_worker:从哪进池)。
三个词:数据并行、fork-join、工作窃取
数据并行:同一个操作作用在一大批数据上,数据可以切开各算各的,最后合并。map、filter、sum、sort 都是这一类。和它相对的是「任务并行」——一组不同的、可能互相等待的任务,那是 tokio 或线程加 channel 的地盘。
fork-join:rayon 只有一个并行原语 join(a, b):「a 和 b 可能并行执行,两个都做完才返回」。迭代器把数据对半切,左半一个闭包、右半一个闭包,交给 join;每一半再切,形成一棵二叉树。join 是阻塞的、有返回值的,所以两个闭包可以借用调用方栈上的数据——这是 rayon 能把 &[T] 直接交给多个线程而不需要 Arc 的原因。
工作窃取:join 并不立刻开线程。它把 b 推进当前线程的本地双端队列,自己先跑 a;跑完 a 回头看,b 还在就自己跑,于是不需要任何跨线程通信。别的线程闲着时,会从这个队列的另一端把 b「偷」走——这才真的并行了。rayon-core 的 crate 文档就是这样描述 join 的:「It will run in parallel if task B gets stolen before task A can finish. It will run sequentially if task A finishes before task B is stolen」(lib.rs:5-7)。
flowchart TB root["sum(0..n)"] -->|join| l["sum(0..n/2)"] root -->|join| r["sum(n/2..n)"] l -->|join| ll["左左"] l -->|join| lr["左右"] r -->|"join(被偷到别的线程)"| rl["右左"] r -->|join| rr["右右"]
这三件事合起来,就是 rayon 的全部设计空间:只要计算能写成「切开、各自算、合并」,切多细由运行时根据有没有线程来偷动态决定(一次 par_iter),用户不需要知道有几个核。
和相邻工具的分工
| 谁管线程 | 谁管任务 | 任务形状 | 「阻塞」的含义 | |
|---|---|---|---|---|
std::thread::spawn / scope | 你:每次调用开一个 OS 线程 | 你 | 一个线程一个任务 | 阻塞的是那一个 OS 线程,代价自己承担 |
| crossbeam | 你(crossbeam::scope 或 std) | 你,用它的 deque / channel 自己搭 | 任意 | 同上;crossbeam 只给积木 |
| rayon | rayon:固定大小的池,默认等于逻辑核数 | rayon:join 树 + 工作窃取 | fork-join,CPU 密集,最好无阻塞 | 阻塞一个 worker 就少一个干活的线程;全部阻塞可能饿死 |
| tokio | tokio:少量 worker + 阻塞池 | tokio:Future 在 .await 处让出 | IO 密集、大量并发等待 | 任务里同步阻塞会占住 worker,同 worker 上的任务全部等待 |
两个要点:
- rayon 和 tokio 是互补的。 tokio 文档建议把 CPU 密集的活交给 rayon 这类专用池;tokio 卷 阻塞池 也说「如果要跑大量 CPU 密集的计算,开几百个线程只会互相抢 CPU,更合适的是用固定大小的线程池(比如 rayon)」。反过来,在 async 任务里直接调
par_iter会让 tokio worker 阻塞在 rayon 的LockLatch上,见 in_worker:从哪进池 和 tokio 卷 阻塞在 async 里 的「rayon + oneshot」改法。 - rayon 是 crossbeam 的用户。 rayon-core 的每个 worker 用 crossbeam-deque 的
Worker/Stealer,全局注入队列是Injector,sleep 模块的状态数组用CachePadded包着。这些机制本卷不重讲,链到 crossbeam 卷 05 deque。
两层 crate
rayon 拆成两个 crate,2017-02-28 拆开(6b70f1b):
- rayon-core:线程池和
join/scope/spawn/broadcast这几个原语,约 5000 行非测试代码。crate 文档开头说它「houses the core stable APIs of Rayon」。它用links = "rayon-core"保证一个程序里只有一份,因而只有一个全局池(源码地图)。 - rayon:并行迭代器、并行 slice、并行排序、
str/ 集合的并行接口,2.7 万行。它把 rayon-core 的 API 原样重新导出,自己不开线程。
拆分的效果:rayon 可以频繁加 API、发新版本,rayon-core 保持稳定;依赖不同 rayon 版本的库仍共享同一个 rayon-core、同一个全局池。代价是 rayon-core 的公共 API 很难再改(08 设计取舍)。
前几卷里谁在用
| 卷 | 怎么用 rayon | 本卷哪里解释 |
|---|---|---|
| tantivy | 多线程搜索 executor 是 Arc<rayon::ThreadPool>,线程名前缀 tantivy-search-,在 pool.scope 里给每个段 spawn 一个任务;SegmentUpdater 有两个 rayon 池(单线程的元数据池、合并池,见 IndexWriter) | scope |
| candle | 卷积和 softmax 用 par_chunks 跑在全局池上;with_context 把闭包 install 进 candle 自己的池;RAYON_NUM_THREADS 同时决定 get_num_threads() 和全局池大小 | ThreadPoolBuilder、in_worker |
| pytorch-to-candle | 采样里 217 个样本有 205 个在 rayon 的 in_worker_cold,栈顶 __psynch_cvwait:主线程把 9 行 softmax 交给 rayon 后睡下等 | in_worker |
| burn | Flex 后端只在大张量上开 rayon(求和约 16 MiB、矩阵乘 M·N·K ≥ 192³) | 09 常见坑 |
| tenferro | 每个池私有一个 rayon 池,公开 API 不暴露 rayon:: | in_worker |
| 本站 | 不直接依赖;经 tantivy 和桌面端的 image 间接带进 rayon 1.12.0(crossbeam 卷:在本站中) | 10 在本站中 |
这些用法有一个共同点:真正决定性能的是「每次交给 rayon 的活有多大」。burn 和 candle 的阈值、candle 的 softmax 现象,都是同一个问题的不同表现。
不适合的场景
| 场景 | 为什么 | 本卷哪里 |
|---|---|---|
| 任务里有 IO 或其他阻塞等待 | 池子大小固定,阻塞一个 worker 就少一个;所有 worker 都在等一个还没开始的 rayon 任务时会饿死 | 09 常见坑 |
| 极小的任务(几十个元素、几微秒的活) | 从池外进池一次就要十几微秒(E13);join 本身也有约 6.6 ns 的固定开销(E1) | in_worker、join |
| 副作用需要确定的顺序 | 叶子的执行顺序取决于谁偷了谁 | 06 并行迭代器 |
| 持锁调用嵌套的并行代码 | 等待时会执行偷来的任务,同一线程可能重入同一把锁 | WorkerThread 主循环、09 常见坑 |
| 在 async 任务里直接调 | 调用线程不是 worker,阻塞在 Condvar 上 | in_worker |
小结
- rayon 的承诺:结果与串行相同(副作用顺序和浮点求和除外)、编译期排除数据竞争;「变快」没有保证。
- 实现只靠一个原语
join:B 进本地 deque、自己跑 A、空闲线程来偷 B。迭代器是join的二分递归。 - 和 tokio 互补(CPU 密集交给 rayon,IO 交给 tokio),建在 crossbeam 的 deque 之上。
- 两层 crate:rayon-core 小而稳定、全进程唯一;rayon 是上层 API。
- 不适合:阻塞、极小任务、需要有序副作用、持锁嵌套、在 async 里直接调。