ForkJoinPool初略分析

之前曾经分析过线程池,在Jdk1.7中出现了一种新的线程池ForkJoinPool,Java8中的parallelStream API就是基于ForkJoinPool实现的,它和普通的线程池不太一样,本文来分析下ForkJoinPool内部运行原理。

本来想着直接分析parallelStream,看了半天看不懂,果然学习没有捷径啊…

 

为什么要有ForkJoinPool:

    计算机中一个任务一般是由一个线程来处理的,如果此时出现了一个非常耗时的大任务,比如对一个大的ArrayList每个元素进行+1操作,如果是普通的ThreadPoolExecutor就会出现线程池中只有一个线程正在处理这个大任务而其他线程却空闲着,这会导致CPU负载不均衡,空闲的处理器无法帮助工作。

    ForkJoinPool就是用来解决这种问题的:将一个大任务拆分成多个小任务后,使用fork可以将小任务分发给其他线程同时处理,使用join可以将多个线程处理的结果进行汇总;这实际上就是分治思想的并行版本。

 

ForkJoinPool使用Demo:

我们在提交任务时,一般不会直接继承ForkJoinTask,只要继承它的子类即可,框架提供了两种子类:

  1. RecursiveAction:用于没有返回结果的任务(类似Runnable)
  2. RecursiveTask:用于有返回结果的任务(类似Callable)
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool(2);
        CountTask sumTask1 = new CountTask(0, 4);

        ForkJoinTask task1 = forkJoinPool.submit(sumTask1);
        System.out.println(task1.get());


    }
}


class CountTask extends RecursiveTask {

    private static final int THREADHOLD = 2;
    private int start;
    private int end;

    /**
     * @Description: 最小的一个任务单元,以及它要处理的范围
     * @param:
     * @return:
     */
    public CountTask(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    protected Integer compute() {
        int sum = 0;

        boolean canCompute = (end - start) <= THREADHOLD;

        if (canCompute) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            //继续将任务细分
            int middle = (start + end) / 2;
            CountTask leftTask = new CountTask(start, middle);
            CountTask rightTask = new CountTask(middle + 1, end);
            //执行子任务
            leftTask.fork();
            int rightValue = rightTask.compute();
            //合并结果
            sum = leftTask.join() + rightValue;
        }
        return sum;
    }

可以看出,提交的单个大任务拆分到多小是由自身的程序来控制的。它和一般的线程池类似,外部提交的任务被封装成ForkJoinTask类型;工作线程被封装成ForkJoinWorkerThread类型;存放任务的队列称为WorkQueue[],这个和一般的线程池不同,一般的线程池只有一个存放任务的阻塞队列,后面会说道这个数组的作用。

 

代码编写思想大致是这样的:

if(任务很小){

    直接计算得到结果

}else{

    分拆成N个子任务

    leftTask.fork()

    rightTask.compute()

    leftTask.join()

}

 

ForkJoin框架的任务运行概览:

ForkJoinPool初略分析_第1张图片

前面Demo里面说过,用户输入的任务会被分割成一个个互不相关的小任务,这些小任务会被放在不同的双端队列中。 ForkJoinPool中的线程会从这些队列中获取任务执行(注意线程安全问题)。

总结下就是,ForkJoinPool中的Task分成两种:

    一种是本地线程调用submit方法提交了任务  —  Submission task

    一种是框架内部fork出来的子任务  --  Worker task

    这两种任务都会存在在WorkQueue[]数组中,但是它们不会混在同一个WorkQueue里面。ForkJoinPool内部会有一种随机哈希算法,将WorkQueue与对应的工作线程关联在一起Submission task放在WorkQueue[]数组偶数索引位置,Worker task放在奇数索引位置

