工作窃取(Work-Stealing)调度:从理论到实践
工作窃取(Work-Stealing) 作为一种经典的调度策略,被广泛应用于多线程编程框架中。
工作窃取的理论基础
在《Scheduling Multithreaded Computations by Work-Stealing》中,工作窃取的核心思想被总结为:每个工作线程优先处理本地队列中的任务,当本地队列为空时,从其他线程的队列中“窃取”任务。这种设计的优势在于:
- 负载均衡:避免某些线程空闲而其他线程过载。
- 低竞争:本地队列操作(如任务添加和弹出)通常无竞争,而窃取操作仅发生在空闲时。
- 递归分治友好:适合处理动态生成的子任务(如分治算法)。
论文提出使用 双端队列(Deque) 实现任务管理:线程从队尾(本地端)执行任务,而窃取者从队头(远程端)窃取任务。这种设计减少了竞争,因为本地操作和窃取操作分别作用于队列的两端。
在《Non-Blocking Steal-Half Work Queues》中,作者进一步优化了工作窃取的实现,提出了一种无锁(Non-Blocking)的“窃取半数任务”策略。当窃取发生时,窃取者一次性窃取目标队列中一半的任务,从而减少窃取频率和竞争开销。当本地队列中的任务数量超过一定阈值时,线程会将部分任务移动到共享队列中,以便其他线程可以窃取。这种设计在高并发场景下显著提升了吞吐量。
工作窃取(Work-Stealing)vs 公共队列(Centralized Queue)
公共队列的实现相对简单,只需维护一个共享队列,处理器从队列中拉取线程执行。
那么工作窃取的优势是?
- 减少通信开销:
- 在工作窃取中,每个处理器维护自己的任务队列,减少了跨处理器通信的频率。只有在处理器空闲时才会尝试从其他处理器窃取任务,从而减少了不必要的通信。
- 相比之下,处理器需要频繁地从公共队列中获取任务来执行,这可能导致更多的通信开销。
- 提高局部性:
- 工作窃取策略允许处理器在本地任务队列中新建任务,减少了对共享资源的访问,提高了数据局部性。
- 公共队列可能导致处理器频繁访问共享队列,增加了缓存不命中率,降低了性能。
- 减少锁竞争:
- 工作窃取策略减少了对共享资源的竞争,因为每个处理器只操作自己的本地队列,减少了锁的使用。
- 公共队列需要使用锁来保护队列的访问,这可能导致锁竞争,降低性能。
在 Java 中,普通的线程池(如
ThreadPoolExecutor)通常使用公共队列来管理任务,而ForkJoinPool则使用了工作窃取策略。
Java Fork/Join Pool
可以看看我写的 为什么JDK钟爱ForkJoinPool?- 掘金
Java 的 ForkJoinPool 是工作窃取算法的典型实现
- 每个工作线程维护一个任务队列,本地线程从队尾插入或弹出任务(
push/pop),窃取者从队头获取任务(poll)。 - 空闲线程随机(启发式策略)选择一个目标线程,尝试从其队列头部窃取一个任务。
Go GMP 调度器
网上GMP blog很多,我也不多加赘述,只关注Work-Stealing
GMP像是更多参考了《Non-Blocking Steal-Half Work Queues》,有公共队列和半数窃取
Go 语言的 GMP 调度模型(Goroutine、Machine、Processor)通过工作窃取实现高效的协程调度:
- 本地队列与全局队列:每个 Processor(P)维护一个本地任务队列(存放 Goroutine),当本地队列为空时,P 会先尝试从全局队列中获取任务,而不是先从其他本地队列窃取。只有当全局队列也为空时,才会触发 Work Stealing 机制,减少窃取操作的频率,从而降低系统复杂度和潜在的竞争情况。
- 批量窃取:Go 的窃取策略借鉴了“窃取半数任务”的思想,每次窃取时尝试获取目标队列中一半的任务,减少竞争。
F/J Pool vs GMP
| 特性 | Java Fork/Join Pool | Go GMP 调度器 |
|---|---|---|
| 队列设计 | 本地队列(也有全局队列,存在感弱) | 本地队列 FIFO,全局队列辅助 |
| 窃取策略 | 随机窃取单个任务 | 窃取半数任务(减少竞争) |
总结
工作窃取是一种以 “本地优先、空闲窃取” 为核心的调度策略,通过减少竞争和动态平衡负载提升性能。