阅读 336

聊聊 ForkJoinPool 及 ForkJoinTask (真正的 Fork/Join)

ForkJoinPool 与 ThreadPoolExecutor

在深入聊 ForkJoinPool 前,我们先聊聊 ForkJoinPool 与 ThreadPoolExecutor的区别。 我们为啥要用 ForkJoinPool ? 相比于我们更常用的 ThreadPoolExecutor ,ForkJoinPool 又能给我们带来什么呢? 带着这样的问题我们来好好聊聊。

异同

1.首先他们都继承自 AbstractExecutorService 但 ForkJoinPool 并不是为了替代 ThreadPoolExecutor 而产生的,相对来说 ForkJoinPool 是对线程池使用场景和功能上进行了一个补充

public class ForkJoinPool extends AbstractExecutorService
复制代码
public class ThreadPoolExecutor extends AbstractExecutorService
复制代码

2.构造函数不同

ThreadPoolExecutor 不是本篇重点,构造函数就不细讲了,相信大家也比较熟悉了。 我们重点说下 ForkJoinPool

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
复制代码
private ForkJoinPool(int parallelism,
                         ForkJoinWorkerThreadFactory factory,
                         UncaughtExceptionHandler handler,
                         int mode,
                         String workerNamePrefix)
复制代码
  1. parallelism:并行度(the parallelism level),默认情况下跟我们机器的 CPU 核心数保持一致,使用 Runtime.getRuntime().availableProcessors() 可以得到我们机器运行时可用的 CPU 核心数。
  2. factory:创建新线程的工厂( the factory for creating new threads)。默认情况下使用 ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory。
  3. handler:线程异常情况下的处理器(Thread.UncaughtExceptionHandler handler),在线程执行任务时对由于某些无法预料的错误而导致任务线程中断时,该处理器会进行一些处理,默认情况为 null。
  4. asyncMode:在ForkJoinPool中,每一个工作线程都有一个独立的任务队列,asyncMode 表示工作线程内的任务队列是采用何种方式进行调度,可以是先进先出FIFO,也可以是后进先出 LIFO。如果为 true,则线程池中的工作线程则使用 先进先出方式 进行任务调度,默认情况下是false 也就是默认为 LIFO 后进先出
  5. workerNamePrefix:顾名思义,工作线程名称前缀 默认为 "ForkJoinPool-" + nextPoolId() + "-worker-"

3.工作模式不同 ForkJoinPool 采用了 一个线程对应专属的一个工作队列,而非 ThreadPoolExecutor 的多个线程对应一个工作队列。即 线程与工作队列关系多对一 变为 一对一

ThreadPoolExecutor 线程池模型如下 在这里插入图片描述 ForkJoinPool 的线程与工作队列对应模型 在这里插入图片描述 其实对于 ForkJoinPool 的整个工作流程,和 ThreadPoolExecutor 还是有很大的区别的,在这里我围绕 Fork/Join 仅阐明比较核心的几个概念:

工作窃取

对于 ForkJoinPool 来说,任务提交有两种: 一种是直接通过 ForkJoinPool 来提交的外部任务 external/submissions task 第二种是内部 fork 分割的子任务 Worker task

也就是下面这两种方法

forkJoinPool.submit(forkJoinTask);
forkJoinTask.fork();

 public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task) {
        if (task == null)
            throw new NullPointerException();
        externalPush(task);
        return task;
}
public final ForkJoinTask<V> fork() {
        Thread t;
        if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
            ((ForkJoinWorkerThread)t).workQueue.push(this);
        else
            ForkJoinPool.common.externalPush(this);
        return this;
}
复制代码

很清晰的给出了两种入队方式

// 内部直接入队,当前线程绑定的队列
((ForkJoinWorkerThread)t).workQueue.push(this);
// 外部入队
externalPush(task);
复制代码
// 实现
final void externalPush(ForkJoinTask<?> task) {
        WorkQueue[] ws; WorkQueue q; int m;
        int r = ThreadLocalRandom.getProbe();
        int rs = runState;
        if ((ws = workQueues) != null && (m = (ws.length - 1)) >= 0 &&
            (q = ws[m & r & SQMASK]) != null && r != 0 && rs > 0 &&
            U.compareAndSwapInt(q, QLOCK, 0, 1)) {
            ForkJoinTask<?>[] a; int am, n, s;
            if ((a = q.array) != null &&
                (am = a.length - 1) > (n = (s = q.top) - q.base)) {
                int j = ((am & s) << ASHIFT) + ABASE;
                U.putOrderedObject(a, j, task);
                U.putOrderedInt(q, QTOP, s + 1);
                U.putIntVolatile(q, QLOCK, 0);
                if (n <= 1)
                    signalWork(ws, q);
                return;
            }
            U.compareAndSwapInt(q, QLOCK, 1, 0);
        }
        externalSubmit(task);
}
复制代码

