正文
我们将采用一个特殊的队列和调度原则来管理任务并通过工作线程来执行任务。这些机制是由任务类中提供的相关方式实现的:主要是由fork、join、isDone(一个结束状态的标示符),和一些其他方便的方法,例如调用coInvoke来分解合并两个或两个以上的任务。
一个简单的控制和管理类(这里指的是FJTaskRunnerGroup)来启动工作线程池,并初始化执行一个由正常的线程调用所触发的Fork/Join任务(就类似于Java程序中的main方法)。
作为一个给程序员演示这个框架如何运行的标准实例,这是一个计算法斐波那契函数的类。
这个版本在第4节中所提到的平台上的运行速度至少比每个任务都在Thread类中运行快30倍。在保持性能的同时这个程序仍然维持着Java多线程程序的可移植性。对程序员来说通常有两个参数值的他们关注:
-
对于工作线程的创建数量,通常情况下可以与平台所拥有的处理器数量保持一致(或者更少,用于处理其他相关的任务,或者有些情况下更多,来提升非计算密集型任务的性能)。
-
一个粒度参数代表了创建任务的代价会大于并行化所带来的潜在的性能提升的临界点。这个参数更多的是取决于算法而不是平台。通常在单处理器上运行良好的临界点,在多处理器平台上也会发挥很好的效果。作为一种附带的效益,这种方式能够与Java虚拟机的动态编译机制很好的结合,而这种机制在对小块方法的优化方面相对于单块的程序来说要好。这样,加上数据本地化的优势,Fork/Join算法的性能即使在单处理器上面的性能都较其他算法要好。
2.1 work−stealing
Fork/Join框架的核心在于轻量级调度机制。FJTask采用了Cilk的work-stealing所采用的基本调度策略:
-
每一个工作线程维护自己的调度队列中的可运行任务。
-
队列以双端队列的形式被维护(注:deques通常读作『decks』),不仅支持后进先出 —— LIFO的push和pop操作,还支持先进先出 —— FIFO的take操作。
-
对于一个给定的工作线程来说,任务所产生的子任务将会被放入到工作者自己的双端队列中。
-
工作线程使用后进先出 —— LIFO(最新的元素优先)的顺序,通过弹出任务来处理队列中的任务。
-
当一个工作线程的本地没有任务去运行的时候,它将使用先进先出 —— FIFO的规则尝试随机的从别的工作线程中拿(『窃取』)一个任务去运行。
-
当一个工作线程触及了join操作,如果可能的话它将处理其他任务,直到目标任务被告知已经结束(通过isDone方法)。所有的任务都会无阻塞的完成。
-
当一个工作线程无法再从其他线程中获取任务和失败处理的时候,它就会退出(通过yield、sleep和/或者优先级调整,参考第3节)并经过一段时间之后再度尝试直到所有的工作线程都被告知他们都处于空闲的状态。在这种情况下,他们都会阻塞直到其他的任务再度被上层调用。
使用后进先出 —— LIFO用来处理每个工作线程的自己任务,但是使用先进先出 —— FIFO规则用于获取别的任务,这是一种被广泛使用的进行递归Fork/Join设计的一种调优手段。引用[5]讨论了详细讨论了里面的细节。
让窃取任务的线程从队列拥有者相反的方向进行操作会减少线程竞争。同样体现了递归分治算法的大任务优先策略。因此,更早期被窃取的任务有可能会提供一个更大的单元任务,从而使得窃取线程能够在将来进行递归分解。
作为上述规则的一个后果,对于一些基础的操作而言,使用相对较小粒度的任务比那些仅仅使用粗粒度划分的任务以及那些没有使用递归分解的任务的运行速度要快。尽管相关的少数任务在大多数的Fork/Join框架中会被其他工作线程窃取,但是创建许多组织良好的任务意味着只要有一个工作线程处于可运行的状态,那么这个任务就有可能被执行。
3. 实现
这个框架是由大约800行纯Java代码组成,主要的类是FJTaskRunner,它是java.lang.Thread的子类。FJTask自己仅仅维持一个关于结束状态的布尔值,所有其他的操作都是通过当前的工作线程来代理完成的。JFTaskRunnerGroup类用于创建工作线程,维护一些共享的状态(例如:所有工作线程的标示符,在窃取操作时需要),同时还要协调启动和关闭。
更多实现的细节文档可以在util.concurrent并发包中查看。这一节只着重讨论两类问题以及在实现这个框架的时候所形成的一些解决方案:支持高效的双端列表操作(push、pop和take), 并且当工作线程在尝试获取新的任务时维持窃取的协议。
3.1 双端队列
(校注:双端队列中的元素可以从两端弹出,其限定插入和删除操作在队列的两端进行。)
为了能够获得高效以及可扩展的执行任务,任务管理需要越快越好。创建、发布、和弹出(或者出现频率很少的获取)任务在顺序编程模式中会引发程序调用开销。更低的开销可以使得程序员能够构建更小粒度的任务,最终也能更好的利用并行所带来的益处。
Java虚拟机会负责任务的内存分配。Java垃圾回收器使我们不需要再去编写一个特殊的内存分配器去维护任务。相对于其他语言的类似框架,这个原因使我们大大降低了实现FJTask的复杂性以及所需要的代码数。
双端队列的基本结构采用了很常规的一个结构 —— 使用一个数组(尽管是可变长的)来表示每个队列,同时附带两个索引:top索引就类似于数组中的栈指针,通过push和pop操作来改变。base索引只能通过take操作来改变。鉴于FJTaskRunner操作都是无缝的绑定到双端队列的细节之中,(例如,fork直接调用push),所以这个数据结构直接放在类之中,而不是作为一个单独的组件。
但是双端队列的元素会被多线程并发的访问,在缺乏足够同步的情况下,而且单个的Java数组元素也不能声明为volatile变量(校注:声明成volatile的数组,其元素并不具备volatile语意),每个数组元素实际上都是一个固定的引用,这个引用指向了一个维护着单个volatile引用的转发对象。一开始做出这个决定主要是考虑到Java内存模型的一致性。但是在这个级别它所需要的间接寻址被证明在一些测试过的平台上能够提升性能。可能是因为访问邻近的元素而降低了缓存争用,这样内存里面的间接寻址会更快一点。
实现双端队列的主要挑战来自于同步和他的撤销。尽管在Java虚拟机上使用经过优化过的同步工具,对于每个push和pop操作都需要获取锁还是让这一切成为性能瓶颈。然后根据以下的观察结果我们可以修改Clik中的策略,从而为我们提供一种可行的解决方案: