fork / join框架比线程池更好吗?


134

使用新的fork / join框架有什么好处,而不只是在开始时将大任务简单地拆分为N个子任务,然后将它们发送到缓存的线程池(来自Executors),然后等待每个任务完成?我看不到使用fork / join抽象如何简化问题或使解决方案比我们多年以来的效率更高。

例如,本教程示例中的并行化模糊算法可以这样实现:

public class Blur implements Runnable {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    private int mBlurWidth = 15; // Processing window size, should be odd.

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    public void run() {
        computeDirectly();
    }

    protected void computeDirectly() {
        // As in the example, omitted for brevity
    }
}

首先拆分,然后将任务发送到线程池:

// source image pixels are in src
// destination image pixels are in dst
// threadPool is a (cached) thread pool

int maxSize = 100000; // analogous to F-J's "sThreshold"
List<Future> futures = new ArrayList<Future>();

// Send stuff to thread pool:
for (int i = 0; i < src.length; i+= maxSize) {
    int size = Math.min(maxSize, src.length - i);
    ForkBlur task = new ForkBlur(src, i, size, dst);
    Future f = threadPool.submit(task);
    futures.add(f);
}

// Wait for all sent tasks to complete:
for (Future future : futures) {
    future.get();
}

// Done!

任务进入线程池的队列,当工作线程可用时,将从队列中执行任务。只要拆分足够精细(以避免特别地等待最后一个任务)并且线程池具有足够的线程(至少N个处理器),所有处理器都将全速工作,直到完成整个计算为止。

我想念什么吗?使用fork / join框架的附加价值是什么?

Answers:


136

我认为基本的误解是,Fork / Join示例并未显示出窃取工作,而只是显示了某种标准的分而治之。

偷工作可能是这样的:工人B已经完成工作。他是一个善良的人,所以他环顾四周,发现工人A仍在努力工作。他走过去问:“伙计,我可以帮你。” 一个答复。“很酷,我要完成1000个单位的任务。到目前为止,我已经完成了345个工作,剩下655个工作。您能将673转换为1000,我将346转换为672。” B说:“好的,让我们开始吧,我们可以早些去酒吧。”

您会看到-工人即使在开始实际工作时也必须彼此沟通。这是示例中缺少的部分。

另一方面,这些示例仅显示类似“使用分包商”的内容:

工人A:“党,我有1000个工作单元。对我来说太多了。我自己做500个工作,然后将500个工作分包给别人。” 直到大任务分解成每个10个单位的小包为止。这些将由可用的工人执行。但是,如果一个小包是一种毒药,并且比其他小包花费的时间长得多-倒霉,分裂阶段就结束了。

Fork / Join与预先拆分任务之间唯一的区别是:当预先拆分时,您从一开始就拥有完整的工作队列。示例:1000个单位,阈值为10,因此队列中有100个条目。这些数据包分配给线程池成员。

Fork / Join比较复杂,它试图使队列中的数据包数量减少:

  • 步骤1:将一个包含(1 ... 1000)的数据包放入队列
  • 步骤2:一名工作人员弹出数据包(1 ... 1000),并用两个数据包替换:(1 ... 500)和(501 ... 1000)。
  • 步骤3:一名工作人员弹出数据包(500 ... 1000)并推送(500 ... 750)和(751 ... 1000)。
  • 步骤n:堆栈包含以下数据包:(1..500),(500 ... 750),(750 ... 875)...(991..1000)
  • 步骤n + 1:弹出并执行数据包(991..1000)
  • 步骤n + 2:弹出并执行数据包(981..990)
  • 步骤n + 3:弹出数据包(961..980),并将其拆分为(961 ... 970)和(971..980)。....

您会看到:在Fork / Join中,队列较小(示例中为6),并且“ split”和“ work”阶段是交错的。

当多个工作人员同时弹出并推动时,交互作用当然不是很清楚。


我认为这确实是答案。我想知道在任何地方是否有实际的Fork / Join示例可以显示其窃取功能?通过基本示例,可以从单元的大小(例如阵列长度)完全预测工作量,因此很容易进行前期拆分。如果无法从设备的大小很好地预测每个设备的工作量,那么盗窃肯定会在问题上有所作为。
乔纳斯·普拉卡

