概述
最近在理解Golang中的Per P概念,于是我就去Go的源码中挖呀挖,结果挖到了Go的调度器设计。
Golang的调度器设计文档提到了Go中的P(OS线程)调度器使用的是work-stealing调度算法论文。
论文中提到了两个多线程调度算法:work sharing和work stealing。
什么是work sharing?
什么是work stealing?
Work Sharing(工作共享)
先来个专业解释:
在工作共享策略中,当一个处理器生成新的线程时,调度器会尝试将部分线程迁移到其他处理器上,以期分派工作到未充分利用的处理器。这样,工作可以在处理器之间共享,从而实现并行计算。
有点抽象,我个人理解是这样:work sharing:工作共享,是多核多线程并行处理任务的调度算法。 它的基本思想是把任务均匀的分摊到任务执行节点(这里的计算节点就是OS线程)。
Work sharing算法每个任务执行节点使用共享的任务队列,也叫全局队列,调度器负责分配任务给这些任务执行节点。
应用层
我们身处应用层的小开发怎么来理解。我觉得对于Java程序员来说,work sharing就是线程池(ThreadPoolExecturor)。Java的ThreadPoolExecturor有一个共享的任务队列,有一个或者多个任务线程。所有的任务线程都从共享的任务队列中获取任务并执行。
优劣
-
优点
因为所有的任务都由调度器统一分配,所以可以保证所有的处理器都保持负载均衡,避免了某些处理器空闲而其他处理器繁忙的情况。 -
缺点
Work Sharing 要求处理器间经常进行任务交换,这可能会带来一些额外的开销。例如上下文切换的成本和处理器间的通信成本等。同时,这样的设计也使得调度器成为了一个明显的瓶颈。
Work Stealing(工作窃取)
在工作窃取调度器中,计算机系统中的每个处理器都有一个需要执行的工作项(计算任务,线程)队列。每个工作项由一系列顺序执行的指令组成,但是在执行过程中,工作项也可能生成新的工作项,这些新的工作项可与其它工作并行执行。这些新产生的工作项最初会被放在执行当前工作项的处理器队列中。当一个处理器的工作执行完毕,它会查看其他处理器的队列并“窃取”他们的工作项。实际上,工作窃取将调度工作分摊到空闲的处理器上,并且只要所有处理器都有工作要做,就不会产生调度开销。
我们身处应用层的小开发怎么来理解。其实就是每个线程有一个自己的任务队列(这样任务队列就去中心化了),每个线程先从自己的线程本地来获取任务执行,但是当自己的任务队列空了就从其他线程的任务队列中“偷”一些任务继续执行。
work sharing vs work stealing
我们来对比一下work sharing和work stealing:
Work Sharing | Work Stealing | |
---|---|---|
线程分配方式 | 当一个处理器产生新线程时,调度器将线程迁移至其他处理器 | 闲置处理器主动从忙碌处理器窃取线程执行 |
适用情况 | 所有处理器均有工作任务时,能保持负载均衡 | 一部分处理器空闲,一部分处理器忙碌时,能主动窃取任务,保持负载均衡 |
线程迁移频率 | 总是会发生线程迁移,可能会影响性能 | 在所有处理器都有任务执行的情况下不迁移线程,减少了线程调度开销 |
主动/被动 | 被动分配 – 调度器主动将任务分配给处理器 | 主动窃取 – 处理器主动从其他处理器窃取任务 |
实现复杂性 | 实现起来相对简单,易于理解 | 实现起来相对复杂,需要处理窃取行为等细节问题 |
典型应用实例 | Java中的ThreadPoolExecutor | Java中的ForkJoinPool |
ForkJoinPool解析
在Java中,ForkJoinPool实现了Work stealing,我们来看下ForkJoinPool中work stealing相关的组件:
- 工作线程
- 任务队列
- 调度器
- 任务
- 工作窃取策略
- 同步机制
工作线程
ForkJoinPool中Work Thread类为:ForkJoinWorkerThread
,我们来看下构造函数:
// 根据指定的线程组和ForkJoinPool创建新的ForkJoinWorkerThread
// 确定是否使用系统类加载器和是否清除线程本地变量
super(group, null, pool.nextWorkerThreadName(), 0L, !clearThreadLocals);
// 保存ForkJoinPool,并且获取并保存uncaughtExceptionHandler
UncaughtExceptionHandler handler = (this.pool = pool).ueh;
// 为当前工作线程创建新的ForkJoinPool.WorkQueue
this.workQueue = new ForkJoinPool.WorkQueue(this, 0);
// 如果需要清除线程本地变量,则设置清除标志
if (clearThreadLocals)
workQueue.setClearThreadLocals();
// 设置线程为守护线程
super.setDaemon(true);
// 如果有uncaughtExceptionHandler,则设置给当前线程
if (handler != null)
super.setUncaughtExceptionHandler(handler);
// 如果需要使用系统类加载器,则设置系统类加载器为当前线程的上下文类加载器
if (useSystemClassLoader)
super.setContextClassLoader(ClassLoader.getSystemClassLoader());
COPY
以上代码中,我们看到工作线程有一个workQueue。
任务队列
任务队列ForkJoinPool是双端队列:从队列头部获取任务,从队列尾部窃取任务。
我们只关注构造函数:
工作队列关联了一个ForkJoin工作线程,并且任务队列的内存存储使用了环形缓冲区(Ring Buffer)来存储任务。
/**
* 构造函数。对于拥有队列,大多数字段是在pool.registerWorker中的线程启动时初始化的。
*
* @param owner 所有者,此工作队列属于哪个 ForkJoinWorkerThread
* @param config 配置参数,可能包括了如何处理该工作队列的各种配置
*/
WorkQueue(ForkJoinWorkerThread owner, int config) {
// 设置此工作队列的所有者为指定的 ForkJoinWorkerThread
this.owner = owner;
// 设置此工作队列的配置为指定的配置
this.config = config;
// 初始化 base 和 top 表示队列的头和尾部索引为 1
base = top = 1;
}
COPY
调度器
ForkJoinPool的调度器主要是负责任务提交,任务队列管理,工作线程和工作窃取的处理。
在了解ForkJoinPool任务提交之前我们得先了解一下任务队列, 在ForkJoinPool中任务队列是一个数组存放了所有工作线程的任务队列,在提交任务时会随机从这个任务队列数组中选取一个然后把任务提交到这个任务队列中:
WorkQueue[] queues; // main registry
COPY
如下图:
提交任务
提交任务函数:
private <T> ForkJoinTask<T> poolSubmit(boolean signalIfEmpty,
ForkJoinTask<T> task)
COPY
提交任务流程:
任务窃取
任务窃取是ForkJoinPool的核心了吧,窃取主要涉及两个核心方法:scan
和awaitWork
。
while ((src = scan(w, src, r)) >= 0 || (src = awaitWork(w)) == 0);
COPY
scan
scan流程:
- 首先从自己的任务队列中获取一个任务并执行
- 如果自己的任务队列中就尝试从其他任务队列队尾窃取任务
这个函数代码有点麻烦,有点费脑子,大家自己看吧。
总结
Work stealing可以提升并发和并行,因为空闲的线程会主动窃取其他线程中任务队列的任务从而提升并行处理能力。