摘要:前言在前面的文章框架之中梳理了框架的简要运行格架和异常处理流程显然要理解框架的调度包含工作窃取等思想需要去中了解而对于的拓展和使用则需要了解它的一些子类前文中偶尔会提到的一个子类直译为计数的完成器前文也说过的并行流其实就是基于了框架实现因此
前言
在前面的文章"ForkJoin框架之ForkJoinTask"中梳理了ForkJoin框架的简要运行格架和异常处理流程,显然要理解ForkJoin框架的调度,包含工作窃取等思想,需要去ForkJoinPool中了解,而对于ForkJoinTask的拓展和使用则需要了解它的一些子类,前文中偶尔会提到ForkJoinTask的一个子类:CountedCompleter,直译为计数的完成器.
前文也说过,JAVA8的并行流其实就是基于了ForkJoin框架实现,因此并行流其实就在使用我们前面提到的工作窃取和分治思想.为了方便对于ForkJoinTask的理解,本文将详述CountedCompleter(同时在ForkJoinPool中也需要了解它),以及前文提到的工作线程ForkJoinWorkerThread,并简单看一看并行流.
CountedCompleter源码根据doug的注释,CoutedCompleter是一个特殊的ForkJoinTask,它会在触发完成动作时,检查有没有挂起action,若没有则执行一个完成动作.这个概念有些抽象,必须结合源码和源码作者给出的示例加以理解,同样的,理解了它,也就理解了CountedCompleter的扩展类的实现方式,从而能阅读懂有关的源码(如并行流中涉及到运行集拆分,结果合并,运算调度等源码).
它也是一个抽象类,基于ForkJoinTask的exec函数进行了若干扩展.
public abstract class CountedCompleterextends ForkJoinTask //任务的完成者,很明显这是一个全局的栈结构(暂时这么理解吧,其实也不太严格). final CountedCompleter> completer; //重要字段,代表完成前挂起的任务数量,用volatile修饰. volatile int pending; //带有completer的构造器. protected CountedCompleter(CountedCompleter> completer) { this.completer = completer; } //不带completer的构造器 protected CountedCompleter() { this.completer = null; } //抽象的compute方法,它是类似ForkJoinTask的扩展方式. public abstract void compute(); //重写的exec方法 protected final boolean exec() { //直接调用compute方法并返回false.回到ForkJoinTask类中的doExec方法,可以看到 //调用了exec后若得到true值,将会执行setCompletion(NORMAL)动作.且该动作将在首次唤醒等待结果的线程. //此处return了false,将不去执行上述操作.详情参考上篇文章. compute(); return false; }
以上是CountedCompleter的签名,字段,构造器和核心的抽象方法compute,其实整个CountedCompleter就是在围着这点东西转,首先看一看与ForkJoinTask的结合.
显然,CountedCompleter简单重写了ForkJoinTask的exec方法简单调用抽象的compute方法并返回false,当出现异常时,流程不变,但当compute方式正常完成的情况,将不可能进行父类后续的设置完成和唤醒操作.因此它必须由CountedCompleter自定义的完成.
而CountedCompleter也确实暴露了一些公有函数,但是调用的时机却要用户继承它之后决定.我们先来继续一些辅助源码并理解Completer的设计理念,稍后再来看它的完成方法.
//onCompletion勾子方法,默认空实现. //CountedCompleter在tryComplete方法中会在符合完成的第一个条件(无挂起任务)的情况下执行它. //complete方法也会对它有无条件地调用. //关于这两个方法稍后详述. //它的实现取决于要实现的操作,并行流中的一些ops会在此处进行一些中间结果处理,比如结果集的合并(reduce操作). public void onCompletion(CountedCompleter> caller) { } //重写ForkJoinTask中的方法.上篇源码分享文章中提过,在ForkJoinTask的setExceptionalCompletion会调用internalPropagateException //传递异常,而且是个空实现,而在CountedCompleter中实现了该方法,并在内部调用onExceptionalCompletion void internalPropagateException(Throwable ex) { CountedCompleter> a = this, s = a; //循环判断每一个task是否要传递异常给它的completer //无方法体的while循环.道格大神的代码神迹. while (a.onExceptionalCompletion(ex, s) && //要传递给completer且具备completer且completer还不是完成态(正常或非正常) (a = (s = a).completer) != null && a.status >= 0 && //则令completer去记录异常完成,若记录成功则进入下一轮循环. a.recordExceptionalCompletion(ex) == EXCEPTIONAL) ; //因为onExceptionalCompletion固定返回true,若没有中间完成的任务,直到最后一个completer,也就是root, //root不具备completer,将中断循环. } //异常完成勾子方法. //按上一节的概念,当ForkJoinTask执行出错,即exec->compute出错时,最终会调到此勾子.或当手动completeExceptionally或cancel时. public boolean onExceptionalCompletion(Throwable ex, CountedCompleter> caller) { //直接返回true,显然也是一个供扩展的方法.返回true代表异常应该传递给this的completer. return true; } //返回completer public final CountedCompleter> getCompleter() { return completer; } //返回挂起任务数量. public final int getPendingCount() { return pending; } //设置挂起任务数量 public final void setPendingCount(int count) { pending = count; } //原子地为挂起任务数量添加delta public final void addToPendingCount(int delta) { U.getAndAddInt(this, PENDING, delta); } //原子地将当前挂起任务数量从expected更改到count public final boolean compareAndSetPendingCount(int expected, int count) { return U.compareAndSwapInt(this, PENDING, expected, count); } //将当前任务的挂起数量原子减至0. public final int decrementPendingCountUnlessZero() { int c; do {} while ((c = pending) != 0 && !U.compareAndSwapInt(this, PENDING, c, c - 1)); return c; } //返回root completer.逻辑很简单. public final CountedCompleter> getRoot() { CountedCompleter> a = this, p; while ((p = a.completer) != null) a = p; return a; }
以上是几个工具函数,逻辑也很简单,仅有一处可能留有疑问:完成态/异常态是如何传递的.
现在大家应该理解为什么ForkJoinTask要将internalPropagateException置为空实现了,显然,对于不同方式的实现,确实需要不同的传递行为.CountedCompleter保存了一个类似"栈结构"的任务链,虽然提前讲到栈底即为root任务(当然root在底部还是顶部本身不重要),显然任何一个子任务出现了问题,与它关联的父任务的行为显然要有一个明确的由子类定义的规则.
我们看到在重写的internalPropagateException方法中,不停地判断当前任务是否要将异常信号传递给链上的下一个任务(on方法始终返回true,没关系我们可以在子类中重写),然后让未完成的completer去记录同一个异常ex.
那么问题来了,只要completer已完成过(正常完成过异常完成或取消),显然while循环中断,completer和它的后续completer将不会被处理(1).同样,若传递异常的任务本身就是另一个或几个任务的completer,它的异常信息显然不会反向传递(2).
对于问题(1),显然如果后续的completer已出现过异常,必然也会走一遍同样的逻辑,传递给后面的completer,如果它正常完成,也必然要有相应向后传递的行为,否则无法解决(1),我们接下来即论述相关方法.
对于问题(2),显然问题(1)中描述的情况与此有所交集,如果我们建立了一个CountedCompleter任务,并在compute方法中大肆fork子任务入队,fork之后不等子任务完成,也不获取子任务的执行结果,直接将父任务setCompletion或者setExceptionalCompletion,子任务还是会继续执行的.
为了便于理解,我们继续来看与任务的完成有关的方法.
//尝试完成根任务或减少栈链下游的某一个completer的挂起数(包含它自身). public final void tryComplete() { //1.初始用a保存this,后续为当前操作任务,用s保存a. CountedCompleter> a = this, s = a; for (int c;;) { //2.第一次进入或在6造成竞态的某一次循环中,a(this或this的completer链中的某一个)的的挂起任务数为0,代表它挂起的任务都完成了. if ((c = a.pending) == 0) { //3.a的勾子方法,若已经运行过4,且判断条件为假未能到5并在下一次循环重新回到3的情况,a!=s且a是s的completer, //在对onCompletion重写时,可以根据this与参数是否相等进行判断,如并行流聚合时可以根据这个条件进行结果集的合并. a.onCompletion(s); //4.将a指向自己的completer,s指向原来的a. if ((a = (s = a).completer) == null) { //5.原来a的completer不存在,即a不是root,不需要再传递了,让root进行quietlyComplete并返回. //此时说明整条链上的competer挂起任务全部是0. s.quietlyComplete(); return; } //隐藏的7.当原a的completer存在(a不是root)的情况,继续对该complter判断挂起任务数或尝试减1,对下一个元素开启下一轮循环. } //6.对this的completer栈的某一次循环时发现了挂起任务数不为0的,则对该completer的挂起数减1, //表示它挂起的任务完成了一个,并返回.若在此时恰好出现了竞态,另一条链上的任务抢先减一,则当前 //的a要进入下一循环,它可能会在2处判断通过,进入到链上的下一个completer的传播逻辑. else if (U.compareAndSwapInt(a, PENDING, c, c - 1)) return; } } //基本等效于tryComplete,只是不执行onCompletion,tryComplete会在判断链上某个completer的挂起任务数是0立即执行onCompletion. public final void propagateCompletion() { CountedCompleter> a = this, s = a; for (int c;;) { if ((c = a.pending) == 0) { if ((a = (s = a).completer) == null) { s.quietlyComplete(); return; } } else if (U.compareAndSwapInt(a, PENDING, c, c - 1)) return; } } //complete方法,逻辑简单,丝毫不考虑挂起数,直接执行当前task的几个完成方法,并尝试对completer进行tryComplete. //它不改变自己的挂起任务数,但会让completer对栈上的其他completer或自身尝试减少挂起数或完成root. public void complete(T rawResult) { CountedCompleter> p; setRawResult(rawResult);//使用参数设置为当前任务的结果,尽管它为空方法. onCompletion(this);//直接调用onCompletion勾子. quietlyComplete();//安静地将status置为NORMAL. if ((p = completer) != null) //自己不改变自身挂起数,也不尝试完成root,但让completer尝试去向下执行这些操作. p.tryComplete(); } //没办法多带带理解这个方法名.官方注释是和nextComplete放置在循环中使用. public final CountedCompleter> firstComplete() { for (int c;;) { if ((c = pending) == 0) //1.当前task没有挂起任务数,则返回它. return this; else if (U.compareAndSwapInt(this, PENDING, c, c - 1)) //2.否则尝试减少一个挂起任务数并返回null.但当出现竞态时,可能导致未能进入2而在下一次循环进入1. return null; } } //结合前面的firstComplete互相理解,它会对当前任务判断是否有completer,有则对该completer进行firstComplete, //否则将当前任务安静完成并返回null. //故结果只能返回null或completer public final CountedCompleter> nextComplete() { CountedCompleter> p; if ((p = completer) != null) //有completer且completer已无挂起任务数,则返回completer, //有completer且completer有挂起任务数,则尝试对该任务数减一并返回null.出现竞态则可能返回该completer. return p.firstComplete(); else { //无completer,安静完成当前任务并返回null. quietlyComplete(); return null; } } //等同于getRoot().quietlyComplete() public final void quietlyCompleteRoot() { for (CountedCompleter> a = this, p;;) { if ((p = a.completer) == null) { a.quietlyComplete(); return; } a = p; } } //如果当前任务未完成,尝试去出栈执行,并处理至多给定数量的其他未处理任务,且对这些未处理任务 //来说,当前任务处于它们的完成路径上(即这些任务是completer栈链的前置任务),实现特殊的工作窃取. public final void helpComplete(int maxTasks) { Thread t; ForkJoinWorkerThread wt; if (maxTasks > 0 && status >= 0) { if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) //当前线程是ForkJoinWorkerThread,尝试执行当前任务并尝试从线程的工作队列中尝试帮助前置任务执行. (wt = (ForkJoinWorkerThread)t).pool. helpComplete(wt.workQueue, this, maxTasks); else //使用common池的externalHelpComplete方法. ForkJoinPool.common.externalHelpComplete(this, maxTasks); } }
上一段代码总体逻辑不难,有以下几点总结:
1.显然tryComplete方法在调用后的最终结果只有两个:自己或completer链前方的某一个completer的挂起任务数减1(1),自己或completer链前方某一个completer(root)的quietlyComplete被执行(2).简单来说,就是让root进行quietlyComplete(链上每一个挂起任务数都是0)或让链上的某一个completer减少一个挂起任务.
2.tryComplete方法只会对root进行quietlyComplete,进而setComplete(NORMAL),对于链上的其他任务,最多会帮助挂起数减一,而不会把它们置为完成态,但是线程池在执行任务时,或者直接对一个链上的completer进行invoke,doExec甚至get等操作时,这些方法会将该中间completer进行setComplete.
3.每一个CountedCompleter都可能有自己的completer栈链,每一个CountedCompleter也可以位于其他CountedCompleter的栈链上且上游不唯一而下游唯一一(倒树形),任何一条栈链只能有一个root,root的completer为null.
4.从tryComplete方法来看正常运行情况下的规则,每一个CountedCompleter的tryComplete只能向前影响到链上的另一个completer,因为实现数量的增加方法有好几处,用户在实现时,随时可能将一些completer的数量设置成任意的数,故可以出现前面tryComplete注释中隐藏的7的情况,即存在一个completer,它的下一个completer的挂起数是0,它却能将下下个completer安静完成或将其挂起数减一,即跨无挂起数节点传递.
5.前面列出的helpComplete方法是CountedCompleter的特殊工作窃取方法(或者也不能叫作窃取,因为非common池情况窃取的是自己线程的任务,common池则依赖于一个探测值),具体的窃取细节在ForkJoinPool中,将在后面的文章中论述,但简单的逻辑已经在注释中描述清楚,把它归到这一块,也是因为它与前面描述的逻辑有所纠葛.124提到了tryComplete的向前影响结果,而在实际的应用中,我们可能会有各种各样的情景,ForkJoin框架无法阻止我们对ForkJoinTask的exec函数进行任意式的扩展,也无法阻止我们对CountedCompleter的compute任意扩展,那么如何在我们任意拓展的情景下保持效率和健壮?比如下面这个使用场景:
a.建立一种ForkJoinTask,直接继承CountedCompleter并重写compute方法,则它可以运行在ForkJoinPool中.
b.我们接下来在compute方法中多次根据计算结果集的大小进行拆分并递归fork子任务入池,父任务成为子任务的completer,同时compute方法自身也负责不可拆分的计算逻辑,并在自身这一块计算结束后,可能等待所有fork入池的子任务结束,也可能不等待子任务,直接结束父任务,让线程空出来做其他的事.
c.所有子任务结束后,使用一个合并函数合并子任务的结果集和自身的结果,并作为最终的结果.然后tryComplete(如果b中使用了join,或者判断当前任务是root).
显然,b中fork出的子任务,也同样要执行bc的逻辑.那么可能出现这样的情况:
不同的父任务子任务在ForkJoinPool最初始压入当前工作线程的队列中,但随时可能被其他工作线程甚至外部线程偷去执行.
父任务抢先抢得运行资源,运行完自己计算的部分,而入池的子任务及子孙任务有大量未完成.
难道父任务的执行线程就这样干等?在前一篇文章中说过,ForkJoin框架适宜多计算,轻io,轻阻塞的情况,且本身就是为了避免线程忙的忙死饿的饿死,因此每个任务等待子任务执行结束是不可取的,这或许也是为什么有了ForkJoinTask,却还要有CountedCompleter的原因之一吧.
若我们在任何每一个任务中只是单纯地将该分出去的子任务fork入池并执行自己那一部分,并不让当前线程join子任务呢?(事实上不join子任务恰好可以将当前线程的资源腾出来做其他的事)
所以,除了前面5中提到的若干种(124)向前影响completer栈链的挂起数或root的完成态,还需要一个能向栈链后方有所影响的操作,比如帮助子任务的完成,毕竟子任务也是b中fork出来且由自己入队的.
helpComplete方法就可以做到这一点,它在ForkJoinPool中,它仅应在当前任务未完成时使用,首先它会尝试将当前任务从出队列并执行(ForkJoinPool::popCC及成功后续doExec,LIFO),出队失败则表示正在被执行甚至被偷去执行.出队这一步之后,再尝试自己的线程工作队列中找出自己的子孙任务(FIFO)并进行执行(ForkJoinPool::pollAndExecCC).
而若执行完某个父任务的工作线程必然会调用tryComplete等有关方法,将自身或栈链后方的某一个completer的挂起数减一,甚至因为一些不合理的api使用(如直接更改了后方某个任务的挂起数量)而直接终止了root,将root任务标记成完成态.(注意前面强调的"运行完自己计算的部分",这就是否定本句话的关键了,前面也说明"helpComplete仅在当前任务未完成时使用",显然,完成了自己负责的计算内容并不代表当前任务完成了,因为它的子任务还没有完成,因此它不会调用tryComplete,并且可以去帮助子任务)
同时,执行完父任务负责的计算内容的任务线程也会去找它栈链后方的其他任务,按照b的逻辑,这将是它的子任务,帮助它们完成,每完成一个子任务(子任务无子任务,不再help的情况),会进行tryComplete传递一次.
余下的方法很简单.
//重写自ForkJoinTask的结果,前文也说过CountedCompleter也不维护result,返回null. //但并行流或者一些其他并行操作可以实现此结果,比如ConcurrentHashMap中支持的map reduce操作. public T getRawResult() { return null; } //同上,默认空,一些子类会有特别的实现. protected void setRawResult(T t) { }
显然,completer栈链上的所有任务是可以并行执行的,且每一个完成都可以向后tryComplete一次,并在其后可以帮助前面的任务完成,而我们若实现上述两个方法,完全可以将自身运算的结果设置进去,在root被安静完成后,ForkJoinTask将可以get到结果(或join也将返回结果),可在此时合并计算结果,有些结果显然是可以并行的.
一些操作,比如find类型,任何一个子任务完成了find,就可以直接让root结束,然后直接让整条栈链上的任务cancelIgnoringExceptions.
一些需要聚合每一个任务结果的操作,比如reduce类型,需要每个父任务根据子任务的结果去reduce,它的父任务再根据他和兄弟任务的结果reduce,最终合并到root.显然,mapper由子任务实现,reducer由父任务实现.
一些接近find或reduce类型(或者说find的变种),比如filter,每一个任务都会有结果,这个结果可能是自己负责的原集中的一部分子集,也可能就是个空集,父任务合并每个子任务的结果集,直到root.
排序类型的操作,如使用归并排序,显然每个父任务即是divider也是merger,分解出的每个子集交给子任务去计算,父任务再去负责merge.
......
以上是ForkJoinTask的抽象子类CountedCompleter的源码分析,接下来我们继续分析工作线程.
ForkJoinWorkerThread源码只要对java的线程结构稍有了解,ForkJoinWorkerThread的源码十分简单,且前面提过,ForkJoinTask被声称是一个轻量于普通线程和Future的实体,而它在ForkJoinPool中的运行载体便是ForkJoinWorkerThread,这个轻量究竟体现在何处?
//类签名,直接继承自Thread public class ForkJoinWorkerThread extends Thread { //每个ForkJoinWorkerThread都只能属于一个线程池,且保存该池的引用. final ForkJoinPool pool; //每个ForkJoinWorkerThread都有一个工作队列, 显然队列中的任务就是该线程干活的最小单位了.它也是工作窃取机制的核心. final ForkJoinPool.WorkQueue workQueue; //构造函数,创建时指定线程池. protected ForkJoinWorkerThread(ForkJoinPool pool) { // 线程名称 super("aForkJoinWorkerThread"); this.pool = pool; //将工作线程注册到ForkJoinPool后会返回一个工作队列,供当前线程使用和供其他线程偷取. this.workQueue = pool.registerWorker(this); } //带线程组的构造器 ForkJoinWorkerThread(ForkJoinPool pool, ThreadGroup threadGroup, AccessControlContext acc) { super(threadGroup, null, "aForkJoinWorkerThread"); //inheritedAccessControlContext是从Thread继承下来的,字面意思是继承的访问控制上下文,设置为acc. U.putOrderedObject(this, INHERITEDACCESSCONTROLCONTEXT, acc); //注册入池之前,清除掉本地化信息 eraseThreadLocals(); this.pool = pool; this.workQueue = pool.registerWorker(this); }
//返回注册的池.
public ForkJoinPool getPool() { return pool; } //返回当前线程工作队列在池中的索引,每个队列都会维护一个在池中的索引. public int getPoolIndex() { return workQueue.getPoolIndex(); } //空函数,可交给子类实现,按照官方注释,它的作用是在构造之后(这个构造不是指new出线程对象, //而是在run方法已进入的时候,说明"构造"是指线程已经完成了创建能够正常运行),处理任务之前. protected void onStart() { } //工作线程终止时的勾子方法,负责执行一些有关的清理操作.但是若要重写它,必须在方法的 //最后调用super.onTermination.参数exception是造成该线程终止的异常.若是正常结束, //则它是null. protected void onTermination(Throwable exception) { } //核心方法. public void run() { //doug在这一块标注"只运行一次",查看ForkJoinPool的源码, //ForkJoinPool中会有一个WorkQueue的数组,在取消线程的注册后, //本线程关联的WorkQueue会从该数组移除,但WorkQueue中的array不会置空. if (workQueue.array == null) { Throwable exception = null; try { //前面说过的预先操作 onStart(); //用线程池的runWorker方法执行,传入队列. pool.runWorker(workQueue); } catch (Throwable ex) { //发生异常,中断前记录下来 exception = ex; } finally { try { //将记录下来的异常调用勾子方法. onTermination(exception); } catch (Throwable ex) { if (exception == null) //执行勾子方法本身出现了异常,记录下来 exception = ex; } finally { //调用线程池的解除注册方法,会将本线程的WorkQueue从数组中移除,同时使用上述异常. pool.deregisterWorker(this, exception); } } } } //擦除本地变量.把当前线程的两个ThreadLocalMap全部置空 final void eraseThreadLocals() { U.putObject(this, THREADLOCALS, null); U.putObject(this, INHERITABLETHREADLOCALS, null); } //每正常运行完一次顶级task,就调用一次它.这个顶级任务自带易误解天性,其实可以理解为每一次从队列取出的任务. void afterTopLevelExec() { } //自带子类.它不具备任何特殊权限,也不是用户定义的任何线程组的成员,每次运行完一个顶级任务, //则擦除本地化变量. static final class InnocuousForkJoinWorkerThread extends ForkJoinWorkerThread { //自已创建默认线程组. private static final ThreadGroup innocuousThreadGroup = createThreadGroup(); //访问控制上下文支持权限. private static final AccessControlContext INNOCUOUS_ACC = new AccessControlContext( new ProtectionDomain[] { new ProtectionDomain(null, null) }); //构造函数. InnocuousForkJoinWorkerThread(ForkJoinPool pool) { super(pool, innocuousThreadGroup, INNOCUOUS_ACC); } @Override void afterTopLevelExec() { //在每一次从队列取出的"顶级"任务运行后即擦除本地化变量. eraseThreadLocals(); } @Override public ClassLoader getContextClassLoader() { //如果获取线程上下文类加载器,永远直接返回系统类加载器. return ClassLoader.getSystemClassLoader(); } //尝试对未捕获异常处理器的设置,忽略. @Override public void setUncaughtExceptionHandler(UncaughtExceptionHandler x) { } //禁止直接设置线程的上下文类加载器. @Override public void setContextClassLoader(ClassLoader cl) { throw new SecurityException("setContextClassLoader"); } //创建一个以顶级线程组为父的线程组. private static ThreadGroup createThreadGroup() { try { sun.misc.Unsafe u = sun.misc.Unsafe.getUnsafe(); Class> tk = Thread.class; Class> gk = ThreadGroup.class; long tg = u.objectFieldOffset(tk.getDeclaredField("group")); long gp = u.objectFieldOffset(gk.getDeclaredField("parent")); //当前线程的所属组. ThreadGroup group = (ThreadGroup) u.getObject(Thread.currentThread(), tg); //循环条件,当前线程的所属组不是null while (group != null) { //不停地循环向上取parent ThreadGroup parent = (ThreadGroup)u.getObject(group, gp); if (parent == null) //发现无parent的线程组,说明是系统顶级线程组,用它当parent创建一个"无害"线程组返回. return new ThreadGroup(group, "InnocuousForkJoinWorkerThreadGroup"); //有parent,把它赋给group开启下一轮循环. group = parent; } } catch (Exception e) { //有异常用Error包装抛出. throw new Error(e); } //不能return就抛出Error. throw new Error("Cannot create ThreadGroup"); } }
以上是工作线程的代码,粗略总结一下它和普通线程的区别.
首先,它内部会维护一个工作队列,用它来实现任务调度和窃取.
其次,它提供了一些扩展,如每次顶层任务运行结束,清理ThreadLocal,这也是一种保护机制,避免同线程的本地化数据随之污染.但粗略去看ForkJoinPool的代码,发现它只是在每次从队列取出并运行完一个任务后清除,并称这个为"顶级循环",这倒也没错,但这个任务并不能称之为顶级任务,因为这里的任务类型是ForkJoinTask,不一定是CountedCompleter等明显标识了依赖关系的子类,所以父任务和子任务被塞进一个队列,即使未被窃取,只由当前线程执行,两次的本地化数据也是不同的.
不过如果我们在ForkJoinTask的exec方法中加入本地化,或在CountedCompleter中加入本地化,显然每一个在此生成的子任务都会在相应的线程执行doExec时设置这些属性,并在执行结束后清除.
最后官方提供的默认子类,以及一些线程组,优先级,权限等作者也未深入研究,但是我们构建线程池的时候有一个参数就是"线程工厂",了解下它或许能对后续的ForkJoinPool源码阅读有所帮助.
接下来简述一个官方提供的案例,并以此聊一聊并行流.
官方案例第一节论述了CountedCompleter,显然它作为一个抽象类,只是定义了某一些环节,以及一些环节的子环节的组合过程,而具体的实现与使用它定义的api则由用户实现,它的源码中并无使用(当然也可以看一些子类,但比较复杂),在CountedCompleter的源码注释中,道格大神提供了若干案例,这里举出两个来简要说明一下前面论述过的使用方式,也可以为下一节论述官方提供的子类(并行流api中)提供阅读基础.
第一个是并行的可窃取的分治查找算法.
@Test public void testDivideSearch(){ Integer[] array = new Integer[10000000]; for(int i = 0; i < array.length; i++){ array[i] = i+1; } AtomicReferenceresult = new AtomicReference<>(); Integer find = new Searcher<>(null, array, result, 0, array.length - 1,this::match).invoke(); LOGGER.info("查找结束,任务返回:{},result:{}",find,result.get()); } static class Searcher extends CountedCompleter { final E[] array; final AtomicReference result; final int lo, hi; final Function matcher; Searcher(CountedCompleter> p, E[] array, AtomicReference result, int lo, int hi,Function matcher){ super(p); this.array = array; this.result = result; this.lo = lo; this.hi = hi; this.matcher = matcher; } @Override public void compute() { int l = this.lo;int h = this.hi; while(result.get() == null && h >= l){ if(h - l >=2){ int mid = (l + h)>>>1; //添加挂起任务数量,这样当出现tryComplete时可以触发root的结束(未查到) addToPendingCount(1); new Searcher (this,array,result,mid,h,matcher).fork(); h = mid; }else{ E x = array[l]; if(matcher.apply(x) && result.compareAndSet(null,x)){ super.quietlyCompleteRoot(); } break; } } //当前未有任何一个线程查到结果,当前任务也完成了子集查找,减少一个挂起数量,若挂起数已减至0则终止. if(null == result.get()) tryComplete(); } } private boolean match(Integer x) { return x > 2000000 && x%2 ==0 && x%3 == 0 && x%5 ==0 && x %7 ==0; }
该案例的逻辑很简单,给定一个非常大的数组,充分利用本机的资源去查找满足一个条件的元素.为了方便,在具体的查找数据上选定了整型,查找的条件也非常简单.
在该案例中,会对结果进行分治,首先分治出足够多的子任务,剩下的不需再分的父任务由当前线程完成,子任务则压入工作队列,其他空闲的线程就会来偷取子任务并执行.当有任务一个子任务查找到相应的数字后,即将它存放到result,并安静地完成根任务.
此时整个任务链处在一个非常尴尬的情况:查找到结果的子任务将root设置为完成,而整条链上的非root任务均未完成.但因循环条件不满足,退出了循环.此时查到result已有值,并不执行最后的tryComplete,执行结束,任务的status依旧为未完成,是否有重复执行的问题?
答案是没有问题,因为ForkJoinTask绝对会在ForkJoinPool中调度(哪怕是common池),在common池中,任务执行前必须出队,尽管compute方法在本例中没有将这些任务设置为完成,但任务不会被二次执行.可见,上一章中费大力介绍的status字段也有无用的时候.
但是除了root任务需要使用到获取结果的功能,需要保证status是负数,它产生的子孙任务还有什么用呢?所有compute方法会因为循环中止而结束,此后的这些任务不存在任何外部引用,会被gc清理,即使存在外部引用,用它去获取子孙任务的执行情况或result也没有任何意义.
显然这个案例解决了至少两个疑问,一是怎么实现一个保存result的ForkJoinTask,二是ForkJoin框架如何在查找方面大幅提升性能,很明显,相比单线程遍历的办法,此例多线程查询,且任何一个子任务在并行条件下完成了查询,整个大任务均可以终止.
第二个是传说中的map reduce.大数据中常使用此概念(跨节点).
在并行流中,map可以代表非阻断操作,reduce可以代表阻断操作,但是reduce同样可以并行地执行.
道格在注释上给出了两个map reduce案例,我们只看第一个,它也是后续并行流一节我们要看的例子比较相近的解法.方法二有些绕,较难理解,但也优雅.
@Test public void testMapReduce() { Integer[] array = {1, 2, 3}; //方法一. Integer result = new MapRed<>(null, array, (a)->a+2, (a,b)->a+b, 0,array.length).invoke(); LOGGER.info("方法一result:{}",result); //方法二我就不抄了,就在官方注释上. result = new MapReducer<>(null, array, (a) -> a + 1 , (a, b) -> a + b, 0, array.length, null).invoke(); LOGGER.info("方法二result:{}", result); } /** * 第一种map reduce方式,很好理解. * @param*/ private class MapRed extends CountedCompleter { final E[] array; final MyMapper mapper; final MyReducer reducer; final int lo, hi; MapRed sibling;//兄弟节点的引用 E result; MapRed(CountedCompleter> p, E[] array, MyMapper mapper, MyReducer reducer, int lo, int hi) { super(p); this.array = array; this.mapper = mapper; this.reducer = reducer; this.lo = lo; this.hi = hi; } public void compute() { if (hi - lo >= 2) { int mid = (lo + hi) >>> 1; MapRed left = new MapRed(this, array, mapper, reducer, lo, mid); MapRed right = new MapRed(this, array, mapper, reducer, mid, hi); left.sibling = right; right.sibling = left; //只挂起右任务 setPendingCount(1); right.fork(); //直接运算左任务. left.compute(); } else { if (hi > lo) result = mapper.apply(array[lo]); //它会依次调用onCompletion.并且是自己调自己或completer调子, //且只有左右两个子后完成的能调成功(父任务的挂起数达到0). tryComplete(); } } public void onCompletion(CountedCompleter> caller) { //忽略自己调自己. if (caller != this) { //参数是子任务. MapRed child = (MapRed ) caller; MapRed sib = child.sibling; //设置父的result. if (sib == null || sib.result == null) result = child.result; else result = reducer.apply(child.result, sib.result); } } public E getRawResult() { return result; } } //mapper和reducer简单的不能再简单. @FunctionalInterface private static interface MyMapper { E apply(E e); } @FunctionalInterface private static interface MyReducer { E apply(E a, E b); }
上面的逻辑也很简单,首先就是对任务的分解,简单的将任务分为左和右,左直接由父任务执行(可能再分),右则入池,所有子任务直到不能再分(叶子任务)以map为result,每个叶子任务完成后会调用tryComplete.
这个动作会触发一系列的completer栈元素的挂起数下降或完成,显然,如果把completer理解为一个普通树(这是作者很少见到的非二叉树的情况,尽管这个例子写成了二叉树,我们完全可以在compute中将父任务一分为多,而不是限2个),从叶子节点开始,每个叶子节点完成(result是mapper的结果)会尝试onCompletion并减少父节点的挂起任务数,但只有同父节点的最后一个兄弟节点可以进入onCompletion设置父节点的结果,并且由于这个设置过程的前提是父节点符合挂起任务数为0,因此符合循环继续的条件,叶子节点的动作会继续向上判断父节点的父节点,直到root为止.假设线程数量足够,保证每个子任务都有一个线程处理,那么深度每上一层,就会有一半(非二叉树的情况每个父节点只能有一个通过)的执行叶子节点任务的线程因不符合某个任务的挂起数量为0的条件而退出,这样逐级传导,最后到root调用它最后一个子节点的onCompletion,使用reducer进行合并.
本例中进行结果合并的写法(onCompletion)只适合二叉树,有兴趣的读者可以看看道格在注释中给出的第二种写法,几叉都可以.而且该实现很优雅,并未写onCompletion函数,但是写法真心够绕的.
并行流简述在JAVA8中支持了lamda表达式的同时,也支持了函数式编程,由此出现了一种新型的计算方式:流式计算,也出现了一种让包括作者在内很多人兴奋不已的编程方式:响应式编程.
流式计算的核心在于Stream api,流有很多分类,比如并行流和串行流,这点可以顾名思义,同样的,流中的每一个操作都可以划分类型,比如阻断操作和非阻断操作.
java中实现并行流就是基于这些操作,CountedCompleter的一些子类就是这些操作的类型,显然,如在前一篇文章所说,使用了并行流,就是使用了ForkJoin框架.
当我们使用下面的代码,会发生什么操作?
Stream.of(1,2,3,4,5).parallel().map(x -> x + 1).reduce((a, b) -> a + b).get(); //map只是将动作简单地记了下来,包装起来,等到阻断操作时才会真正执行. 位于ReferencePipeline public finalStream map(Function super P_OUT, ? extends R> mapper) { Objects.requireNonNull(mapper);//非空检查 //返回一个无状态操作. return new StatelessOp (this, StreamShape.REFERENCE, StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) { @Override Sink opWrapSink(int flags, Sink sink) { //典型的适配器模式.将action一律封装为Sink. return new Sink.ChainedReference (sink) { @Override public void accept(P_OUT u) { downstream.accept(mapper.apply(u)); } }; } }; } //阻断操作reduce位于 ReferencePipeline public final Optional reduce(BinaryOperator accumulator) { return evaluate(ReduceOps.makeRef(accumulator)); } //AbstractPipeline final R evaluate(TerminalOp terminalOp) { assert getOutputShape() == terminalOp.inputShape(); if (linkedOrConsumed) throw new IllegalStateException(MSG_STREAM_LINKED); linkedOrConsumed = true; return isParallel() ? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags())) : terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags())); } //TerminalOp阻断操作接口的默认方法 default R evaluateParallel(PipelineHelper helper, Spliterator spliterator) { if (Tripwire.ENABLED) Tripwire.trip(getClass(), "{0} triggering TerminalOp.evaluateParallel serial default"); return evaluateSequential(helper, spliterator); } //看ReduceOps 它返回了一内部类ReduceTask public R evaluateParallel(PipelineHelper helper, Spliterator spliterator) { return new ReduceTask<>(this, helper, spliterator).invoke().get(); } //内部类ReduceTask间接继承自CountedCompleter private static final class ReduceTask > extends AbstractTask > { private final ReduceOp op; ReduceTask(ReduceOp op, PipelineHelper helper, Spliterator spliterator) { super(helper, spliterator); this.op = op; } ReduceTask(ReduceTask parent, Spliterator spliterator) { super(parent, spliterator); this.op = parent.op; } //老外起的名子,造小孩. @Override protected ReduceTask makeChild(Spliterator spliterator) { //和上面的例子非常相似的代码,只是封装更好. return new ReduceTask<>(this, spliterator); } @Override protected S doLeaf() { //叶子节点做这个. return helper.wrapAndCopyInto(op.makeSink(), spliterator); } //重写了前面提过的onCompletion函数 @Override public void onCompletion(CountedCompleter> caller) { if (!isLeaf()) { //不是叶子节点.这条件,和前面咱们分析的多么匹配. //计算左结果 S leftResult = leftChild.getLocalResult(); //联合右结果. leftResult.combine(rightChild.getLocalResult()); //联合完的结果就是当前completer的结果. setLocalResult(leftResult); } // 直接父类是AbstractTask,它会对父,左右子帮助gc. super.onCompletion(caller); } } //AbstractTask帮助gc public void onCompletion(CountedCompleter> caller) { spliterator = null; leftChild = rightChild = null; } //更多实现细节自阅...
显然,并行流(至少我举的这个例子)是基于ForkJoin框架的.分治的思想与前面道格的例子相似,只是更加优雅和封装更好.有了前面的基础,若要详细熟悉并行流原理,需要进一步了解的只有他们的继承树,分割聚合组件等边角料,核心的调度思想已经不再是困难.
回到问题,当我们使用并行流时发生了什么?首先是非阻断操作时,与串行流情况同样,也是先将action封装成适配器,仅在阻断操作发生时的调度不同,并行流在阻断操作下使用ForkJoin框架进行调度,任务的分割则使用它的Splitor,结果的合并也有它的Combiner.其他的流程与上面的案例无异.
后语1.CountedCompleter使用普通树的结构存放动作,但是它又是另类的树,因为子节点能找到父节点,父节点却找不到子节点,而只知道子节点代表的动作未执行的数量,因此或许从访问方式的角度来看还是用栈来理解更好.在这里树既是数据结构,也是一个另类的操作栈.只从一个completer往下看,它是个栈,但从父节点的角度来讲,它是一个访问不到子节点的普通树(或许我们不应该强行为它套上一个数据结构,不然总觉得不伦不类,但是用树这个形状便于理解).每个节点会存放挂起任务数量,每个节点的任务完成未必会设置它自己的完成态,但会尝试将completer父元素栈(或者树的一条线)上的每个任务挂起数量减一或将根节点安静置为完成态.关于具体的理解和代码实现,以及如何保存一个任务的运行结果,可以参考前面案例的章节,也可以以此为基础去看并行流的源码,但也要相应的理解并行流为了便捷实现而提供的各种分割合并组件.
2.ForkJoinWorkerThread是运行在ForkJoinPool中的主要线程,它内部维护了一个工作任务队列,并存放了该队列在线程池中的间接索引.借此实现任务的窃取,避免过于空闲等待,任务fork会直接push到该队列,第一次扩容时,才给该队列初始化任务数组,当线程从池中卸载时,不会清除掉该数组,这样线程无法再次启动.线程的启动有一些勾子,官方提供的线程工厂有两个,一个直接创建ForkJoinWorkerThread,另一个创建它的子类
InnocuousForkJoinWorkerThread,它除了一些安全策略外,最大的区别在于ForkJoinWorkerThread在注册入池前进行本地化数据的清理,而它则每次完成一个主任务处理就清理一次.
3.并行流是ForkJoin框架的一个典型应用,JAVA8 Stream api中的并行流定义了大量的以CountedCompleter为基础的操作.利用分割/合并和周边组件实现了基于ForkJoin框架的并行计算调度.
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/77888.html
摘要:前言在前面的三篇文章中先后介绍了框架的任务组件体系体系源码并简单介绍了目前的并行流应用场景框架本质上是对的扩展它依旧支持经典的使用方式即任务池的配合向池中提交任务并异步地等待结果毫无疑问前面的文章已经解释了框架的新颖性初步了解了工作窃取 前言 在前面的三篇文章中先后介绍了ForkJoin框架的任务组件(ForkJoinTask体系,CountedCompleter体系)源码,并简单介绍...
摘要:前言在前面的文章和响应式编程中提到了和后者毫无疑问是一个线程池前者则是一个类似经典定义的概念官方有一个非常无语的解释就是运行在的一个任务抽象就是运行的线程池框架包含和若干的子类它的核心在于分治和工作窍取最大程度利用线程池中的工作线程避免忙的 前言 在前面的文章CompletableFuture和响应式编程中提到了ForkJoinTask和ForkJoinPool,后者毫无疑问是一个线程...
摘要:分区函数返回一个布尔值,这意味着得到的分组的键类型是,于是它最多可以分为两组是一组,是一组。当遍历到流中第个元素时,这个函数执行时会有两个参数保存归约结果的累加器已收集了流中的前个项目,还有第个元素本身。 一、收集器简介 把列表中的交易按货币分组: Map transactionsByCurrencies = transactions.stream().collect(groupi...
摘要:类似的你可以用将并行流变为顺序流。中的使用顺序求和并行求和将流转为并行流配置并行流线程池并行流内部使用了默认的,默认的线程数量就是处理器的数量包括虚拟内核通过得到。 【概念 并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每一个数据块的流。在java7之前,并行处理数据很麻烦,第一,需要明确的把包含数据的数据结构分成若干子部分。第二,给每一个子部分分配一个独立的线程。第三,适...
摘要:这减轻了手动重复执行相同基准测试的痛苦,并简化了获取结果的流程。处理项目的代码并从标有注释的方法处生成基准测试程序。用和运行该基准测试得到以下结果。同时,和的基线测试结果也有略微的不同。 Java 8 已经发布一段时间了,许多开发者已经开始使用 Java 8。本文也将讨论最新发布在 JDK 中的并发功能更新。事实上,JDK 中已经有多处java.util.concurrent 改动,但...
阅读 1103·2021-09-22 15:37
阅读 1131·2021-09-13 10:27
阅读 2465·2021-08-25 09:38
阅读 2444·2019-08-26 11:42
阅读 1524·2019-08-26 11:39
阅读 1554·2019-08-26 10:58
阅读 2316·2019-08-26 10:56
阅读 2568·2019-08-23 18:08