    ForkJoinPool的核心是它的work-stealing算法,线程池中的线程和队列是一一对应的,当前线程把队列中的任务执行完毕之后,可以从其他线程的队列中窃取任务执行,实现任务的尽快完成。

 

ForkJoinPool源码解析:

一些重要变量说明:

ctl: volatile long ctl; // main pool control

控制线程池内部状态的变量,这是long类型的变量,每16位都表示不同的含义:

ForkJoinPool初略分析_第2张图片

最高16位:表示现在正在运行的线程总数,初始值是并行值的相反数,总线程数没有达到阈值时它是一个负数。(而下面的区别是:一些线程如果被挂起,那么它不算在这里)

次高16位:表示线程总量,初始值也是并行值的相反数,记录了我们一共开启了多少线程。

再次16位:WorkQueue状态,第一位表示active还是inactive,其余15位表示版本号(ABA问题)。

最后16位:保存了一个WorkQueue在WorkQueue[]的下标,和其他worker通过字段stackPred组成的一个TreiberStack(这个栈的pull和pop使用了CAS,所以支持并发下的无锁操作)。

低32位可以直接使用sp=(int)ctl获取,为负表示存在空闲worker。

 

runState: 表示当前线程池的运行状态,同时也作为锁保护WorkQueues[]数组的更新。两者只有在一种情况下会互相影响:当添加一个新的工作线程时(tryAddWorker),只有在获取锁的情况下才能对ctl进行CAS操作。

 

commonPool: 一个静态的ForkJoinPool。ForkJoinPool静态初始化之后,commonPool会一直存在,并且在应用中共享,它可以减少资源的占用。比如parallelStream底层用的实际上就是commonPool。

 

WorkerQueue:

ForkJoinPool初略分析_第3张图片

ForkJoinPool初略分析_第4张图片

scanState:用于判断工作线程是INACTIVE(可能处在阻塞等待唤醒状态), 大于0表示当前的工作线程是激活的。任务是否为SCANNING(两者都不是时,它就是正在运行的任务)。scanState比较有意思,如果<0表示的是该WorkQueue没有属于自己的owner(ps:下标为偶数的都没有)。如果是个整数,表示有自己的owner,该值的初始值表示其在WorkQueue[]数组中的下标,肯定是个奇数。如果是个偶数,表示该队列所属的Thread正在执行Task。

top和base标识了队列的两端,新的任务会放入top位,窃取的时候从base位窃取,后面分析执行任务的时候会讲到这些变量具体是怎么用的。

伪共享状态解释:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

 

构造函数:

ForkJoinPool初略分析_第5张图片     asyncMode默认是false,即每个线程按照LIFO(后进先出)的顺序执行队列中的任务。为true的时候,表示是异步模式,子任务执行遵循FIFO顺序。

    多数场景下,可以直接使用ForkJoinPool中的commonPool,它是ForkJoinPool根据系统参数初始化的一个线程池。因为它是static的,所以可以在多个应用之间共享,当然这些应用要在同一个进程中。

 

ForkJoinPool框架单个任务运行流程:

ForkJoinPool初略分析_第6张图片

从队列中获取任务图解:

ForkJoinPool初略分析_第7张图片

    根据上面的流程图,一步步来分析ForkJoinPool是如何工作的。

向线程池提交任务:

    有三种方法可以调用:

    submit:提交一个异步任务,后续可以使用task.get()阻塞获取结果

    execute:提交一个异步任务,和上面的区别是它无返回结果

    invoke:内部会调用task.join()方法阻塞直到返回计算结果,相当于同步。

和普通的线程池是一样的,一般外部提交任务用的都是submit接口,任务会被封装成ForkJoinTask类型。这三个方法内部调用的都是externalPush(ForkJoinTask task)方法,先来分析下这个方法是怎么运行的。

externalPush(ForkJoinTask task):

添加外部任务到submission队列(WorkQueue[]数组的偶数位置)

ForkJoinPool初略分析_第8张图片

它的大致流程是,首先在WorkQueue[]中随机找一个偶数槽位的WorkQueue,然后将task放在WorkQueue.array的top位置,并更新top位。这个过程中需要对WorkQueue进行加锁。如果队列剩余的任务数少于1,会创建或者激活一个工作线程来运行任务。

(ps:为什么n<1需要调用signalWork()方法,看注释说这个方法的作用是create or activate a worker if too few are active,暂时还不是很理解这一步为啥要这么搞)

程序第一次运行的时候,调用的是externalSubmit(task)方法,可以将这个方法理解为是完整版的externalPush,主要是用于第一次提交任务时初始化WorkQueue[]数组,对应的WorkQueue队列以及其他一些相关属性,并将任务提交到WorkQueue的内部队列中,来看看这个方法。

externalSubmit(ForkJoinTask task):

ForkJoinPool初略分析_第9张图片

ForkJoinPool初略分析_第10张图片

ForkJoinPool初略分析_第11张图片

它会做三件事:(注意代码是个for(;;)自旋)

