Rust in Everything
基于 rayon 1.12.0

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(),结果不变,速度变快。这句承诺有三层:

  1. 结果不变:README 说并行迭代器「guaranteed to produce the same results as their sequential counterparts」,紧接着给出例外:有副作用(往 channel 里发、写磁盘)时,副作用的顺序可能不同。还有一个 README 没写的例外:浮点加法不满足结合律,sum::<f32>() 的结果随切分方式变化(fold、reduce 与 sum)。
  2. 不会出数据竞争:par_iter() 要求元素 Sync、闭包 Send + Sync,join 要求两个闭包都 Send。编译期挡住,不靠运行时检查。
  3. 变快:这一条没有保证。任务太小时并行的固定开销比计算本身还大(本卷 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 只给积木
rayonrayon:固定大小的池,默认等于逻辑核数rayon:join 树 + 工作窃取fork-join,CPU 密集,最好无阻塞阻塞一个 worker 就少一个干活的线程;全部阻塞可能饿死
tokiotokio:少量 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
burnFlex 后端只在大张量上开 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 里直接调。

💬 关联讨论

发起讨论
加载中...