A Java Fork/Join Framework by Doug Lea

综述

本文描述了一个java框架的设计、实现和性能。这个框架支持将分而治之的并行计算方法。总体设计可以看做是为Cilk设计的work-stealing框架的变体。主要的实现包括了有效地创建和管理任务队列和工作线程。可计量的性能表现出了并行计算的优越性,但也提出了需要的改良。

1.简介

Fork/Join 并行化是最简单有效的获得良好并行性能的设计方法之一。Fork/Join 算法是熟悉的分而治之算法的并行版本,一般来说:

Result solve(Problem problem) {
	if (problem is small) 
		directly solve problem
	else {
		split problem into independent parts
		fork new subtasks to solve each part
		join all subtasks
		compose result from subresults
	}
}
Fork动作开始一个新的fj自任务。Join动作使当前任务在子任务完成前不再运行。FJ算法,就像其他分而治之的算法,基本上都是递归的,重复地分解子任务,直到他们小到能用简单直接的串行算法解决。一些相关的编程算法和例子在Concurrent Programming in Java se的4.4节和FJTask(一个支持此种编程的Java框架)算法性能中讨论过。FJTask可以从http://gee.cs.oswego.edu下载到,在util.concurrent包中。

2.设计

FJ程序可以在任何支持并行子任务,以及有一个进程等待他们结束的框架中运行。然而,java线程类(包括POSIX线程,java线程的基础)并不是支持FJ程序的好工具:

~FJ任务有简单且常规的同步和管理要求。FJ任务的计算图需要一个更有效的规划技巧,而非传统的普适线程。例如,FJ任务不需要阻塞,除非等待其子任务。因此,为记录阻塞信息的额外支出和记事都是浪费的。

~在正常的任务粒度之下,创建和管理线程的时间可能比计算时间还要长。当粒度可以且应该服从在特定平台上运行时的特点,为了超过线程额外开销的太粗的粒度会破坏并行性。

简单来说,标准的线程框架对FJ任务太复杂了。但是,既然线程是很多其他并行程序的基础,为了更好的支持FJ而去掉所有线程的额外支出或者协调规划是不现实的。

虽然这些想法很有历史,但第一个提供系统级解决方案的公开框架是Cilk。Cilk和其他轻量级可执行的框架在os上加了一层FJ层次。这个策略对Java同样适用,虽然Java线程也是封装了os进程的一个层次。创建这样一个框架的优势就是让FJ程序简单的跨平台编写。

FJTask框架是Cilk的变种。其他变种有Hood,Filaments,stackthreads,以及其他的轻量级可执行的任务。所有这些框架把任务映射到线程的方法基本和os把线程映射到CPU的方法一样,不同之处在于,关注到了FJ程序在实现映射时的简单、规则和要求。虽然所有这些框架都能适应不同风格并行程序,他们对FJ特别优化:

~工作进程池。每个工作进程都是标准进程用以计算队列中的任务。正常情况下,有多少CPU就有多少工作进程。在本地框架,这些被映射到内核线程或轻量级进程,然后映射到CPU。在Java虚拟机上,只能相信JVM和OS会将线程映射到CPU上。然而,这个很容易实现,因为这些线程都是计算紧急型的。任何正常的映射方式都会把这些线程映射到不同的CPU上。

~所有FJ任务都是轻量级可执行类的实例而非线程。Java中,独立任务实现runnable接口,复写run函数。在FJTask框架中,任务是FJTask的子类,而非Thread,二者都是Runnable接口的实现。

~使用有特殊目的的排队和调度规则来管理执行任务。这些结构被task类提供的方法触发:理论上是fork,join,isDone以及其他方便使用的方法,例如coInvoke。

~当在正常线程中被调用时,简单的管理控制单元建立起工作池并初始化运行FJ任务。

作为标准演示,下面是斐波那契函数的计算方法:

class Fib extends FJTask {
static final int threshold = 13; 
volatile int number; // arg/result
Fib(int n) { number = n; }
int getAnswer() {
if (!isDone()) 
throw new IllegalStateException();
return number;
}
public void run() {
int n = number;
if (n <= threshold) // granularity ctl
number = seqFib(n);
else {
Fib f1 = new Fib(n − 1);
Fib f2 = new Fib(n − 2);
coInvoke(f1, f2); 
number = f1.number + f2.number;
}
}
public static void main(String[] args) {
try {
int groupSize = 2; // for example 
FJTaskRunnerGroup group = 
new FJTaskRunnerGroup(groupSize);
Fib f = new Fib(35); // for example
group.invoke(f);
int result = f.getAnswer();
System.out.println("Answer: " +
result);
}
catch (InterruptedException ex) {} 
}
int seqFib(int n) {
if (n <= 1) return n;
else return seqFib(n−1) + seqFib(n−2);
}
}
这个版本比同样的Thread 版本至少快30倍。同时保留了Java固有的跨平台性。有两个调节参数会对程序员有较大价值:

~创建的工作进程数,大致应该符合可用CPU数。

~粒度参数,用来指出额外支出超出并行带来的好处的临界点。这个参数更加注重算法而非平台。一般情况下,可以找到同时适用于单核和多核的粒度参数。另一个好处就是,这个方式很符合JVM动态编译的优化方式,其对小函数很有效。这个,以及数据本地化的优势,可以是FJ算法比其他算法在单核上都更加有效

2.1work-stealing

FJ框架的核心在于他的轻量级调度机制。FJTask改进了Cilk率先使用的work-stealing调度机制:

~每个工作线程有一个调度队列。

~双端队列,支持LIFO和FIFO。

~子任务产生的任务被加入一个指定工作线程的队列中。

~本地任务是LIFO的。

~当一个工作线程没有任务可做了,会随即选一个工作线程,偷掉他的一个任务来执行,盗取过程是FIFO的。