    1.创建WorkerQueue[n]数组(n最大为64,且容量为2的幂次方) ,并将线程池的状态修改为STARTED。操作过程中需要对runState加锁,操作完毕之后释放锁。然后继续自旋

    2.然后初始化一个WorkerQueue。WorkerQueue内部有一个ForkJoinTask[] array用来存放用户提交的任务,初始大小为1<<13(8192),最大为1<<26,扩容的时候会扩大为原来的一倍。然后继续自旋

    3. 将WorkerQueue放入到WorkerQueue[]数组中随机一个偶数槽位ws[k = r & m & SQMASK],并将任务放入到WorkerQueue.array的top位置。最后调用signalWorker()方法创建或者激活线程执行任务,方法结束。

注意:如果线程池状态runState<0(终止),那么就会调用tryTerminate()方法停止线程池,并抛出RejectedExecutionException。

    上面“3”中的ws[k = r & m & SQMASK],SQMASK是固定值0x007e(111 1110),末尾时0,所以&操作之后必定是偶数(偶数&其他数必定为偶数,所以才说submission Task一定是在ws[]的偶数位置);m是wq.length-1也是固定的;r是ThreadLocalRandom.getProbe(),是一个线程私有的伪随机数生成器,不同的线程probe都是不同的,但是同一个线程是相同的;也就是说同一个线程提交的Submission task是放在同一个WorkerQueue中的

 

signalWorker():

激活或者创建工作线程来运行任务

ForkJoinPool初略分析_第12张图片

方法的含义是需要看下线程池有没有满负荷(活跃线程数是不是已经达到了并发数)。

如果有空闲线程,那么唤醒(去ctl低32位判断线程空闲情况,使用unpark方法唤醒)。

如果没有空闲线程,但是总线程数不足,那么addWorker(c & ADD_WORKER判断第二个16位是正数还是负数,负数说明总线程数不足,前面分析ctl参数时说过)。这里和普通的线程池的逻辑一样,尽快让线程池达到满负荷的线程数

 

tryAddWorker():

ForkJoinPool初略分析_第13张图片

    尝试创建一个ForkJoinWorkThread线程,方法内部是调用createWorker()来实现的。后面会看到,createWorker()内部还调用了其他方法,在WorkQueue[]数组的奇数下标位置创建WorkQueue,然后执行用户提交任务。

    因为i=((s<<1) | 1) & m,因为s<<1肯定是偶数,所以|1尾部肯定是个奇数,m=ws.length-1,所以m肯定也是个奇数,奇数&奇数就是个奇数。由此肯定可以得到奇数的下标

 

createWorker():

ForkJoinPool初略分析_第14张图片

    该方法会通过线程工程创建一个线程,然后启动它。如果过程中发生异常的话,会调用deregisterWorker()方法处理这个失败的逻辑。

创建的ForkJoinWorkerThread需要注册到线程池中,这一步是在newThread()中做的,注册这一步其实就是将一个工作线程和一个workQueue绑定在一起,也说明一个工作线程对应一个workQueue,反之不成立

ForkJoinPool初略分析_第15张图片

registerWorker()逻辑也简单,它首先会去创建一个执行worker task的WorkQueue,初始化这个WorkQueue的一些属性并将这个WorkQueue的owner设置为自己。

