Java中Fork/Join 正确理解

Fork-Join 模型

fork-join (以下简称 F-J) 首先是一种并行模型,以 fork (开启)分支的方式执行代码逻辑,待全部分支递归执行完毕,join (合并)所有并行分支,而后代码会继续串行执行。从算法角度上来说,特别适合基于分治法的计算。

传统线程池

传统的线程池的实现大致是这样的:

首先生成一个线程池需要指定核心线程数(coreSize)、最大线程数(maxSize)、work queue 阻塞队列初始化、线程创建工厂类(ThreadFactory)以及阻塞队列满员后的丢弃策略等等。

理论上实现了 Runnable 的类实例都能作为 task 被 submit 到线程池里,那么这些任务在接下来的“命运”是怎样的呢?

任务不会马上 offer 到 work queue,而是逐步创建好足够数量(coreSize)的线程作为核心线程(worker thread),只要 worker thread 的数量小于 coreSize ,就继续创建下去,因此最早这批 task 会先和 worker thread 绑定,绑定完成后 worker thread也随之启动(Thread.start())。参考代码:

 

java.util.concurrent.ThreadPoolExecutor#execute

if (workerCountOf(c) < corePoolSize) {
    if (addWorker(command, true))
        return;
    c = ctl.get();
}

假设 core size 是10,那么线程池 submit 11 个 task后,便只会有 10 个固定线程启动起来,当 worker thread 处理完 task 以后,也并不会马上销毁,而是继续从 work queue 中 poll task 数据。poll 行为本身也是线程安全的,而 queue 是阻塞队列,队列为空时,worker thread 如果此时 poll 则会被阻塞;但当队列中有数据的时候,worker thread 将被唤醒执行下一个 task。

我们看到,worker thread 和 task 是 1:N 的关系,task 并非是真正的线程实例,相对 1:1 而言,这种方式更加轻量;线程池相对于普通线程创建而言,线程数量是可控的;核心线程在执行过程中不会被回收,可以对核心线程资源充分利用,减少了线程上下文切换的成本。

F-J pool

F-J pool 基本特点:

  • worker thread 是给定的,由OS直接调度,这点和传统线程池一致。通常,worker thread 会和处理器核心数量保持一致;
  • F-J task 都是轻量级执行实例,worker thread 和 task 同样是 1:N 的关系 ;
  • 通过 work queue 管理任务,worker thread 执行任务。与传统线程池不同是,传统线程池只会维护一个 work queue;而 F-J pool 中的每个 worker thread 都独立维护一个 work queue。

看看下面的构造方法,一目了然:

/**
 * Creates a ForkJoinWorkerThread operating in the given pool.
 *
 * @param pool the pool this thread works in
 * @throws NullPointerException if pool is null
 */
protected ForkJoinWorkerThread(ForkJoinPool pool) {
    // Use a placeholder until a useful name can be set in registerWorker
    super("aForkJoinWorkerThread");
    this.pool = pool;
    this.workQueue = pool.registerWorker(this);
}

 

工作量窃取(work-stealing)

F-J 核心就在于轻量级的调度(相对于传统线程池而言),理解了 work-stealing 就理解了 F-J 的调度算法。

  • ForkJoinWorkerThread 就是 F-J pool 中的工作线程,与其对应的 work queue 是一个双端队列(deque),同时具备 FIFO(take/poll 操作) 和 LIFO(push 和 pop 操作) 的特点;
  • 对于已经给定的 worker thread A,任务 fork 出来的子任务是会被 push 到 A 的 deque 之中(这里的 fork 只是入栈,是非阻塞的)。由于 deque 被 A 独占,push操作是线程安全的,无需加锁;
  • worker thread A 基于 LIFO 规则对 deque pop 操作,从栈顶获取执行任务并执行。由于 deque 被 A独占, pop 操作也是线程安全,无需加锁;
  • 当 worker thread 本地 deque 没有任务可以执行的时候,会随机在其他worker thread 的 deque中,基于 FIFO 规则在deque 尾部 take/poll(窃取)一个任务执行,这里是需要加锁的;
  • worker thread A 执行到 fork 操作,push 到 deque,非阻塞;A 执行到 join 操作,原则上说 join 操作没有返回值以前,之后的逻辑都无法执行,但是为了提高线程利用率,这里的 A 也是非阻塞的,直到 A 被告知拿到了 join 的递归结果,join 后的操作才会继续执行;
  • 当 worker thread 无法从其他线程中获取任务,线程就会正式进入到阻塞状态。

