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

时间:2011-10-28 08:31:16

标签: java fork-join

使用新的fork/join framework而不仅仅是在开始时将大任务分成N个子任务,将它们发送到缓存的线程池(来自Executors)并等待每个任务,有什么好处?去完成?我没有看到使用fork / join抽象如何简化问题或使解决方案从我们多年来的工作中提高效率。

例如,tutorial example中的并行化模糊算法可以像这样实现:

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个处理器)线程,所有处理器都在全速运行,直到整个计算完成。 / p>

我错过了什么吗?使用fork / join框架的附加价值是什么?

11 个答案:

答案 0 :(得分:124)

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

工作窃取将是这样的:工人B已经完成了他的工作。他是一个善良的人,所以他环顾四周,看到工人A仍然非常努力。他漫步并问道:“嘿小伙子,我可以帮你一把。”回复。 “很酷,我有1000个单位的任务。到目前为止,我已经完成了345次离开655.你能不能在673到1000号工作,我会做346到672。” B说“好的,我们先来吧,我们可以早点去酒吧。”

你知道 - 即使他们开始真正的工作,工人也必须彼此沟通。这是示例中缺少的部分。

另一方面,示例仅显示“使用分包商”:

工人A:“Dang,我有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”阶段是交错的。

当多个工人同时弹出并推动时,互动当然不是那么清楚。

答案 1 :(得分:25)

如果你有n个繁忙线程都是100%独立工作,那么这将比Fork-Join(FJ)池中的n个线程更好。但它永远不会那么成功。

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

那么为什么我们不把这个问题简化为FJ大小的部分并且有一个线程池工作。典型的FJ使用将问题分解成小块。以随机顺序执行这些操作需要在硬件级别进行大量协调。开销将是一个杀手。在FJ中,任务被放入队列中,线程以后进先出顺序(LIFO /堆栈)读取,并且工作窃取(通常在核心工作中)先进先出(FIFO /“队列”)。结果是长阵列处理可以在很大程度上顺序完成,即使它被分成很小的块。 (同样的情况是,在一次大爆炸中将问题分解成小的均匀大小的块可能并不是一件轻而易举的事。假设在没有平衡的情况下处理某种形式的层次结构。)

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

答案 2 :(得分:13)

Fork / join与线程池不同,因为它实现了工作窃取。来自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秒。

编辑:正如Joonas指出的那样,任务不一定预先分配给一个线程。 fork / join的想法是线程可以选择将任务拆分为多个子块。所以要重申以上内容:

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

答案 3 :(得分:12)

上述每个人都是正确的,通过窃取工作可以获得好处,但要扩大其原因。

主要好处是工作线程之间的有效协调。工作必须分开并重新组装,这需要协调。正如您在A.H的答案中所见,每个线程都有自己的工作清单。此列表的一个重要属性是它已排序(顶部的大型任务和底部的小型任务)。每个线程执行其列表底部的任务,并从其他线程列表的顶部窃取任务。

结果是:

  • 任务列表的头部和尾部可以独立同步,减少列表上的争用。
  • 工作的重要子树被拆分并由同一个线程重新组装,因此这些子树不需要进行内部线程协调。
  • 当线程窃取工作时,它需要一大片然后再细分到自己的列表
  • 工作钢带意味着螺纹几乎完全被利用,直到工艺结束。

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

答案 4 :(得分:11)

在此示例中,Fork / Join不添加任何值,因为不需要分叉,并且工作负载在工作线程之间均匀分配。 Fork / Join只会增加开销。

这是关于这个主题的nice article。引用:

  

总的来说,我们可以说ThreadPoolExecutor是首选   工作负载在工作线程之间均匀分配的位置。能够   为了保证这一点,您需要确切地知道输入数据是什么   好像。相比之下,ForkJoinPool提供了良好的性能   不管输入数据如何,因此是非常稳健的   溶液

答案 5 :(得分:11)