    然后去WorkerQueue[]数组的奇数位置去寻找为null的下标,如果有的话就将自己设置进去。如果没有的话每次增加一个step重新寻找下一个位置,整个数组都没找到的话会将WorkerQueue[]数组扩容一倍,再寻找合适的奇数下标将自己放入。

    从代码中也可以看出ForkJoinWorkerThread和放置它要执行的Worker task的WorkQueue是一一对应的!

 

至此,外部任务提交流程以及WorkQueue相关资源的初始化就结束了,总结一下就是:

  1. 外部调用接口提交任务,ForkJoinPool会将任务放入到随机的一个偶数下标的WorkQueue中。如果对应的WorkQueue为空的话就创建一个新的
  2. 如果工作线程还未达到并发上限的话,优先创建一个工作线程,否则就唤醒一个工作线程。
  3. 创建工作线程的话,还会创建一个对应的WorkQueue放在WorkQueue[]数组的奇数位置,它用于放置这个线程所要执行的Worker Task

 

执行任务:

ForkJoinWorkerThread里面的run()会调用pool.runWorker(workQueue)来获取属于这个线程的WorkQueue中的任务执行。但是一开始属于这个工作线程的WorkQueue中肯定是没有任务的,因为外部提交的任务都提交到了下标为偶数的WorkQueue中,所以需要从偶数队列中拿任务。

如果执行期间出现异常,那么就会通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息。

ForkJoinPool初略分析_第16张图片

常信息。

 

看下runWorker()中是如何执行Submission Task的:

ForkJoinPool初略分析_第17张图片

注意下,runWorker()方法中传入的WorkQueue是属于这个工作线程的WorkerQueue。它的执行流程如下:

  1. scan尝试去获取一个任务
  2. runTask执行获取到的任务,执行完毕之后再随机获取一个任务
  3. awaitWork 没有任务就进行等待,如果一直等不到任务会返回false,最后会跳出runWorker循环,执行run中的finally中的deregisterWorker注销工作线程

该方法会用scan不停的去偷取任务,偷到一个任务之后会回到自己的队列,不停的pop()任务出来执行直到队列为空(这个任务fork()出来的子任务是会放到自己的队列中,如果有任务被steal了,那么这个被steal的任务fork()出来的子任务就到了窃取线程的队列中去了),执行完成之后再偷取下一个来执行。

 

先看scan(w,r)是如何获取任务的:

说实话,一看这么长的代码就有一种想吐的感觉!而且注释也不多!代码很长就不截图了,大概说下执行流程:

  1. 随机获取一个WorkQueue[]数组中的一个WorkQueue
  2. 获取这个WorkQueue base位的ForkJoinTask,更新base位置并返回任务。如果q.base-q.top < -1,表明WorkQueue中有超过一个任务,那么还要尝试唤醒或者增加工作线程(也就说至少会有两个工作线程咯!)
  3. 如果当前工作线程处于INACTIVE状态,那么调用tryRelease尝试唤醒工作线程
  4. 如果base位置为空或发生偏移,则对索引位进行随机移位,然后重新扫描。
  5. 如果扫描完整个WorkQueue[]之后都没有获取到任务,说明当前工作线程是多于的,那么将当前工作线程置为INACTIVE(即休眠),重置checkSum。
  6. 再扫描一圈还是没有任务则跳出循环返回null。

 

再看下runTask()方法:

ForkJoinPool初略分析_第18张图片

将WorkQueue的scanState从SCANNING转为RUNNING;

记录当前任务是steal来的,保存在currentSteal,并执行doExec()

除了窃取到的任务,还会执行自己WorkQueue里的任务execLocalTasks(根据mode控制取任务是LIFO还是FIFO,调用doExec执行,直到WorkQueue为空);

累加steal数量,能执行的都执行了,scanState转回SCANNING。

 

Fork/Join方法是怎么执行的:

doExec()的核心其实是用户编写的Task程序中的fork()、join()方法。doExec()最终会调用到你写的程序中override的compute()方法。执行完毕之后会执行execLocalTask(),队列中的其他任务。这样子,源码和使用的方法关联起来了。execLocalTasks对线程所属的WorkQueue内的任务进行LIFO执行

之前已经说过fork()比较简单,任务fork()出来的子任务是会放到自己的队列中,如果有任务被steal了,那么这个被steal的任务fork()出来的子任务就到了窃取线程的队列中去了

 