AH如果您的答案是正确的,则不会解释如何做。Oracle提供的示例不会导致工作被窃取。如您在此处描述的示例中,fork和join如何工作?您能否显示一些Java代码,这些代码将以fork和join的方式按您描述的方式进行窃取?谢谢
Marc

@Marc:对不起,但我没有可用的示例。
AH

6
Oracle的示例IMO的问题不在于它没有演示窃取工作(如AH所描述的那样),而是为简单的ThreadPool编写算法也很容易,该算法也能做到(如Joonas所做的那样)。当无法将工作预先分割为足够多的独立任务,但可以将其递归拆分为彼此独立的任务时,FJ最为有用。参见我的示例示例
ashirley 2012年

2
关于窃取工作可能会派上用场的一些示例:h-online.com/developer/features/…–
volley

27

如果您有n个繁忙线程全部独立地以100%的速度工作,那将比Fork-Join(FJ)池中的n个线程更好。但是,这种方法永远无法解决。

可能无法将问题精确地分为n个相等的部分。即使您这样做,线程调度在某种程度上也是不公平的。您最终将等待最慢的线程。如果您有多个任务,那么它们每个都可以以小于n路的并行度运行(通常效率更高),而在其他任务完成时可以升至n路。

那么,为什么不将问题分解为FJ大小的碎片,并在其中进行线程池处理呢?典型的FJ使用将问题分解成小块。以随机顺序执行这些操作需要在硬件级别进行大量协调。这些开销将是致命的。在FJ中,将任务放入一个队列中,该线程以先进先出顺序(LIFO /堆栈)读取线程,并且工作窃取(通常在核心工作中)是先进先出(FIFO /“队列”)。结果是,即使将长数组处理分成很小的块,也可以在很大程度上按顺序进行。(在这种情况下,一口气将问题分解成均匀的小块可能也不是一件容易的事。要说是在不平衡的情况下处理某种形式的层次结构。)

结论:FJ允许在不稳定的情况下更有效地使用硬件线程,如果您有多个线程,则总是这样。


但是,为什么FJ也不会最终也等待最慢的线程呢?有一定数量的子任务,当然,其中一些总是最后一个要完成的子任务。maxSize在我的示例中,调整参数将产生与FJ示例中的“二进制拆分”几乎类似的子任务划分(在compute()方法内完成,该方法要么计算某些内容,要么将子任务发送到invokeAll())。
乔纳斯·普拉卡

因为它们小得多-我将添加到答案中。
Tom Hawtin-大头钉2011年

好的,如果子任务的数量比可以并行实际处理的数量大几个数量级(这很有意义,以避免不得不等待最后一个任务),那么我可以看到协调问题。如果该划分被认为是精细的,则FJ示例可能会产生误导:它使用的阈值为100000,对于1000x1000的图像,该阈值将产生16个实际的子任务,每个子任务处理62500个元素。对于10000x10000的图像,将有1024个子任务,这已经有些事情了。
乔纳斯·普拉卡

19

线程池和Fork / Join的最终目标是相同的:两者都想尽可能地利用可用的CPU能力以实现最大吞吐量。最大吞吐量意味着应在尽可能长的时间内完成尽可能多的任务。需要做些什么?(对于以下情况,我们将假定不乏计算任务:对于100%的CPU使用率,总有足够的事情要做。此外,在超线程的情况下,我对内核或虚拟内核等效地使用“ CPU”)。

  1. 至少需要运行的线程数与可用的CPU数量一样,因为运行更少的线程将使内核不使用。
  2. 最多运行的线程数必须与可用的CPU数量一样多,因为运行更多的线程会为Scheduler产生额外的负载,Scheduler将CPU分配给不同的线程,这导致一些CPU时间流向了Scheduler而不是我们的计算任务。

因此,我们发现要获得最大吞吐量,我们需要拥有与CPU完全相同的线程数。在Oracle的模糊示例中,您既可以采用固定大小的线程池,而线程数量等于可用CPU的数量,也可以使用线程池。没关系,你是对的!

那么什么时候会遇到线程池问题?那是一个线程阻塞,因为您的线程正在等待另一个任务完成。假设以下示例:

class AbcAlgorithm implements Runnable {
    public void run() {
        Future<StepAResult> aFuture = threadPool.submit(new ATask());
        StepBResult bResult = stepB();
        StepAResult aResult = aFuture.get();
        stepC(aResult, bResult);
    }
}

我们在这里看到的是一个由三个步骤A,B和C组成的算法。A和B可以彼此独立执行,但是步骤C需要步骤A和B的结果。该算法将任务A提交给线程池并直接执行任务b。之后,线程将等待任务A也完成,然后继续执行步骤C。如果A和B同时完成,则一切正常。但是,如果A比B花费更长的时间怎么办?这可能是因为任务A的性质决定了它,但也可能是因为任务A开头没有可用线程,因此任务A需要等待。(如果只有一个CPU可用,因此您的线程池只有一个线程,这甚至会导致死锁,但是现在这还不重要)。关键是刚刚执行任务B的线程阻塞整个线程。由于我们拥有与CPU相同的线程数,并且一个线程被阻塞,这意味着一个CPU处于空闲状态

Fork / Join解决了这个问题:在fork / join框架中,您将编写相同的算法,如下所示:

class AbcAlgorithm implements Runnable {
    public void run() {
        ATask aTask = new ATask());
        aTask.fork();
        StepBResult bResult = stepB();
        StepAResult aResult = aTask.join();
        stepC(aResult, bResult);
    }
}

看起来一样,不是吗?但是,提示是aTask.join 不会阻塞。相反,这里是窃取工作的地方:线程将环顾过去已分叉的其他任务,并将继续执行这些任务。首先,它检查自己分叉的任务是否已经开始处理。因此,如果A尚未由另一个线程启动,它将继续执行A,否则它将检查其他线程的队列并窃取它们的工作。一旦另一个线程的另一个任务完成,它将检查A是否现在完成。如果是以上算法则可以调用stepC。否则,它将寻找另一个要偷的任务。因此,即使面对阻塞操作,fork / join池也可以实现100%的CPU使用率

但是有一个陷阱:只能在join调用ForkJoinTasks时进行工作窃取。对于外部阻塞操作,例如等待另一个线程或等待I / O操作,无法完成此操作。那么,等待I / O完成是常见的任务吗?在这种情况下,如果我们可以向Fork / Join池中添加一个额外的线程,那么在阻塞操作完成后立即将其再次停止将是第二好的选择。而ForkJoinPool实际上可以做到这一点,如果我们使用的是ManagedBlocker秒。

斐波那契

JavaDoc for RecursiveTask中,有一个使用Fork / Join计算斐波那契数的示例。有关经典的递归解决方案,请参见:

public static int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

正如在JavaDocs中所解释的那样,这是一种计算斐波纳契数的不错的转储方法,因为该算法具有O(2 ^ n)复杂度,而更简单的方法也是可能的。但是,此算法非常简单且易于理解,因此我们坚持使用它。假设我们想通过Fork / Join加快速度。天真的实现看起来像这样:

class Fibonacci extends RecursiveTask<Long> {
    private final long n;

    Fibonacci(long n) {
        this.n = n;
    }

    public Long compute() {
        if (n <= 1) {
            return n;
        }
        Fibonacci f1 = new Fibonacci(n - 1);
        f1.fork();
        Fibonacci f2 = new Fibonacci(n - 2);
        return f2.compute() + f1.join();
   }
}

该任务划分的步骤太短了,因此会可怕地执行,但是您可以看到该框架通常运行得很好:两个求和项可以独立计算,但是我们需要两个都来构建最终结果。因此一半是在另一个线程中完成的。在没有死锁的情况下,对线程池执行相同的操作很有趣(可能,但并非如此简单)。

仅出于完整性考虑:如果您实际上想使用这种递归方法来计算斐波那契数,那么这里是一个优化的版本:

class FibonacciBigSubtasks extends RecursiveTask<Long> {
    private final long n;

    FibonacciBigSubtasks(long n) {
        this.n = n;
    }

    public Long compute() {
        return fib(n);
    }

    private long fib(long n) {
        if (n <= 1) {
            return 1;
        }
        if (n > 10 && getSurplusQueuedTaskCount() < 2) {
            final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
            final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
            f1.fork();
            return f2.compute() + f1.join();
        } else {
            return fib(n - 1) + fib(n - 2);
        }
    }
}