根据 work-stealing 模型我们可以知道,对于常规调度(非窃取调度)来说,是不需要加锁的,相较于传统线程池轻量不少;另外,先进入 deque 的任务被"窃取"的优先级越高。仔细想想这种设计十分巧妙。个人理解,“大任务”优先被窃取执行,好处在于更快速地将细化后的“小任务”平摊在各个工作线程上,这样就能够在较短时间内使线程池达到满载,充分利用了并行计算资源;

另外,大任务的优先窃取也能保证 work deque 尽可能丰满,因为这样可以减少窃取这种相对而言比较重的操作频次;反之,如果优先窃取小任务,而小任务理论上很少几率会被分解,那么一个 worker thread 窃取其他 deque 的频次会高许多,调度成本会增加。

 

如何简单实现一个F-J求和?

递归分支算法该怎么用?Doug Lea 给出了一个伪代码:

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
   }
}

根据大神的指导思想,我写了一个简单的求和任务

public class Sum extends RecursiveTask<Integer> {

    private final int start;

    private final int end;

    Sum(int start, int end) {
        this.start = start;
        this.end = end;
    }


    @Override
    protected Integer compute() {

        if (end - start < 10) {
            // 当计算元素少于10的时候,那么这个任务足够小,不需要继续拆分
            int sum = 0;
            for (int i = start; i <= end; i++) {
                sum += i;
            }
            return sum;
        } else {
            int middle = (start + end) / 2;
            Sum left = new Sum(start, middle);
            Sum right = new Sum(middle + 1, end);
            left.fork();
            right.fork();
            return left.join() + right.join();
        }

    }
}

把任务丢到F-J pool执行

@Test
public void sumTest() throws ExecutionException, InterruptedException {
    ForkJoinPool pool = new ForkJoinPool();
    Future<Integer> value = pool.submit(new Sum(0, 1000));
    // 打印结果 500500
    System.out.println(value.get());
}

当计算量不够大的时候,这样拆分计算没有实际意义,计算速度并不一定会比单线程直接算要好,毕竟这里存在线程上下文切换成本。

 

parallelStream

除了递归分治计算场景,我在 parallelStream 中发现了 F-J pool 的影子。但是这里却没有用到递归 RecursiveTask。

参考

java.util.stream.ForEachOps.ForEachTask

// Similar to AbstractTask but doesn't need to track child tasks
public void compute() {
    Spliterator<S> rightSplit = spliterator, leftSplit;
    long sizeEstimate = rightSplit.estimateSize(), sizeThreshold;
    if ((sizeThreshold = targetSize) == 0L)
        targetSize = sizeThreshold = AbstractTask.suggestTargetSize(sizeEstimate);
    boolean isShortCircuit = StreamOpFlag.SHORT_CIRCUIT.isKnown(helper.getStreamAndOpFlags());
    boolean forkRight = false;
    Sink<S> taskSink = sink;
    ForEachTask<S, T> task = this;
    while (!isShortCircuit || !taskSink.cancellationRequested()) {
        if (sizeEstimate <= sizeThreshold ||
            (leftSplit = rightSplit.trySplit()) == null) {
            task.helper.copyInto(taskSink, rightSplit);
            break;
        }
        ForEachTask<S, T> leftTask = new ForEachTask<>(task, leftSplit);
        task.addToPendingCount(1);
        ForEachTask<S, T> taskToFork;
        if (forkRight) {
            forkRight = false;
            rightSplit = leftSplit;
            taskToFork = task;
            task = leftTask;
        }
        else {
            forkRight = true;
            taskToFork = leftTask;
        }
        taskToFork.fork();
        sizeEstimate = rightSplit.estimateSize();
    }
    task.spliterator = null;
    task.propagateCompletion();
}

从源码可以看出,compute 过程中只有fork 没有 join,fork 即是将任务入栈,这是因为这里不需要拿到合并计算的结果,只是需要将集合中每个元素分别独立且并行执行指定逻辑,在某些场景下,这种方式会比声明一个传统线程池直观清晰许多,也能节省不少代码。

另外,parallelStream 也不用像传统线程池那样需要 CountDownLatch 做线程协同,这是因为主线程(main)参与到了调度工作,而不会在 forEach 结束前去执行 forEach 之后的逻辑。

 

小结

F-J pool 比起传统线程池而言,调度更轻量了许多,毕竟相比于传统只有一个 work queue 而言,多个 deque 的出现减少了并发冲突,甚至在一般的调度中是线程安全且无需加锁的

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菠萝-琪琪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值