我可以使用ForkJoinPool的工作窃取行为来避免线程饥饿死锁吗?

时间:2014-10-26 18:11:12

标签: java multithreading concurrency java.util.concurrent fork-join

如果池中的所有线程都在等待同一池中的排队任务完成,则线程饥饿死锁在正常线程池中发生。 ForkJoinPool通过从join()调用内部的其他线程窃取工作来避免此问题,而不是简单地等待。例如:

private static class ForkableTask extends RecursiveTask<Integer> {
    private final CyclicBarrier barrier;

    ForkableTask(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    protected Integer compute() {
        try {
            barrier.await();
            return 1;
        } catch (InterruptedException | BrokenBarrierException e) {
            throw new RuntimeException(e);
        }
    }
}

@Test
public void testForkJoinPool() throws Exception {
    final int parallelism = 4;
    final ForkJoinPool pool = new ForkJoinPool(parallelism);
    final CyclicBarrier barrier = new CyclicBarrier(parallelism);

    final List<ForkableTask> forkableTasks = new ArrayList<>(parallelism);
    for (int i = 0; i < parallelism; ++i) {
        forkableTasks.add(new ForkableTask(barrier));
    }

    int result = pool.invoke(new RecursiveTask<Integer>() {
        @Override
        protected Integer compute() {
            for (ForkableTask task : forkableTasks) {
                task.fork();
            }

            int result = 0;
            for (ForkableTask task : forkableTasks) {
                result += task.join();
            }
            return result;
        }
    });
    assertThat(result, equalTo(parallelism));
}

但是当ExecutorService界面使用ForkJoinPool时,工作窃取似乎不会发生。例如:

private static class CallableTask implements Callable<Integer> {
    private final CyclicBarrier barrier;

    CallableTask(CyclicBarrier barrier) {
        this.barrier = barrier;
    }

    @Override
    public Integer call() throws Exception {
        barrier.await();
        return 1;
    }
}

@Test
public void testWorkStealing() throws Exception {
    final int parallelism = 4;
    final ExecutorService pool = new ForkJoinPool(parallelism);
    final CyclicBarrier barrier = new CyclicBarrier(parallelism);

    final List<CallableTask> callableTasks = Collections.nCopies(parallelism, new CallableTask(barrier));
    int result = pool.submit(new Callable<Integer>() {
        @Override
        public Integer call() throws Exception {
            int result = 0;
            // Deadlock in invokeAll(), rather than stealing work
            for (Future<Integer> future : pool.invokeAll(callableTasks)) {
                result += future.get();
            }
            return result;
        }
    }).get();
    assertThat(result, equalTo(parallelism));
}

通过粗略查看ForkJoinPool的实现,所有常规ExecutorService API都是使用ForkJoinTask实现的,所以我不确定为什么会出现死锁发生。

3 个答案:

答案 0 :(得分:27)

你几乎回答了自己的问题。解决方案是“ForkJoinPool通过从join()调用内部窃取其他线程的工作来避免此问题的声明”。每当除了ForkJoinPool.join()之外的某些其他原因阻塞线程时,不会发生这种工作窃取,并且线程只是等待并且什么都不做。

这样做的原因是,在Java中,ForkJoinPool不可能阻止其线程阻塞,而是为其提供其他工作。线程本身需要避免阻塞,而是要求池应该做的工作。这只能在ForkJoinTask.join()方法中实现,而不是在任何其他阻止方法中实现。如果您在Future内使用ForkJoinPool,您还会看到饥饿死锁。

为什么工作窃取只在ForkJoinTask.join()中实现,而不是在Java API中的任何其他阻塞方法中实现?好吧,有很多这样的阻塞方法(Object.wait()Future.get()java.util.concurrent中的任何并发原语,I / O方法等),它们与{{无关} 1}},这只是API中的一个任意类,因此向所有这些方法添加特殊情况将是糟糕的设计。它还可能导致非常令人惊讶和不希望的效果。想象一下,例如用户将任务传递给等待ForkJoinPool的{​​{1}},然后发现该任务在ExecutorService中挂起很长时间只是因为正在运行的线程偷了其他一些(长期运行的)工作项而不是等待Future并在结果可用后立即继续。一旦线程开始处理另一个任务,它就无法返回到原始任务,直到第二个任务完成。因此,其他阻止方法不会进行工作窃取实际上是一件好事。对于Future.get(),此问题不存在,因为主要任务尽快继续并不重要,所有任务一起尽可能高效地处理是非常重要的。