~当工作线程遇到join,他转而开始执行其他任务,直到任务结束。否则任务不会被阻塞。

~当工作线程没有任务也没能窃取任务,他就休眠,除非所有工作线程都进入闲置,他会一会之后再次尝试盗取任务。如果都闲置了,所有线程都会阻塞直至有新任务。

LIFO的本地调度规则和FIFO的外部调度规则对于FJ的递归很有效。同样,保证了本地和盗取发生在队列两端,减少冲突。同样体现了分而治之的大任务优先策略。因此,老的任务可能提供更多任务。

这些规则的结果就是,粒度越小运行越快,递归也能提速。虽然很少有盗取任务的情况发生,很多细粒度任务能让工作线程时刻处在运行过程中。

3.实现

框架实现大概用了800行纯Java代码,主要在FJTaskRunner,Java线程的子类。FJTask本身只保存一个布尔型完成情况,并通过授权他的当前工作线程进行其他操作。FJTaskRunnerGroup类提供创建工作线程功能,保存一些必要的共有信息,并帮助协同开始和结束。
更多的细节可以在util.concurrent包里找到。本节只讨论两组实现中遇到的问题:高效的队列实现,和管理窃取任务协议。
3.1队列
为了实现高效和可扩放的执行,任务管理必须尽可能高速。创建,入队出队任务和串行任务中所谓的支出相类似。低额外支出让程序员能使用更小的粒度,借此更好的开拓并行性。
任务分配是JVM的任务。Java垃圾回收提醒我们,需要创建一个特殊内存分配器来保存任务。与其他语言的类似框架相比,Java的这个特点减少了实现FJTask的复杂度和代码量。
队列的基本结构很普通,包括一个数组,两个游标:Top将队列模拟成基于数组的栈。Base只在take时改变。由于FJTaskRunner的操作都和队列的具体细节紧密相关,这个DS直接内嵌到了类中,而非定义为一个单独的组件。
因为队列数组有多线程访问,有时并没有完全同步,但单个的Java数组元素不能声明为volatile,每个元素其实是一个小的前端对象的固定引用,此对象中有一个volatile引用。这个决定开始是为了保证顺应Java内存管理规则,但它应用的非直接性导致了更好的性能,可能是减少了访问临近元素产生的cache争夺,因为非直接性导致元素较为分散。
实现过程中的主要挑战在于同步和避免同步。即使在有同步优化的JVM上,为每个入队出队操作加锁也成为了瓶颈。然而,Cilk中使用的策略提供了基于以下观察的解决方案:
~出入队操作只由父进程调用。
~take操作可用一个take的进入锁保证线程安全。因此,冲突控制简化为一个双方的同步问题。
~只有在队列为空时,take和pop操作才会被干预。其他时候,保证获得一个元素。
将top和base声明为volatile保证了pop和take在没有上锁的时候也能实现。这通过一个类Dekker算法实现,其中每个数值操作都是先于比较的:
if (??top >= base) ...
if (++base < top) ...
每次,都必须检查队列是否为空。一个非对称规则被用在冲突上:pop重新检查状态并在获得take保持的锁后尝试继续,只有在队空时退出。take则直接退出,然后基本上从另一个线程处窃取任务。这个非对称性是和Cilk中使用的THE协议唯一的明显不同。
volatile的使用同时允许push操作在队不溢出时不用同步。溢出时需要加锁并扩大数组。其他时候,只是简单保证top只在由take产生的空缺被填满后才改变(Otherwise, simply ensuring that top is
updated only after the deque array slot is filled in suppresses interference by any take)。
随后的实现发现,一些JVM不遵守JMM规范要求的精确写后读成对的volatile区域。变通就是,当有少于两个元素时,pop需要在锁下重试,take加了一个次级锁,来实现内存barrier。这保证只有一个错误读取时程序正常运行,而且只有很小的性能折扣。
3.2窃取和空闲
工作线程不知道任务的同步要求。他们只是创建,push,pop,take,管理队列,执行任务。这种简单性保证了任务足够时执行的效率。然而,在没有足够任务时,这个模式需要依靠启发。比如,开始一个任务,即将结束和全局同步时。
主要问题在于,当这里没有任务而又无法窃取任务时,工作线程怎么办。如果程序在专用的多核处理器上运行,可以依靠严格的忙等待循环尝试窃取任务。然而,即使这样,尝试窃取任务增加了争夺,这可以降低性能。并且,在更普通的情况下,OS将会被通知运行不相干的其他线程。
实现这些功能的Java工具十分简陋,没有保障,但应用中可以接受。不能窃取任务的线程自降优先级,使用yield后再次尝试,并在FJTaskRunnerGroup中注册为不活跃。如果所有线程都不活跃,则阻塞并等待新任务。否则,在一些尝试过后,线程进入睡眠节奏,它们在尝试之间睡眠而非yield。这些强加的睡眠在需要较长时间分解的任务中可能导致人为的延迟。但这个看上去是最佳的折中方案。将来的版本可能提供附加控制函数,让程序员能更改参数,提高效率。
第四节性能测试不予翻译,原文已经上传到文件分享。
笔记:
1.使用进程池减少额外支出;
2.并行模型没有考虑到数据的传输问题,应该是默认为SMP,故所有的任务不一定在哪个线程上执行,思路和一般性的线程固定执行某一任务不一样;
3.提高控制层次,来减少同步;
4.LIFO的内部调度模拟了串行递归调用的调用顺序,FIFO的窃取任务保证窃取到规模最大的任务;
5.非直接引用带来的性能改善应该是在并行存储的机器上,而在但内存的机器上应该会有性能下降。






你可能感兴趣的:(java,jvm,框架,工作,算法,任务)