这使子任务小得多,因为只有在n > 10 && getSurplusQueuedTaskCount() < 2为true 时才将其拆分,这意味着要执行do(n > 10)的方法调用明显超过100个,并且没有非常多的人工任务在等待(getSurplusQueuedTaskCount() < 2)。

在我的计算机上(4核(计数超线程时为8核,Intel(R)Core i7-2720QM CPU @ 2.20GHz)),fib(50)采用经典方法需要64秒,而使用Fork / Join方法只需18秒。尽管在理论上不尽人意,但它是一个相当明显的收益。

摘要

  • 是的,在您的示例中,Fork / Join与经典线程池相比没有任何优势。
  • 涉及阻塞时,Fork / Join可以大大提高性能
  • Fork / Join规避了一些死锁问题

17

Fork / join与线程池不同,因为它实现了工作窃取。从叉子/加入

与任何ExecutorService一样,fork / join框架将任务分配给线程池中的工作线程。fork / join框架与众不同,因为它使用工作窃取算法。工作用尽的工作线程可以从其他仍很忙的线程中窃取任务。

假设您有两个线程,以及4个任务a,b,c,d,分别需要1、1、5和6秒。最初,将a和b分配给线程1,将c和d分配给线程2。在线程池中,这将花费11秒。使用fork / join,线程1完成并可以从线程2窃取工作,因此任务d将最终由线程1执行。线程1执行a,b和d,线程2执行c。总时间:8秒,而不是11。

编辑:正如乔纳斯指出的,任务不一定要预先分配给线程。fork / join的想法是线程可以选择将任务拆分为多个子部分。因此,请重申上述内容:

我们有两个任务(ab)和(cd),分别耗时2和11秒。线程1开始执行ab并将其分为两个子任务a和b。与线程2类似,它分为两个子任务c和d。线程1完成a&b之后,它可以从线程2中窃取d。


5
线程池通常是ThreadPoolExecutor实例。在这种情况下,任务进入队列(实际上是BlockingQueue),工作线程在完成上一个任务后立即从中接收任务。据我了解,任务并未预先分配给特定线程。每个线程一次最多具有1个任务。
乔纳斯·普拉卡

4
AFAIK 对于一个 ThreadPoolExecutor 有一个队列,该队列又控制多个线程。这意味着将任务或可运行对象(不是线程!)分配给执行者时,任务也不会预先分配给特定的线程。FJ也确实如此。到目前为止,使用FJ没有任何好处。
AH

1
@AH是的,但是fork / join允许您拆分当前任务。执行任务的线程可以将其分为两个不同的任务。因此,使用ThreadPoolExecutor,您可以获得固定的任务列表。使用fork / join,正在执行的任务可以将其自己的任务分为两部分,然后在其他线程完成工作后可以将其拾取。如果您先完成,也可以。
马修·法威尔

1
@Matthew Farwell:在FJ示例中,在每个任务中,compute()要么计算任务,要么将其拆分为两个子任务。它选择哪个选项取决于任务的大小(if (mLength < sThreshold)...),因此这只是创建固定数量的任务的一种理想方法。对于1000x1000的图像,将有16个实际计算出内容的子任务。另外,将有15个(= 16-1)“中间”任务仅生成和调用子任务,而自己不计算任何内容。
乔纳斯·普拉卡

2
@Matthew Farwell:我可能不太了解FJ,但是如果子任务决定执行其computeDirectly()方法,就无法再窃取任何东西。至少在示例中,整个拆分是先验完成
乔纳斯·普拉卡

14

上面的每个人都是正确的,因为偷窃工作可以带来好处,但是请继续解释为什么这样做。

主要好处是工作线程之间的有效协调。工作必须分解并重新组装,这需要协调。如您在AH上面的答案中所见,每个线程都有自己的工作清单。该列表的一个重要属性是对它进行了排序(大任务在顶部,小任务在底部)。每个线程执行其列表底部的任务,并从其他线程列表的顶部窃取任务。

结果是:

  • 任务列表的头部和尾部可以独立同步,从而减少了列表上的争用。
  • 工作的重要子树由同一线程分解和重新组装,因此这些子树不需要线程间协调。
  • 当线程窃取工作时,会占用大量内存,然后细分为自己的列表
  • 加工钢丝意味着螺纹几乎被充分利用,直到过程结束。