由于所有相关部分都不公开,因此也无法在Future内实施自己的工作窃取方法。

然而,实际上还有第二种方法可以防止饥饿死锁。这称为托管阻止。它不使用工作窃取(以避免上面提到的问题),但也需要阻塞的线程积极配合线程池。使用托管阻塞,线程告诉线程池它可能在之前被阻塞它调用潜在阻塞方法,并在阻塞方法完成时通知池。然后线程池知道存在饥饿死锁的风险,并且如果其所有线程当前处于某些阻塞操作中并且还有其他任务要执行,则可能产生其他线程。请注意,由于额外线程的开销,这比工作窃取效率低。如果使用普通期货和托管阻塞实现递归并行算法而不是ForkJoinTask并且工作窃取,则额外线程的数量会变得非常大(因为在算法的“除法”阶段,很多任务将被创建并提供给立即阻塞并等待子任务结果的线程。但是,仍然会阻止饥饿死锁,并且它避免了任务必须等待很长时间的问题,因为它的线程同时开始处理另一个任务。

Java的ForkJoinPool也支持托管阻止。要使用它,需要实现接口ForkJoinPool.ManagedBlocker,以便从此接口的ForkJoinTask方法中调用任务要执行的潜在阻塞方法。然后任务可能不会直接调用阻塞方法,而是需要调用静态方法ForkJoinPool.managedBlock(ManagedBlocker)。此方法在阻塞之前和之后处理与线程池的通信。它也适用于当前任务未在ForkJoinPool内执行,然后它只调用阻塞方法。

我在Java API(Java 7)中找到的唯一实际使用托管阻止的地方是类Phaser。 (此类是同步屏障,如互斥锁和锁存器,但更灵活,功能更强大。)因此,与block任务内的ForkJoinPool同步应使用托管阻塞,并可避免饥饿死锁(但{{1}仍然更可取,因为它使用工作窃取而不是托管阻止)。无论您是直接使用Phaser还是通过其ForkJoinPool界面,都可以使用此功能。但是,如果您使用类ForkJoinTask.join()创建的任何其他ForkJoinPool,则无法使用,因为这些不支持托管阻止。

在Scala中,托管拦截的使用更为普遍(descriptionAPI)。

答案 1 :(得分:1)

我看到你在做什么,但我不知道为什么。屏障的想法是如此独立的线程可以等待彼此达到共同点。你没有独立的主题。线程池F / J适用于Data Parallelism

您正在做一些更适合Task Parallelism

的事情

F / J继续的原因是框架创建了&#34;延续线程&#34;当所有工作线程都在等待时,继续从deques中获取工作。

答案 2 :(得分:1)

您将实现细节与合同担保相混淆。您在文档中的哪个位置发现join会窃取工作并防止死锁?相反,文档说:

  

方法join()及其变体仅在完成依赖项是非循环的时才适用;也就是说,并行计算可以描述为有向无环图(DAG)。否则,执行可能会遇到死锁的形式,因为任务会周期性地互相等待。

您的示例是循环的。调用barrier.await()的任务彼此依赖。

文档进一步指出:

  

但是,此框架支持其他方法和技术(例如,使用Phaser,helpQuiesce()和complete(V)),这些方法和技术可用于构造非静态构造为DAG的问题的自定义子类。 / p>

Phaser的文档说明:

  

在ForkJoinPool中执行的任务也可以使用定相器。如果池的parallelismLevel可以容纳同时阻塞的最大数目的参与者,则可以确保进度。

这仍然不清楚(因为在描述与join的交互时并没有明确说明),但这可能意味着Phaser的设计可以像第一个示例中那样工作。