工作窃取(Work-Stealing)调度:从理论到实践

747 阅读4分钟

工作窃取(Work-Stealing)调度:从理论到实践

工作窃取(Work-Stealing) 作为一种经典的调度策略,被广泛应用于多线程编程框架中。

工作窃取的理论基础

在《Scheduling Multithreaded Computations by Work-Stealing》中,工作窃取的核心思想被总结为:每个工作线程优先处理本地队列中的任务,当本地队列为空时,从其他线程的队列中“窃取”任务。这种设计的优势在于:

  1. 负载均衡:避免某些线程空闲而其他线程过载。
  2. 低竞争:本地队列操作(如任务添加和弹出)通常无竞争,而窃取操作仅发生在空闲时。
  3. 递归分治友好:适合处理动态生成的子任务(如分治算法)。

论文提出使用 双端队列(Deque) 实现任务管理:线程从队尾(本地端)执行任务,而窃取者从队头(远程端)窃取任务。这种设计减少了竞争,因为本地操作和窃取操作分别作用于队列的两端。

在《Non-Blocking Steal-Half Work Queues》中,作者进一步优化了工作窃取的实现,提出了一种无锁(Non-Blocking)的“窃取半数任务”策略。当窃取发生时,窃取者一次性窃取目标队列中一半的任务,从而减少窃取频率和竞争开销。当本地队列中的任务数量超过一定阈值时,线程会将部分任务移动到共享队列中,以便其他线程可以窃取。这种设计在高并发场景下显著提升了吞吐量。

工作窃取(Work-Stealing)vs 公共队列(Centralized Queue)

公共队列的实现相对简单,只需维护一个共享队列,处理器从队列中拉取线程执行。

那么工作窃取的优势是?

  1. 减少通信开销
    • 在工作窃取中,每个处理器维护自己的任务队列,减少了跨处理器通信的频率。只有在处理器空闲时才会尝试从其他处理器窃取任务,从而减少了不必要的通信。
    • 相比之下,处理器需要频繁地从公共队列中获取任务来执行,这可能导致更多的通信开销。
  2. 提高局部性
    • 工作窃取策略允许处理器在本地任务队列中新建任务,减少了对共享资源的访问,提高了数据局部性。
    • 公共队列可能导致处理器频繁访问共享队列,增加了缓存不命中率,降低了性能。
  3. 减少锁竞争
    • 工作窃取策略减少了对共享资源的竞争,因为每个处理器只操作自己的本地队列,减少了锁的使用。
    • 公共队列需要使用锁来保护队列的访问,这可能导致锁竞争,降低性能。

在 Java 中,普通的线程池(如 ThreadPoolExecutor)通常使用公共队列来管理任务,而 ForkJoinPool 则使用了工作窃取策略。

Java Fork/Join Pool

可以看看我写的 为什么JDK钟爱ForkJoinPool?- 掘金

Java 的 ForkJoinPool 是工作窃取算法的典型实现

  1. 每个工作线程维护一个任务队列,本地线程从队尾插入或弹出任务(push/pop),窃取者从队头获取任务(poll)。
  2. 空闲线程随机(启发式策略)选择一个目标线程,尝试从其队列头部窃取一个任务

Go GMP 调度器

网上GMP blog很多,我也不多加赘述,只关注Work-Stealing

GMP像是更多参考了《Non-Blocking Steal-Half Work Queues》,有公共队列和半数窃取

Go 语言的 GMP 调度模型(Goroutine、Machine、Processor)通过工作窃取实现高效的协程调度:

  1. 本地队列与全局队列:每个 Processor(P)维护一个本地任务队列(存放 Goroutine),当本地队列为空时,P 会先尝试从全局队列中获取任务,而不是先从其他本地队列窃取。只有当全局队列也为空时,才会触发 Work Stealing 机制,减少窃取操作的频率,从而降低系统复杂度和潜在的竞争情况。
  2. 批量窃取:Go 的窃取策略借鉴了“窃取半数任务”的思想,每次窃取时尝试获取目标队列中一半的任务,减少竞争。

F/J Pool vs GMP

特性Java Fork/Join PoolGo GMP 调度器
队列设计本地队列(也有全局队列,存在感弱)本地队列 FIFO,全局队列辅助
窃取策略随机窃取单个任务窃取半数任务(减少竞争)

总结

工作窃取是一种以 “本地优先、空闲窃取” 为核心的调度策略,通过减少竞争和动态平衡负载提升性能。

参考

Scheduling Multithreaded Computations by Work-Stealing

Non-Blocking Steal-Half Work Queues