Fork/Join框架笔记
2019-09-28 00:35:32
Fork/Join框架
Java 7 提出一个面向特定问题的ExecutorService接口的额外实现
这个框架被设计用来解决可以使用分而治之的技术将任务分解成更小的任务,在一个任务中,检查你想解决问题的大小,如果它大于一个特定的大小,把它分解成更小的任务,然后用这个框架来执行,如果问题小于特定的大小,直接在任务中解决这个问题,下图总结了这个概念。
没有公式来确定问题的参数大小,所以你可以根据它的特点来确定一个任务是否可以被细分。你可以参考任务处理元素的大小和预估任务执行时间来确定子任务大小。你需要解决的问题是测试不同的参考大小来选择最好的一个。你可以将ForkJoinPool作为一种特殊的执行者来考虑。
这个框架基于以下两种操作:
- fork操作:当你把任务分成更小的任务和使用这个框架执行它们。
- join操作:当一个任务等待它创建的任务的结束。
Fork/Join框架和Executor框架的主要区别是work-stealing算法,当一个任务正在等待它使用join操作创建的子任务的话,只想这个任务的线程查找其他未被执行的任务开始它的执行,从而提高了程序的性能。
基于这个思想,Fork/Jion框架的局限性为:
- 任务只能使用fork()和join()操作,作为同步机制。如果使用其他同步机制,工作线程不能执行其他任务,当它们在同步操作时。比如,在Fork/Join框架中,你使任务进入睡眠,正在执行这个任务的工作线程将不会执行其他任务,在这睡眠期间内。
- 任务不应该执行I/O操作,如读或写数据文件。
- 任务不能抛出检查异常,它必须包括必要的代码来处理它们。
Fork/Join框架的核心是由以下两个类:
- ForkJoinPool:它实现ExecutorService接口和work-stealing算法。它管理工作线程和提供关于任务的状态和它们执行的信息。
- ForkJoinTask: 它是将在ForkJoinPool中执行的任务的基类。它提供在任务中执行fork()和join()操作的机制,并且这两个方法控制任务的状态。通常, 为了实现你的Fork/Join任务,你将实现两个子类的子类的类:RecursiveAction对于没有返回结果的任务和RecursiveTask 对于返回结果的任务。
创建一个Fork/Join池
使用Fork/Join框架的基本元素
- 创建一个ForkJoinPool对象来执行任务。
- 创建一个ForkJoinPool执行的ForkJoinTask类。
java推荐的Fork/Join结构:
If (problem size < default size){ tasks=divide(task); execute(tasks); } else { resolve problem using another algorithm; }
- 你将以一种同步方式执行任务。当一个任务执行2个或2个以上的子任务时,它将等待它们的结束。通过这种方式 ,正在执行这些任务的线程(工作线程)将会查找其他任务(尚未执行的任务)来执行,充分利用它们的执行时间。
- 你将要实现的任务将不会返回任何结果,所以你将使用RecursiveAction作为它们实现的基类。
实例代码:
package forkJoin; import java.util.List; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RecursiveAction; import java.util.concurrent.TimeUnit; public class Task extends RecursiveAction { private static final long serialVersionUID = 1L; private List products; private int first; private int last; private double increment; public Task(List products, int first, int last, double increment) { this.products = products; this.first = first; this.last = last; this.increment = increment; } @Override protected void compute() { if (last – first < 10) { updatePrices(); } else { int middle = (last + first) / 2; System.out.printf("Task: Pending tasks:%s\n", getQueuedTaskCount()); Task t1 = new Task(products, first, middle + 1, increment); Task t2 = new Task(products, middle + 1, last, increment); invokeAll(t1, t2); } } private void updatePrices() { for (int i = first; i < last; i++) { Product product = products.get(i); product.setPrice(product.getPrice() * (1 + increment)); } } public static void main(String[] args) { ProductListGenerator generator = new ProductListGenerator(); List products = generator.generate(10000); Task task = new Task(products, 0, products.size(), 0.20); ForkJoinPool pool = new ForkJoinPool(); pool.execute(task); do { System.out.printf(“Main: Thread Count: %d\n”, pool.getActiveThreadCount()); System.out.printf(“Main: Thread Steal: %d\n”, pool.getStealCount()); System.out.printf(“Main: Parallelism: %d\n”, pool.getParallelism()); try { TimeUnit.MILLISECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } while (!task.isDone()); pool.shutdown(); if (task.isCompletedNormally()) { System.out.printf(“Main: The process has completednormally.\n”); } for (int i = 0; i < products.size(); i++) { Product product = products.get(i); if (product.getPrice() != 12) { System.out.printf("Product %s: %f\n", product.getName(),product.getPrice()); } } System.out.println("Main: End of the program.\n"); } }
package forkJoin; import java.util.ArrayList; import java.util.List; public class ProductListGenerator { public List generate(int size) { List ret = new ArrayList(); for (int i = 0; i < size; i++) { Product product = new Product(); product.setName("Product" + i); product.setPrice(10); ret.add(product); } return ret; } }
1 package forkJoin; 2 3 public class Product { 4 private String name; 5 private double price; 6 public String getName() { 7 return name; 8 } 9 public void setName(String name) { 10 this.name = name; 11 } 12 public double getPrice() { 13 return price; 14 } 15 public void setPrice(double price) { 16 this.price = price; 17 } 18 19 }
在这个实例中,创建一个ForkJoinPool对象和一个在池中执行的ForkJoinTask对象。为了创建ForkJoinTask对象,使用无参构造器,创建一个线程数等于处理器数的池。当ForkJoinPool对象被创建时,这些线程在池中等待,直到有任务让他们执行。
由于Task类没有返回结果,所以它继承RecursiveAction类。在这个指南中,你已经使用了推荐的结构来实现任务。如果这个任务更新超过10产品,它将被分解成两部分,并创建两个任务,一个任务执行一部分。你已经在Task类中使用first和last属性,用来了解这个任务要更新的产品队列的位置范围。你已经使用first和last属性,只复制产品数列一次,而不是为每个任务创建不同的数列。
调用invokeAll(),这是一个同步调用,这个任务在继续它的执行前,必须等待子任务的结束,当任务正在等待它的子任务结束时,正在执行的它的工作线程执行其他正在等待的任务。
使用execute()方法提交唯一的任务给这个池,用来所有产品数列。在这种情况下,它是一个异步调用,而主线程继续它的执行。
ForkJoinPool类提供的方法:
- execute (Runnable task):这是在这个示例中,使用的execute()方法的另一个版本。在这种情况下,你可以提交一个Runnable对象给ForkJoinPool类。注意:ForkJoinPool类不会对Runnable对象使用work-stealing算法。它(work-stealing算法)只用于ForkJoinTask对象。
- invoke(ForkJoinTask<T> task):当execute()方法使用一个异步调用ForkJoinPool类,正如你在本示例中所学的,invoke()方法使用同步调用ForkJoinPool类。这个调用不会(立即)返回,直到传递的参数任务完成它的执行。
- 你也可以使用在ExecutorService接口的invokeAll()和invokeAny()方法。这些方法接收一个Callable对象作为参数。ForkJoinPool类不会对Callable对象使用work-stealing算法,所以你最好使用执行者去执行它们。
ForkJoinTask类同样提供在示例中使用的invokeAll()的其他版本。这些版本如下:
- invokeAll(ForkJoinTask<?>… tasks):这个版本的方法使用一个可变参数列表。你可以传入许多你想要执行的ForkJoinTask对象作为参数。
- invokeAll(Collection<T> tasks):这个版本的方法接收一个泛型类型T对象的集合(如:一个ArrayList对象,一个LinkedList对象或者一个TreeSet对象)。这个泛型类型T必须是ForkJoinTask类或它的子类。
即使ForkJoinPool类被设计成用来执行一个ForkJoinTask,你也可以直接执行Runnable和Callable对象。你也可以使用ForkJoinTask类的adapt()方法来执行任务,它接收一个Callable对象或Runnable对象(作为参数)并返回一个ForkJoinTask对象。
加入任务的结果
Fork/Join框架提供了执行返回一个结果的任务的能力。这些任务的类型是实现了RecursiveTask类。这个类继承了ForkJoinTask类和实现了执行者框架提供的Future接口。
在任务中,你必须使用Java API方法推荐的结构:
If (problem size < size){ tasks=Divide(task); execute(tasks); groupResults() return result; } else { resolve problem; return result; }
如果这个任务必须解决一个超过预定义大小的问题,你应该将这个任务分解成更多的子任务,并且用Fork/Join框架来执行这些子任务。当这些子任务完成执行,发起的任务将获得所有子任务产生的结果 ,对这些结果进行分组,并返回最终的结果。最终,当在池中执行的发起的任务完成它的执行,你将获取整个问题地最终结果。
性能
加速比(Speedups)
通过使用不同数目(1~30)的工作线程对同一问题集进行测试,用来得到框架的扩展性测试结果。虽然我们无法保证Java虚拟机是否总是能够将每一个线程映射到不同的空闲CPU上,同时,我们也没有证据来证明这点。有可能映射一个新的线程到CPU的延迟会随着线程数目的增加而变大,也可能会随不同的系统以及不同的测试程序而变化。但是,所得到的测试结果的确显示出增加线程的数目确实能够增加使用的CPU的数目。
加速比通常表示为“Time n/Time1”.如上图所示,其中求积分的程序表现出最好的加速比(30个线程的加速比为28.2),表现最差的是矩阵分解程序(30线程是加速比只有15.35)
另一种衡量扩展性的依据是:任务执行率,及执行一个单独任务(这里的任务有可能是递归分解节点任务也可能是根节点任务)所开销的平均时间。下面的数据显示出一次性执行各个程序所得到的任务执行率数据。很明显,单位时间内执行的任务数目应该是固定常量。然而事实上,随着线程数目增加,所得到的数据会表现出轻微的降低,这也表现出其一定的扩展性限制。这里需要说明的是,之所以任务执行率在各个程序上表现的巨大差异,是因其任务粒度的不同造成的。任务执行率最小的程序是Fib(菲波那契数列),其阀值设置为13,在30个线程的情况下总共完成了280万个单元任务。
导致这些程序的任务完成率没有表现为水平直线的因素有四个。其中三个对所有的并发框架来说都是普遍原因,所以,我们就从对FJTask框架(相对于Cilk等框架)所特有的因素说起,即垃圾回收。
垃圾回收
总的来说,现在的垃圾回收机制的性能是能够与fork/join框架所匹配的:fork/join程序在运行时会产生巨大数量的任务单元,然而这些任务在被执行之后又会很快转变为内存垃圾。相比较于顺序执行的单线程程序,在任何时候,其对应的fork/join程序需要最多p倍的内存空间(其中p为线程数目)。基于分代的半空间拷贝垃圾回收器(也就是本文中测试程序所使用的Java虚拟机所应用的垃圾回收器)能够很好的处理这种情况,因为这种垃圾回收机制在进行内存回收的时候仅仅拷贝非垃圾内存单元。这样做,就避免了在手工并发内存管理上的一个复杂的问题,即跟踪那些被一个线程分配却在另一个线程中使用的内存单元。这种垃圾回收机制并不需要知道内存分配的源头,因此也就无需处理这个棘手的问题。
这种垃圾回收机制优势的一个典型体现:使用这种垃圾回收机制,四个线程运行的Fib程序耗时仅为5.1秒钟,而如果在Java虚拟机设置关闭代拷贝回收(这种情况下使用的就是标记–清除垃圾回收机制了),耗时需要9.1秒钟。
鉴于上面的结果,我们使用64M的半空间作为其他测试的运行标准。其实设置内存大小的一个更好的策略就是根据每次测试的实际线程数目来确定。(正如上面的测试数据,我们发现这种情况下,加速比会表现的更为平滑)。相对的另一方面,程序所设定的任务粒度的阀值也应该随着线程数目成比例的增长。
内存分配和字宽
在上文提到的测试程序中,有四个程序会创建并操作数量巨大的共享数组和矩阵:数字排序,矩阵相乘/分解以及松弛。其中,排序算法应该是对数据移动操作(将内存数据移动到CPU缓存)以及系统总内存带宽,最为敏感的。为了确定这些影响因素的性质,我们将排序算法Sort改写为四个版本,分别对Byte字节数据,short型数据,int型数据以及long型数据进行排序。这些程序所操作的数据都在0~255之间,以确保这些对比测试之间的平等性。理论上,操作数据的字宽越大,内存操作压力也相应越大。
测试结果显示,内存操作压力的增加会导致加速比的降低,虽然我们无法提供明确的证据来证明这是引起这种表现的唯一原因。但数据的字宽的确是影响程序的性能的。比如,使用一个线程,排序字节Byte数据需要耗时122.5秒,然而排序long数据则需要耗时242.5秒。
任务同步
正如3.2章节所讨论的,任务窃取模型经常会在处理任务的同步上遇到问题,如果工作线程获取任务的时候,但相应的队列已经没有任务可供获取,这样就会产生竞争。在FJTask框架中,这种情况有时会导致线程强制睡眠。
从Jacobi程序中我们可以看到这类问题。Jacobi程序运行100步,每一步的操作,相应矩阵点周围的单元都会进行刷新。程序中有一个全局的屏障分隔。为了明确这种同步操作的影响大小。我们在一个程序中每10步操作进行一次同步。如图中表现出的扩展性的差异说明了这种并发策略的影响。也暗示着我们在这个框架后续的版本中应该增加额外的方法以供程序员来重写,以调整框架在不同的场景中达到最大的效率。(注意,这种图可能对同步因素的影响略有夸大,因为10步同步的版本很可能需要管理更多的任务局部性)
任务局部性
FJTask,或者说其他的fork/join框架在任务分配上都是做了优化的,尽可能多的使工作线程处理自己分解产生的任务。因为如果不这样做,程序的性能会受到影响,原因有二:
从其他队列窃取任务的开销要比在自己队列执行pop操作的开销大。
在大多数程序中,任务操作操作的是一个共享的数据单元,如果只运行自己部分的任务可以获得更好的局部数据访问。
如上图所示,在大多数程序中,窃取任务的相对数据都最多维持在很低的百分比。然后其中LU和MM程序随着线程数目的增加,会在工作负载上产生更大的不平衡性(相对的产生了更多的任务窃取)。通过调整算法我们可以降低这种影响以获得更好的加速比。
与其他框架比较
与其他不同语言的框架相比较,不太可能会得到什么明确的或者说有意义的比较结果。但是,通过这种方法,最起码可以知道FJTask在与其他语言(这里主要指的是C和C++)所编写的相近框架比较所表现的优势和限制。下面这个表格展示了几种相似框架(Cilk,Hood ,Stackthreads,以及Filaments)所测试的性能数据。涉及到的测试都是在4CPU的Sun Enterprise450服务器运行4个线程进行的。为了避免在不同的框架或者程序上进行重新配置,所有的测试程序运行的问题集都比上面的测试稍小些。得到的数据也是取三次测试中的最优值,以确保编译器或者说是运行时配置都提供了最好的性能。其中Fib程序没有指定任务粒度的阀值,也就是说默认的1.(这个设置在Filaments版的Fib程序中设置为1024,这样程序会表现的和其它版本更为一致)。
在加速比的测试中,不同框架在不同程序上所得到的测试结果非常接近,线程数目1~4,加速比表现在(3.0~4.0之间)。因此下图也就只聚焦在不同框架表现的不同的绝对性能上,然而因为在多线程方面,所有的框架都是非常快的,大多数的差异更多的是有代码本身的质量,编译器的不同,优化配置项或者设置参数造成的。实际应用中,根据实际需要选择不同的框架以弥补不同框架之间表现的巨大差异。
相比较,计算敏感型程序因为编码质量所引起的性能差异却是很少的。
异步执行任务
待续,转载:ifeve.com/fork-join-4/
在任务中抛出异常
待续,转载:ifeve.com/fork-join-5/
取消任务
待续,转载:ifeve.com/fork-join-6/