使用线程池的大多数其他分而治之方案都需要更多的线程间通信和协调。


13

在此示例中,Fork / Join没有添加任何值,因为不需要分叉并且工作负载均匀地分布在工作线程中。叉/联接仅增加开销。

这是一篇有关该主题的好文章。引用:

总的来说,可以说ThreadPoolExecutor是首选,因为工作负载在工作线程之间平均分配。为了保证这一点,您确实需要准确知道输入数据的外观。相比之下,无论输入数据如何,ForkJoinPool都可提供良好的性能,因此是一个明显更强大的解决方案。


8

另一个重要的区别似乎是,使用FJ,您可以执行多个复杂的“加入”阶段。考虑来自http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html的合并排序,可能需要过多的业务流程来预先拆分此工作。例如,您需要执行以下操作:

  • 排序第一季度
  • 排序第二季度
  • 合并前两个季度
  • 第三季度排序
  • 排序第四季度
  • 合并最后两个季度
  • 合并两半

您如何指定必须在涉及它们的合并之前进行排序等。

我一直在寻找如何最好地为每个项目列表做一件事情。我想我将预先拆分列表并使用标准的ThreadPool。当无法将工作预先拆分为足够的独立任务,但可以将其递归拆分为彼此独立的任务时,FJ似乎最有用。


6

当您进行昂贵的合并操作时,F / J也具有明显的优势。因为它拆分为树结构,所以您只进行log2(n)合并,而不是使用线性线程拆分进行n合并。(这确实假设您拥有与线程一样多的处理器,但这仍然是一个优势)。对于一项家庭作业,我们必须通过对每个索引处的值求和来合并数千个2D数组(所有维度均相同)。对于派生联接和P处理器,随着P接近无穷大,时间接近log2(n)。

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 => 6 9 9
7 8 9 ..1 1 0 .... 8 9 9


3

您会对爬网程序之类的ForkJoin性能感到惊讶。这是您将学习的最佳教程

Fork / Join的逻辑非常简单:(1)将每个大任务分离(fork)为较小的任务;(2)在单独的线程中处理每个任务(必要时将它们分成更小的任务);(3)加入结果。


3

如果问题是我们不得不等待其他线程完成(例如,对数组排序或对数组求和),则应使用fork联接,因为Executor(Executors.newFixedThreadPool(2))由于受限而会阻塞线程数。在这种情况下,forkjoin池将创建更多线程,以覆盖被阻塞的线程以保持相同的并行度

来源: http : //www.oracle.com/technetwork/articles/java/fork-join-422606.html

执行程序实现分而治之算法的问题与创建子任务无关,因为Callable可以自由地将新的子任务提交给其执行程序,并以同步或异步的方式等待其结果。问题是并行性:当一个Callable等待另一个Callable的结果时,它处于等待状态,从而浪费了处理另一个排队等待执行的Callable的机会。

通过Doug Lea的努力,将fork / join框架添加到Java SE 7中的java.util.concurrent包中填补了这一空白

来源: https : //docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html

池尝试通过动态添加,暂停或恢复内部工作线程来维护足够的活动(或可用)线程,即使某些任务因等待加入其他任务而停滞不前。但是,面对阻塞的IO或其他不受管理的同步,无法保证此类调整

public int getPoolSize()返回已启动但尚未终止的工作线程数。当创建线程以协作地阻塞其他线程时保持并行性时,此方法返回的结果可能不同于getParallelism()。


2

对于那些没有太多时间阅读长答案的人,我想添加一个简短答案。比较来自《 Applied Akka Patterns》一书:

您决定使用fork-join-executor还是thread-pool-executor,很大程度上取决于该调度程序中的操作是否将被阻塞。fork-join-executor为您提供最大数量的活动线程,而thread-pool-executor为您提供固定数量的线程。如果线程被阻塞,则fork-join-executor将创建更多线程,而thread-pool-executor将不会创建更多线程。对于阻塞操作,通常最好使用线程池执行器,因为它可以防止线程数量激增。在fork-join-executor中,更多的“反应性”操作会更好。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.