线程池和Fork / Join的最终目标是相似的:两者都希望尽可能利用可用的CPU功率来实现最大吞吐量。最大吞吐量意味着应该在很长一段时间内完成尽可能多的任务。需要做什么? (对于以下内容,我们假设计算任务并不缺乏:100%的CPU利用率总是足够的。此外,我使用&#34; CPU&#34;等效于核心或虚拟核心,以防万一线程)。

  1. 至少需要运行与可用CPU一样多的线程,因为运行较少的线程会使核心未使用。
  2. 最多必须有多个线程在运行,因为有可用的CPU,因为运行更多的线程将为调度程序创建额外的负载,调度程序将CPU分配给不同的线程,这会导致一些CPU时间进入调度程序而不是计算机任务。
  3. 因此我们发现,为了获得最大吞吐量,我们需要拥有与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 next,否则它将检查其他线程的队列并窃取他们的工作。一旦另一个线程的另一个任务完成,它将检查A是否现在完成。如果是上述算法可以调用stepC。否则它将寻找另一个偷窃的任务。因此,即使遇到阻止操作, fork / join池也可以实现100%的CPU利用率

    但是有一个陷阱:工作窃取只能用于join ForkJoinTaskForkJoinPool次呼叫。无法对外部阻塞操作(如等待另一个线程或等待I / O操作)执行此操作。那么等待I / O完成是一项常见的任务呢?在这种情况下,如果我们可以向Fork / Join池添加一个额外的线程,一旦阻塞操作完成就会再次停止,这将是第二个最好的事情。如果我们使用ManagedBlocker s,那么public static int fib(int n) { if (n <= 1) { return n; } return fib(n - 1) + fib(n - 2); } 实际上可以做到这一点。

    斐波

    JavaDoc for RecursiveTask是使用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();
       }
    }
    

    正如JavaDocs中所解释的,这是计算斐波那契数的一种非常好的转储方法,因为该算法具有O(2 ^ n)复杂度,而更简单的方法是可能的。但是这个算法非常简单易懂,所以我们坚持使用它。让我们假设我们想要使用Fork / 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

    这使得子任务更小,因为它们仅在n > 10为真时被拆分,这意味着要进行的操作(getSurplusQueuedTaskCount() < 2)要多得多100个,并且没有非常人工任务已经在等待(fib(50))。

    在我的电脑上(4核(计数超线程时为8核),英特尔(R)核心(TM)i7-2720QM CPU @ 2.20GHz){{1}}使用经典方法需要64秒,而且仅需18秒使用Fork / Join方法获得的秒数非常明显,虽然没有理论上的那么多。

    摘要

    • 是的,在您的示例中,Fork / Join没有经典线程池的优势。
    • Fork / Join可以在涉及阻止时大幅提高性能
    • Fork / Join绕过一些死锁问题

答案 6 :(得分:8)

另一个重要区别似乎是,使用F-J,您可以执行多个复杂的“加入”阶段。考虑来自http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html的合并排序,预分割这项工作需要太多的编排。例如您需要执行以下操作:

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

如何指定必须在合并之前进行排序等等。

我一直在研究如何为每个项目清单做最好的事情。我想我会预先拆分列表并使用标准的ThreadPool。当工作不能被预分割成足够独立的任务时,FJ似乎最有用,但可以递归地分割成彼此独立的任务(例如,将两半分开是独立的,但是将两个排序的半合并为一个排序的整体不是)。 / p>

答案 7 :(得分:6)

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

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 =&gt; 6 9 9
7 8 9 .. 1 1 0 .... 8 9 9

答案 8 :(得分:2)

如果问题是我们必须等待其他线程完成(如排序数组或数组之和),应该使用fork join,因为Executor(Executors.newFixedThreadPool(2))会窒息由于线程数量有限。 在这种情况下,forkjoin池将创建更多线程以掩盖阻塞线程以保持相同的并行性

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

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

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

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

池会尝试通过动态添加,挂起或恢复内部工作线程来维护足够的活动(或可用)线程,即使某些任务停止等待加入其他任务也是如此。但是,面对阻塞的IO或其他非托管同步,无法保证此类调整

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

答案 9 :(得分:1)

你会对像履带行为这样的应用程序中的ForkJoin性能感到惊讶。 这是你要学习的最好的tutorial

  

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

答案 10 :(得分:1)

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

  

您决定使用fork-join-executor还是使用   线程池执行程序主要取决于是否在   该调度程序将被阻止。叉执行器给您一个   活动线程的最大数量,而线程池执行程序给出   您有固定数量的线程。如果线程被阻塞,   fork-join-executor将创建更多,而thread-pool-executor   将不会。对于阻塞操作,通常最好使用   线程池执行程序,因为它可以防止您的线程计数   爆炸。更多的“反应性”操作在   fork-join-executor。