    join()就比较复杂了,核心是内部调用的doJoin():

  1. 如果任务已经完成,直接返回结果
  2. 如果当前线程不是工作线程,那么调用externalAwaitDone()阻塞自己,等到任务完成的时候会被唤醒。(不是工作线程就不要瞎参合事情…)
  3. 如果需要join的任务刚刚好是当前线程所拥有的队列的top位置,这意味着当前工作线程下一个就将执行到它,则执行它。
  4. 需要join的任务不在top位置,那么就调用awaitJoin()方法,方法的作用是Helps and/or blocks until the given task is done or timeout,但是尽量不让当前线程阻塞…

4.1如果在队列中间,将任务取出来执行

4.2 如果队列为空,说明任务被偷了,那么调用helpStealer帮助窃取着执行一个任务(互帮互助…)

4.3 如果窃取的任务执行完成之后,join的任务还没完成,那么就等待到deadLine

        4.4 deadLine到了之后还没等到的话,当前工作线程Block,唤醒或者创建一个新的Worker补偿自己在Block期间缺失的算力

 

一直没有获取到任务执行awaitWork():

注意还是内部有个无限循环,有两种结局:一种是线程终止,第二种是回到runWorker继续执行scan、runTask、awaitWork,分别对应代码中的return false 和 break。

   只有在一段等待时间之后,被外部唤醒或者时间达到唤醒时发现自己有任务,会继续执行;其余的情况都是会终止当前线程,例如线程池关闭了,时间达到唤醒之后发现自己还是过剩的。

 

任务异常处理:

    ForkJoinTask在执行的时候可能会抛出异常,但是和使用一般线程池一样没办法在主线程中捕获。

    所以ForkJoinTask提供了isCompletedAbnormally来判断任务是否已经抛出异常或者被取消了。可以通过getException来获取异常,它会返回Throwable对象,如果任务没有完成或者没有抛出异常则返回null。

 

一些注意点:

ForkJoinPool在执行过程中,会创建大量的子任务,导致GC进行垃圾回收,这些是需要注意的。

    个人觉得ForkJoinPool适用于需要将一个大任务拆分成多个子任务的情况,就是JAVA8的对集合进行parallelStream那样。

    parallelStream.forEach是不保证顺序的,如果要保证顺序正确,应该使用 forEachOrdered

 

总结下ForkJoinPool的核心思想:

ForkJoinPool的设计思想是每个线程维护自己的任务队列,以减少资源竞争,大家可以独立完成自己的任务;再通过偷的方式将热点地区的计算任务散布到其它线程,实现负载均衡。最终这个算法实现了:忙的时候大家各干各的,互不干扰(因为每一轮每个worker要把自己的任务队列执行完,才能进入下一轮偷别人一个),并且总是把已经开始执行的外部任务执行完成后再去接新的外部任务来做(外部任务没有配线程,因此忙的时候所有的CPU都在执行被fork()出来的子任务);闲的时候一有活儿大家都扑上去抢着干,新提交的外部任务能够能迅速占据所有CPU。

 

 

参考:

《Java并发编程的艺术》

https://www.jianshu.com/p/32a15ef2f1bf(ForkJoinPool理论分析)

https://www.jianshu.com/p/6a14d0b54b8d(ForkJoinPool执行流程)

https://blog.csdn.net/u010841296/article/details/83963637(parallelStream顺序性)

 http://gee.cs.oswego.edu/dl/papers/fj.pdf(ForkJoinPool论文)

//fork&join方法执行流程

https://www.jianshu.com/p/f777abb7b251

https://www.jianshu.com/p/6a14d0b54b8d

https://www.jianshu.com/p/44b09f52a225

//并发专栏

https://segmentfault.com/blog/ressmix_multithread?page=2

你可能感兴趣的:(ForkJoinPool初略分析)