开门见山说个事:如果你在 Rust 里写过需要并行的代码,你大概率已经被 rayon 的par_iter()惯坏了——一行代码把迭代器扔到多线程里跑,剩下的什么都不用管。又或者你在用 tokio 写异步服务时,会发现spawn出去的任务好像能自动保持负载均衡,不管你的 future 怎么嵌套、怎么await,系统总能把它们安排得明明白白。这背后就是 work-stealing 调度算法在干活。
但有意思的是,很多写了几年 Rust 的开发者,对 work-stealing 的理解停留在"线程闲着就去偷任务"这个模糊概念上。真被问到"它怎么偷?偷谁的?偷的时候会不会冲突?为什么 Rust 生态里 rayon 和 tokio 都选了这条路?"就卡壳了。这篇我就把这个话题掰开揉碎,从算法本身的原理讲到 Rust 生态里的工程实现,再给出我自己在实际项目中踩过的坑和调优建议。
1. 先从调度这件事说起:为什么静态分配靠不住
1.1 一个反直觉的结论:任务均分不一定均衡
想象一个非常典型的场景:你有一个Vec<Vec<f32>>,要对里面每一个子数组做某种耗时的数值计算。最直觉的并行方案是什么?把数组切成 N 份,N 等于你的 CPU 核数,每个线程处理一份。这就是所谓的静态分配(static partitioning)。看起来公平得不得了:每个线程拿同样多的数据,谁也不吃亏。
但实际跑起来你会发现,负载不均衡才是常态。举个我踩过的例子:我用这种静态切分方式去处理一组稀疏矩阵的分解,结果四个核上跑出来的时间差异接近 3 倍。为什么?因为每个矩阵的稀疏结构不同,有的矩阵分解起来几乎不费劲,有的则因为填充因子(fill-in)严重导致计算量暴涨。均分数据量,不等于均分计算量。这还没算上系统层面的干扰——CPU 频率波动、其他进程抢占、cache 命中率差异,这些都会让"看起来公平"的任务分配变得实际不公平。
更麻烦的是,你没法预先知道每个任务到底要跑多久。有些异构负载你要么提前做 profiling,要么靠经验调切分权重,但这两者都很脆弱。数据变了、场景变了,之前调好的权重就废了。静态分配的脆弱性,根源在于它把"任务分发"和"任务执行"硬绑在一起,缺少动态调整能力。
1.2 为什么动态任务队列是必然方向
既然静态分配不行,自然想到动态分配:搞一个全局共享的任务队列,所有线程从里面取任务。谁闲了谁来拿,拿完就跑。这样计算量不均衡的任务在全局层面被平均掉了——重的任务多占点时间,轻的任务少占点,反正每个线程都在不停地从队列里取活干。
这个方案能解决负载不均衡问题,但引入了新问题:竞争(contention)。所有线程都从同一个全局队列取任务,每次入队出队都要抢一把锁。线程数少的时候还好,线程数一多——尤其是现在服务器 CPU 动不动几十核——锁竞争会直接把并行的收益吃掉大半。
你可能想说:那用无锁队列不就行了?确实可行,但全局无锁队列也有瓶颈。一个队列再快,它的头部只有一个,所有线程都从同一个地方取元素,这本身就制造了"热点"。更严重的是 cache 一致性问题:队列头部的 cache line 会被所有核心来回争抢,表现为严重的 cache line bouncing。实际测试中,你甚至能看到线程数翻倍,性能却掉头往下的"负扩展"现象。
到这里,核心矛盾就很清晰了:静态分配无法应对负载不均衡,动态分配又受制于同步开销。Work-stealing 的出现,正是在这两者之间找到了一个工程上非常漂亮的平衡点。
2. Work-Stealing 的核心机制:人人都有一亩三分地,闲了就去邻居家借活干
2.1 双端队列:算法的心脏
Work-stealing 的基本结构其实不复杂。每个线程维护一个私有的双端队列(deque),这个队列只属于自己,平时的入队和出队操作都发生在自己身上,全程无锁。线程干完了自己队列里的活,才会尝试去别人的队列里"偷"任务。
这就像每家农户各自经营自己的田地,平时各干各的,不用跟邻居打招呼。只有自家的活干完了,才会扛着锄头去邻居家看看有没有能帮忙的活。
关键问题来了:任务怎么往自己队列里放和取?放是放哪一头,取又是取哪一头?这涉及到 deque 的三种操作:
- push:线程往自己的队列底部放入一个新任务。
- pop:线程从自己的队列底部取出一个任务来执行。注意,是底部,不是顶部。
- steal:空闲线程从别的线程队列的顶部取出一个任务。
你发现没有,push 和 pop 都发生在底部(也就是同一端),而 steal 发生在顶部(另一端)。这意味着什么?正常干活时,线程不会碰队列的顶端,只有"小偷"才会从顶端下手。
这个设计的精妙之处在于并发安全。因为一个队列只有两种并发访问模式:要么是"所有者线程操作底部",要么是"多个来袭线程操作顶部"(多个空闲线程可能会同时盯上同一个队列)。底部的 push/pop 因为只有所有者自己在做,天然不用锁;顶部的 steal 多个线程可能同时来,所以顶部必须用原子操作(比如 compare-and-swap)保证安全。"底部无锁 + 顶部原子操作"的组合拳,就把锁竞争压到了最低。
用生活类比来解释就是:我是小店老板,只从我自己的柜台最里面的货架取货(pop 底部),补货也放在最里面(push 底部)。外面的顾客要从店门口货架拿货(steal 顶部)。我和顾客永远不会碰到同一格货架,所以大部分时候我们各干各的,不会有冲突。只有我在最里面拿货、又有顾客刚好要从门口货架最前面拿货时,才需要小心一点,用原子操作保证两边不会乱套。
2.2 偷取策略:LIFO 优先,从"最新分裂"下手
说完了"怎么偷",再说"偷谁的"和"偷哪个"。这是 work-stealing 里最有讲究的部分之一。
首先,空闲线程挑一个 victim(受害者)队列去偷。最简单的策略是随机选一个线程的队列去尝试。为什么随机?因为随机性可以保证在大量线程间负载会逐渐趋向均衡,而且实现代价极低。也有一些更精细的策略考虑,比如优先偷取最后被访问过的队列(利用 cache 局部性),但工程上随机轮询已经足够。
其次,偷哪个任务?上面说过,作者线程 pop 底部,偷取者 steal 顶部。这背后其实是两个经典的调度原则在打架:
- 作者线程的行为是LIFO(后进先出),跟栈一样。为什么?因为后产生的任务,通常是被刚执行的任务分裂出来的"子任务",它的数据很可能还热乎着——相关的变量、中间结果还留在 CPU cache 里。先执行子任务可以提高 cache 命中率。
- 偷取者的行为是FIFO(先进先出),从顶部拿最老的任务。为什么?因为最老的任务往往是最"顶层"的大块任务,代表一整棵任务树的根。偷走一个大的顶层任务,相当于分走了一整棵子树,降低了与 victim 递归分裂产生更多任务时的交互频率。
这里有一个很重要的洞察:**victim 线程留下底部任务继续执行,实际上是在沿着任务树的某条路径深入;而 thief 偷走顶部任务,是拿走了另一整块待开发的树枝。**两者各取所需,工作负载就这样在树形结构上被优雅地切分了。
2.3 什么时候偷:从自适应等待到触发阈值
另一个容易被忽略的细节是线程"何时"变成 thief。线程干完自己的活,立刻就去偷吗?不是。反复偷不到会浪费不少 CPU 周期。工程实现里通常会有一个自适应策略:
- 先自旋等待一小段时间(比如几十微秒)。目的是保留这个线程在 CPU 上,避免频繁进入睡眠又唤醒的开销。因为如果别的线程正忙着分裂任务,很快就会有新任务产生,此时偷取会很快成功。
- 如果自旋一段时间还是偷不到,就调用操作系统提供的 yield/sleep 原语,真正让出 CPU,降低能耗和系统资源占用。
rayon 里做的稍微复杂一点,有空闲线程会逐步降级:从自旋到短 sleep 再到长 sleep,最终进入类似于"lock-in"的状态。这个细节对性能影响很大——在小任务上,自旋时间占比过高,CPU 空转严重;在大任务上,睡眠时间过多,又会导致负载均衡响应变慢。
所以如果你看到 rayon 或者 tokio 里面有大量关于"spin count"的常量调优,请不要觉得这是过度工程化。这正是 work-stealing 调度在微观层面精细调校的体现。
3. Rust 生态里的两大代表:rayon 和 tokio 是怎么落地的
3.1 rayon:数据并行的分治大师
rayon 是 Rust 生态里最经典的 work-stealing 实现,主要面向数据并行和分治(divide-and-conquer)场景。核心 API 是两个:
par_iter():处理集合的并行迭代。join(a, b):将两个闭包并行执行,等待两者都完成。
join(a, b)的底层机制最能体现 work-stealing 的设计。算法大致是:
- 当前线程先执行其中一个闭包
a。 - 执行完后,检查另一个闭包
b是否已经完成。 - 如果
b没完成,那么不是直接阻塞等待,而是先从当前线程的队列里 pop 出其他任务继续执行(也就是"帮忙"来做别的事)。 - 如果队列也空了,才开始去其他线程偷任务。
- 如果偷也偷不到,最后才进入阻塞,等待任务完成。
这段逻辑透露出的核心思想是:在没有真正需要阻塞之前,线程永远要让自己 "有事可做"。一个线程在等待子任务完成时,不是傻等,而是去执行其他可以推进的工作。这其实是 work-stealing 调度器免于死锁的关键——每个线程都是"工作饥渴"的,只要有任何可执行的任务,它就会去做,而不是空等。
rayon 还有一个设计细节值得提:任务分裂是递归发生的。在par_iter里,一个大集合会被递归地一分为二,直到切分成足够小的子任务。每个子任务在被执行时,如果发现还可以继续分裂,就会把另一半 push 到自己队列的底部,自己先执行其中一个半区。这种递归分裂 + LIFO 优先执行的组合,让任务树的生长非常自然。
3.2 tokio:异步任务调度的偷跑者
tokio 的 work-stealing 跟 rayon 不太一样,因为它的任务不是 CPU 密集型的计算任务,而是一个个异步状态机(future)。tokio 的多线程运行时(multi-threaded runtime)本质上是一个 work-stealing 任务调度器,每个工作线程维护一个任务队列。
但 tokio 做了一个有意思的优化:每个 worker 持有一小部分额外的固定插槽(比如一个 LIFO slot),当前 worker 最新 spawn 的任务优先往这个 slot 里放。这样做的好处是:新 spawn 的任务往往和当前正在执行的任务有数据关联(比如共享 Future 的状态),把它放在 worker 自己的"手边",可以最大化缓存局部性并减少跨线程唤醒。
只有当 LIFO slot 满了或者说超过某个阈值,新的任务才会被 push 到这个 worker 的全局队列里;其他 worker steal 的时候,专门从那棵队列的尾部来取(比如从全局队列的其他位置)。这算是一种"尽量让任务留在源 worker 本地"的思路,只有当本地装不下了,才交出去给邻居。
另外 tokio 里还有一个非常重要的配套机制:yield_now()和调度器内部的"公平性守卫"。因为 tokio 的任务都是协作式调度的,如果一个 future 里有死循环不await,这个 worker 就会被卡死,永远不还给调度器。这一点跟 rayon 完全不同,后者是按 OS 线程抢占的,不存在长任务卡死调度器的问题。
3.3 它们的取舍为什么不同
rayon 和 tokio 虽然底层都是 work-stealing,但设计取向完全不同。
rayon 是"计算并行":任务本身不阻塞,执行完一个就下一个,调度器要尽可能压榨 CPU。所以 rayon 倾向于给任务更多嵌套分裂的机会,并用频繁的自旋保持 worker 热启动。
tokio 是"IO 异步":任务会频繁地await,挂起和恢复的代价极大。所以 tokio 的任务数可能极多,任务生命周期很短,调度器必须轻量且避免过多线程间的任务迁移,这也是 LIFO slot 和本地优先策略存在的意义。
表格对比如下:
| 维度 | rayon | tokio |
|---|---|---|
| 目标场景 | CPU 密集型计算 | IO 密集型异步 |
| 任务粒度 | 中等偏大,递归分裂 | 极细,future 状态机 |
| 本地优化 | 主要靠 LIFO 队列 | LIFO slot + 全局队列混合 |
| 等待策略 | 自旋到睡眠的降级 | 任务挂起,原线程可继续执行 |
| 调度公平性 | 不太敏感,任务基本同权 | 敏感,防止单个任务饿死或卡死 |
4. 工程实践:从原理到调优,你要注意的几件大事
4.1 任务粒度是头号性能开关
我自己最开始用 rayon 时,踩过最大的坑就是任务割得太碎。你如果把一个几千个元素的par_iter()拆成每个元素一个任务,rayon 确实也能跑,但调度开销会非常惊人。每个任务都要被 push/pop/steal 一次,这些操作虽然是原子操作和内存栅栏,但不是免费的。你节省的计算时间,全被调度成本吃掉了。
经验法则:每个任务的执行时间应该在微秒到几十微秒之间,不要低于几微秒。对于数值计算,你可以通过手动 chunk 来控制粒度。rayon 提供了一些机制,比如par_chunks()或par_iter().with_min_len()(在 rayon 的某些版本/特性中)来保证每个工作块至少包含若干元素。
这里给出一个我常用的基准测试思路:用一个固定数据集,逐步改变任务切分的粒度(比如从每个元素一个任务,到每 16 个元素一个任务,再到每 128 个元素一个任务),对比总耗时。你几乎总能看到一条 U 型曲线:右边是任务太少导致负载不均,左边是任务太多导致调度开销过大,中间是甜点区。
4.2 线程数不是越多越好
很多新手拿到 work-stealing 调度器,第一反应是把线程数设成 "等于核心数" 甚至 "等于核心数 + 1"(理由是 IO 等待要额外线程)。但在 work-stealing 里,线程数和并行度强相关:线程数太多,多出来的线程没事干,只能天天去 steal,反而增加了全局原子的竞争和内存带宽负担。
更好用的思路是:rayon 的线程池大小默认等于逻辑 CPU 核数,这个默认值在生产中已经够用了。如果你不确定,可以先用默认值跑一遍,再用--features rayon/num_cpus参考 num_cpus 的值做基准。并行度的收益只在 N 小于等于核心数时是线性的,超过核心数之后,增加的往往只是开销和调度延迟。
如果某个阶段你希望给特定任务"插队"或者提高优先级,不要试图增加 worker 数量,而是考虑拆分任务或者调整嵌套层级,让更重要的任务更早被 pop 出来执行。
4.3 注意任务之间的数据依赖和锁
Work-stealing 调度最怕的不是任务本身慢,而是任务之间产生隐式依赖。比如多个任务共享一个Mutex保护的全局计数器,每个任务执行时都要抢锁。你可能觉得"我就加个互斥锁保护一个 usize,不碍事吧",实际跑起来可能发现并行度越调越低——多线程抢锁导致 cache line bouncing,锁竞争让线程频繁阻塞,调度器以为这个线程很忙(阻塞在锁上),但实际它一个任务都没推进。
如果必须共享数据,优先使用无锁结构(比如crossbeam_epoch或chashmap),或者把共享数据彻底变成"每个线程私有一份,最后再归并"。Work-stealing 的本质是任务隔离,你的数据也应该跟着任务走,尽量保持任务间的独立性。一旦出现"大家都去抢同一个资源"的情况,你的并行代码就变成了隐式串行,work-stealing 调度器再怎么优化也无济于事。
4.4 饥饿问题和任务分裂的失衡
理论上,work-stealing 不会饿死任务——因为每个空闲线程最终都会去偷任务,只要有任务存在,总会被执行。但有一种场景容易让某些任务延迟很高:任务树极度不平衡,一棵树上有一个巨大的右子树,其他线程把别的小任务偷完之后,大量线程都去偷这个右子树的子任务,而左子树的某个后代任务反而一直没被偷到。这在一些分支定界搜索里非常常见。
解决思路有两个方向:
- 在任务分裂时保持一定平衡:不要把一个任务分裂成一个巨块加一个碎块,而是尽量拆成大小相近的两半。
- 引入 "力度控制" 的偷取策略:比如,在 rayon 里你可以通过
scope配合手动spawn来调整任务结构;在 tokio 里可以通过yield_now()让出当前执行权,给其他任务机会。
实际上,"偷到哪个任务"这个问题直接影响最坏情况延迟。如果你写的是一个实时性要求较高的系统,要意识到 work-stealing 提供的只是"期望延迟低",而不是"最坏延迟有界"。这是它的数学性质决定的,你在设计时就要心里有数。
5. 自己实现一个最小 work-stealing:从零到可运行
读到这里,你可能觉得"算法原理懂了,库也会用了",但有些东西不自己写一遍总觉得不踏实。我个人建议你至少用 Rust 亲手实现一个最小版的 work-stealing 调度器。不是为了在生产环境用它,而是为了彻底理解它的并发控制细节。
下面给出一个非常精简的核心结构示意,覆盖 4 个关键部分:任务表示、双端队列、worker 循环、偷取逻辑。
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; // 用一个 Box<dyn FnOnce() + Send> 表示可运行任务 type Task = Box<dyn FnOnce() + Send>; /// 每个 worker 私有的双端队列,基于 crossbeam 的 ArrayDeque /// 这里为了演示,直接用了 crossbeam_deque::Worker 和 Stealer use crossbeam_deque::{Worker, Stealer, Injector}; use crossbeam_utils::CachePadded; struct WorkerHandle { worker: Worker<Task>, stealer: Stealer<Task>, // 记录偷取次数用于观察行为 steal_count: CachePadded<AtomicUsize>, } /// 全局注入队列(可选),用于放置初始任务 struct Shared { injector: Injector<Task>, } fn spawn_worker( id: usize, handles: Vec<Arc<WorkerHandle>>, shared: Arc<Shared>, running: Arc<AtomicBool>, ) -> thread::JoinHandle<()> { thread::spawn(move || { // 每个 worker 循环执行任务 while running.load(Ordering::Relaxed) { // 1. 先尝试从自己的队列里 pop 一个任务 if let Some(task) = handles[id].worker.pop() { task(); continue; } // 2. 本地队列空了,尝试从全局注入队列取任务 if let Some(task) = shared.injector.steal() { task(); continue; } // 3. 本地和全局都没有,开始随机偷取别人的队列 let mut stolen = false; let n = handles.len(); for offset in 1..n { let victim_id = (id + offset) % n; if let Some(task) = handles[victim_id].stealer.steal() { handles[victim_id].steal_count.fetch_add(1, Ordering::Relaxed); task(); stolen = true; break; } } // 4. 实在偷不到,短暂自旋后再次尝试 if !stolen { thread::yield_now(); } } }) }这段代码虽然短,但你可以通过它跑很多小实验。比如:
- 统计每个 worker 的
steal_count,对比不同任务粒度下的偷取频率。 - 观察某些 worker 是不是"饿死"了(从没偷到过任务)。
- 修改自旋策略对比整体耗时。
我在做这个最小实现时发现一个有趣的现象:**当任务粒度较小时,偷取次数会很高,甚至出现"任务还没进队列就被偷走"的情况。**这是 work-stealing 调度行为的正常一瞥——调度开销随任务数线性增长,这也是为什么你始终应该控制任务切分的下限。
6. 实战问题排查与性能分析记录
6.1 现象一:并行代码跑得比串行还慢
这是我被问得最多的问题。排查思路按顺序走:
- 先确认任务粒度:如果任务是几纳秒就结束的轻量操作,work-stealing 调度开销可能超过并行收益。可以先手动把任务合并成较大的 chunk 再测。
- 检查是不是内存带宽受限:如果你的任务是单纯遍历一个大数组做算术运算,那瓶颈可能在内存带宽,不在 CPU。多线程不仅没有收益,还会因为多核竞争内存带宽导致更慢。这时候用 work-stealing 没有意义,不如优化访存模式,比如改成 read-only 遍历或者分块处理。
- 看是否有锁竞争:用
perf top或者heaptrack辅助分析,如果看到大量的原子操作和 futex 调用,说明你的任务间共享状态过多,破坏了 work-stealing 的任务独立性。
6.2 现象二:线程数增加但吞吐不再提升
这类情况通常是因为任务树不够"深"。work-stealing 的并行度依赖于任务分裂空间的充足性。如果你的任务在很浅的层级就分裂完了,后面线程再多也无事可做。
检查方法是:在任务里加一个一次性的计数日志,统计总共产生了多少个任务。如果任务数只比线程数多一点,说明分裂太浅。解决方案是改变数据划分策略,让任务树更"深",比如递归地把任务一分为二,而不是一次性全部分配完毕。
6.3 现象三:偶尔出现任务延迟高、或者 involution 深度过大
如果你发现某个特定任务延迟高到一个令人不满意的程度,大概率是某个空闲线程刚好随机选到了错误的 victim,或者 victim 队列的局部性不好。work-stealing 是随机化的,它不保证某个任务一定在某个时间内启动。
缓解方式有两类:
- 如果任务之间实际有依赖关系,手动把有依赖的任务放到同一个 scope 或同一个 worker 内执行(比如 rayon 的
scope+spawn),避免依赖方等待过久。 - 如果系统中本来就有大量短任务和一条长任务链,考虑用优先级队列替代 work-stealing——但这不是本文的范畴,只是提醒你 work-stealing 不是万灵丹。
工作一年多、写过不少并行和异步场景之后,我的最大体会是:work-stealing 不是一个"加了就变快"的黑魔法,它是一个让你"在正确粒度的任务树下,保持线程忙碌"的调度框架。它的优势在于无锁本地操作和低竞争偷取,但前提是你得把任务切分好、数据隔离好。rayon 和 tokio 已经帮你把最难的调度逻辑藏好了,普通开发者真正要花心思的地方,反而是那些看起来很不起眼的"任务怎么切、共享什么数据、预期什么负载模型"。
最后分享一个小技巧:当你对某个并行逻辑的调度行为没有把握时,先不要优化性能,把每个任务的关键路径上打一个Instant::now()的时间戳,记录在共享的内存数组里。跑完后打印出每个 worker 执行了多少任务、空转了多少时间、偷取了几次。这个数据往往比任何 profiling 工具的火焰图都直观,能让你一眼看出负载到底平不平衡、任务粒度到底合不合理。多来几次这样的实验,你对 work-stealing 的直觉就真正建立起来了。