这里提一下 ForkJoinPool 会维护一个 workQueues 也就是所有的工作队列的数组,这个意图有些类似于 HashMap 的 底层数组。

WorkQueue q;
int r = ThreadLocalRandom.getProbe();
m = (ws.length - 1)
q = ws[m & r & SQMASK]
复制代码

所以外部入队是进入到 ws[m & r & SQMASK] WorkQueue 的 m & r & SQMASK 位置的工作队列中

对于 int r = ThreadLocalRandom.getProbe(); 做一个简单的解释这里不做深究

使用 ThreadLocalRandom.getProbe() 得到线程的探针哈希值。

在这里,这个探针哈希值的作用是哈希线程,将线程和数组中的不用元素对应起来,尽量避免线程争用同一数组元素。探针哈希值和 map 里使用的哈希值的区别是,当线程发生数组元素争用后,可以改变线程的探针哈希值,让线程去使用另一个数组元素,而 map 中 key 对象的哈希值,由于有定位 value 的需求,所以它是一定不能变的。

回到正题,我们来聊聊 工作窃取(work-stealing)

该算法是指某个线程从其他队列里窃取任务来执行。

ForkJoinPool 的每个工作线程都维护着一个工作队列(WorkQueue),这是一个双端队列(Deque),里面存放的对象是任务(ForkJoinTask)。

每个工作线程在运行中产生新的任务(通常是因为调用了 fork())时,会放入工作队列的队尾,每次从队尾取出任务来执行。

每个工作线程在处理自己的工作队列同时,会尝试窃取一个任务(或是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的工作队列),窃取的任务会从队首窃取,也就是说窃取任务和执行自己的队列任务的方式是分开的,这样可以尽可能的避免竞争。 在遇到 join() 时,如果需要 join 的任务尚未完成,则会先处理其他任务,并等待其完成。

在既没有自己的任务,也没有可以窃取的任务时,进入休眠。

ForkJoinTask fork / join 想要真正标准的使用 fork/join 框架,那么 ForkJoinTask 是必不可少的。

public abstract class ForkJoinTask<V> implements Future<V>, Serializable
复制代码

作为一个抽象类,我们需要对其进行实现,但是通常来说我们会继承其子类来进行实现。fork/join 框架为我们提供了三类实现:

  1. RecursiveAction:可用于没有返回结果的任务。
  2. RecursiveTask :用于有返回结果的任务。
  3. CountedCompleter: 在任务完成执行后,触发自定义的钩子函数。

我们可以根据自己的业务场景,选择合适的 Task 以及定义其实现。

fork & join

fork 做的事情是将当前的任务,推入当前工作线程的工作队列中

    public final ForkJoinTask<V> fork() {
        Thread t;
        if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
            ((ForkJoinWorkerThread)t).workQueue.push(this);
        else
            ForkJoinPool.common.externalPush(this);
        return this;
    }
复制代码
    public final V join() {
        int s;
        if ((s = doJoin() & DONE_MASK) != NORMAL)
            reportException(s);
        return getRawResult();
    }
复制代码

join 的整体流程如下

在这里插入图片描述

如何使用

其实聊到这里,ForkJoinPool 以及 ForkJoinTask 的核心内容基本都已经介绍差不多了,而在实际的使用中,我们的一般步骤为:

  1. 声明 ForkJoinPool 。
  2. 继承实现 ForkJoinTask 抽象类或其子类,在其定义的方法中实现你的业务逻辑。
  3. 子任务逻辑内部在合适的时机进行子任务的 fork 拆分。
  4. 子任务逻辑内部在合适的时机进行 join 汇总

总结

  • 整体上 ForkJoinPool 是对 ThreadPoolExecutor 的一种补充
  • ForkJoinPool 提供了其独特的线程工作队列绑定方式、工作分离以及窃取方式
  • ForkJoinPool + ForkJoinTask 配合实现了 Fork/Join 框架
  • 适用于任务可拆分为更小的子任务的场景(有点类似递归),适用于计算密集型任务,可以充分发挥 CPU 多核的能力
文章分类
后端
文章标签