Использование семафора внутри вложенного Java-параллельного потока действий может DEADLOCK. Это ошибка?

Рассмотрим следующую ситуацию: мы используем параллельный поток Java 8 для выполнения параллельного цикла forEach, например,

IntStream.range(0,20).parallel().forEach(i -> { /* work done here */})

Количество параллельных потоков контролируется системным свойством "java.util.concurrent.ForkJoinPool.common.parallelism" и обычно равно количеству процессоров.

Теперь предположим, что нам нравится ограничивать количество параллельных исполнений для определенной части работы - например, потому что эта часть имеет интенсивность памяти и ограничение памяти подразумевает предел параллельных исполнений.

Очевидным и изящным способом ограничения параллельных исполнений является использование Семафора (предлагаемый здесь), например, следующий код кода ограничивает количество параллельных исполнений 5:

        final Semaphore concurrentExecutions = new Semaphore(5);
        IntStream.range(0,20).parallel().forEach(i -> {

            concurrentExecutions.acquireUninterruptibly();

            try {
                /* WORK DONE HERE */
            }
            finally {
                concurrentExecutions.release();
            }
        });

Это работает отлично!

Однако: использование любого другого параллельного потока внутри рабочего (в /* WORK DONE HERE */) может привести к тупику.

Для меня это неожиданное поведение.

Объяснение: Поскольку потоки Java используют пул ForkJoin, внутренний forEach является forking, и соединение, кажется, ждет навсегда. Однако это поведение по-прежнему является неожиданным. Обратите внимание, что параллельные потоки работают даже если вы установили "java.util.concurrent.ForkJoinPool.common.parallelism" в 1.

Обратите внимание, что он может быть не прозрачным, если есть внутренняя параллель для каждого.

Вопрос: Является ли это поведение в соответствии со спецификацией Java 8 (в этом случае это подразумевает, что использование Семафоров внутри рабочих параллельных потоков запрещено) или это ошибка?

Для удобства: Ниже приведен полный тестовый пример. Любые комбинации двух булевых операций, кроме "true, true", приводят к тупиковой ситуации.

Уточнение: Чтобы сделать это ясно, позвольте мне подчеркнуть один аспект: тупик не встречается в acquire семафора. Обратите внимание, что код состоит из

  • приобрести семафор
  • запустить код
  • релиз семафора

и тупик происходит в 2. если этот фрагмент кода использует ДРУГОЙ параллельный поток. Затем тупик происходит внутри этого ДРУГОГО потока. Как следствие, кажется, что не разрешено использовать вложенные параллельные потоки и операции блокировки (например, семафор) вместе!

Обратите внимание, что документировано, что параллельные потоки используют ForkJoinPool и что ForkJoinPool и Семафор принадлежат одному и тому же пакету - java.util.concurrent (поэтому можно было бы ожидать, что они будут взаимодействовать красиво).

/*
 * (c) Copyright Christian P. Fries, Germany. All rights reserved. Contact: [email protected]
 *
 * Created on 03.05.2014
 */
package net.finmath.experiments.concurrency;

import java.util.concurrent.Semaphore;
import java.util.stream.IntStream;

/**
 * This is a test of Java 8 parallel streams.
 * 
 * The idea behind this code is that the Semaphore concurrentExecutions
 * should limit the parallel executions of the outer forEach (which is an
 * <code>IntStream.range(0,numberOfTasks).parallel().forEach</code> (for example:
 * the parallel executions of the outer forEach should be limited due to a
 * memory constrain).
 * 
 * Inside the execution block of the outer forEach we use another parallel stream
 * to create an inner forEach. The number of concurrent
 * executions of the inner forEach is not limited by us (it is however limited by a
 * system property "java.util.concurrent.ForkJoinPool.common.parallelism").
 * 
 * Problem: If the semaphore is used AND the inner forEach is active, then
 * the execution will be DEADLOCKED.
 * 
 * Note: A practical application is the implementation of the parallel
 * LevenbergMarquardt optimizer in
 * {@link http://finmath.net/java/finmath-lib/apidocs/net/finmath/optimizer/LevenbergMarquardt.html}
 * In one application the number of tasks in the outer and inner loop is very large (>1000)
 * and due to memory limitation the outer loop should be limited to a small (5) number
 * of concurrent executions.
 * 
 * @author Christian Fries
 */
public class ForkJoinPoolTest {

    public static void main(String[] args) {

        // Any combination of the booleans works, except (true,true)
        final boolean isUseSemaphore    = true;
        final boolean isUseInnerStream  = true;

        final int       numberOfTasksInOuterLoop = 20;              // In real applications this can be a large number (e.g. > 1000).
        final int       numberOfTasksInInnerLoop = 100;             // In real applications this can be a large number (e.g. > 1000).
        final int       concurrentExecusionsLimitInOuterLoop = 5;
        final int       concurrentExecutionsLimitForStreams = 10;

        final Semaphore concurrentExecutions = new Semaphore(concurrentExecusionsLimitInOuterLoop);

        System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism",Integer.toString(concurrentExecutionsLimitForStreams));
        System.out.println("java.util.concurrent.ForkJoinPool.common.parallelism = " + System.getProperty("java.util.concurrent.ForkJoinPool.common.parallelism"));

        IntStream.range(0,numberOfTasksInOuterLoop).parallel().forEach(i -> {

            if(isUseSemaphore) {
                concurrentExecutions.acquireUninterruptibly();
            }

            try {
                System.out.println(i + "\t" + concurrentExecutions.availablePermits() + "\t" + Thread.currentThread());

                if(isUseInnerStream) {
                    runCodeWhichUsesParallelStream(numberOfTasksInInnerLoop);
                }
                else {
                    try {
                        Thread.sleep(10*numberOfTasksInInnerLoop);
                    } catch (Exception e) {
                    }
                }
            }
            finally {
                if(isUseSemaphore) {
                    concurrentExecutions.release();
                }
            }
        });

        System.out.println("D O N E");
    }

    /**
     * Runs code in a parallel forEach using streams.
     * 
     * @param numberOfTasksInInnerLoop Number of tasks to execute.
     */
    private static void runCodeWhichUsesParallelStream(int numberOfTasksInInnerLoop) {
        IntStream.range(0,numberOfTasksInInnerLoop).parallel().forEach(j -> {
            try {
                Thread.sleep(10);
            } catch (Exception e) {
            }
        });
    }
}

Ответ 1

Каждый раз, когда вы разложите проблему на задачи, где эти задачи могут быть заблокированы для других задач, и попытайтесь выполнить их в пуле конечных потоков, вы рискуете вызвать тупик, вызванный пулом. См. Java Concurrency на практике 8.1.

Это, без сомнения, ошибка - в вашем коде. Вы заполняете пул FJ задачами, которые будут блокировать ожидание результатов других задач в одном пуле. Иногда вам повезло, и все удается не затормозить (точно так же, как не все ошибки блокировки блокировки постоянно выходят из тупика), но в основном вы катаетесь на каком-то очень тонком льду.

Ответ 2

После небольшого изучения исходного кода ForkJoinPool и ForkJoinTask я предполагаю, что нашел ответ:

Это ошибка (на мой взгляд), и ошибка находится в doInvoke() от ForkJoinTask. Проблема на самом деле связана с вложением двух циклов и, по-видимому, не с использованием Семафора, однако для этого необходимо, чтобы Семафор (или блокирование во внешнем контуре) нуждался в том, чтобы сделать проблему очевидной и привести к тупик (но я могу себе представить, что в этой ошибке есть другие проблемы - см. Вложенная Java 8 параллельная для каждого цикла работает плохо. Предполагается ли это поведение?).

Реализация метода doInvoke() в настоящее время выглядит следующим образом:

/**
 * Implementation for invoke, quietlyInvoke.
 *
 * @return status upon completion
 */
private int doInvoke() {
    int s; Thread t; ForkJoinWorkerThread wt;
    return (s = doExec()) < 0 ? s :
        ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
        (wt = (ForkJoinWorkerThread)t).pool.awaitJoin(wt.workQueue, this) :
        externalAwaitDone();
}

(и, возможно, также в doJoin, который выглядит аналогичным). В строке

        ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?

он проверяется, если Thread.currentThread() является экземпляром ForkJoinWorkerThread. Причиной этого теста является проверка того, работает ли ForkJoinTask на рабочем потоке пула или основного потока. Я считаю, что эта строка подходит для не-вложенной параллели, где она позволяет различать, выполняются ли текущие задачи на основном потоке или на рабочем столе пула. Тем не менее, для задач внутреннего цикла этот тест является проблематичным: назовем поток, который запускает parallel(). ForEeach поток создателя. Для внешнего цикла поток создателя является основным потоком, и он не является instanceof ForkJoinWorkerThread. Однако для внутренних циклов, начинающихся с ForkJoinWorkerThread, поток создателя также является instanceof ForkJoinWorkerThread. Следовательно, в этой ситуации тест ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ВСЕГДА ИСТИННЫЙ!

Следовательно, мы всегда вызываем pool.awaitJoint(wt.workQueue).

Теперь обратите внимание, что мы вызываем awaitJoint в FULL workQueue этого потока (я считаю, что это дополнительный недостаток). Похоже, что мы не только присоединяемся к задачам с внутренними циклами, но также и задаем (-ами) внешнего цикла, и мы ПРИСОЕДИНИТЕСЬ ВСЕМИ ТЫМИ задачами. К сожалению, внешняя задача содержит этот Семафор.

Чтобы доказать, что ошибка связана с этим, мы можем проверить очень простой обходной путь. Я создаю t = new Thread(), который запускает внутренний цикл, а затем выполняет t.start(); t.join();. Обратите внимание, что это не будет вводить никаких дополнительных parallelism (я сразу же присоединяюсь). Однако он изменит результат теста instanceof ForkJoinWorkerThread для потока создателя. (Обратите внимание, что задача все равно будет отправлена ​​в общий пул). Если этот поток обертки создан, проблема больше не возникает - по крайней мере, в моей текущей тестовой ситуации.

Я публикую полную демоверсию http://svn.finmath.net/finmath%20experiments/trunk/src/net/finmath/experiments/concurrency/ForkJoinPoolTest.java

В этом тестовом коде комбинация

final boolean isUseSemaphore        = true;
final boolean isUseInnerStream      = true;
final boolean isWrappedInnerLoopThread  = false;

приведет к тупиковой ситуации, а комбинация

final boolean isUseSemaphore        = true;
final boolean isUseInnerStream      = true;
final boolean isWrappedInnerLoopThread  = true;

(и фактически все остальные комбинации) не будет.

Обновление:. Поскольку многие указывают, что использование Семафора опасно, я попытался создать демо-версию проблемы без Семафора. Теперь больше нет тупиковой ситуации, но, на мой взгляд, неожиданной проблемы с производительностью. Я создал новую запись для этого Вложенная Java 8 параллельная для каждого цикла работает бедно. Ожидается ли такое поведение?. Демо-код находится здесь: http://svn.finmath.net/finmath%20experiments/trunk/src/net/finmath/experiments/concurrency/NestedParallelForEachTest.java

Ответ 3

Я проверил ваш тест в профилировщике (VisualVM), и я согласен: потоки ждут семафора и aWaitJoin() в пуле F/J.

В этой структуре есть серьезные проблемы, связанные с join(). Я уже четыре года пишу критику об этой структуре. Основная проблема соединения начинается здесь.

aWaitJoin() имеет схожие проблемы. Вы можете ознакомиться с кодом самостоятельно. Когда структура попадает на дно рабочего дека, он выдает wait(). Все это сводится к тому, что эта структура не имеет никакого способа сделать контекстный переключатель.

Существует способ получить эту структуру для создания потоков компенсации для застопоренных потоков. Вам необходимо реализовать интерфейс ForkJoinPool.ManagedBlocker. Как вы можете это сделать, я понятия не имею. Вы используете базовый API с потоками. Вы не внедряете API Streams и не записываете свой собственный код.

Я придерживаюсь своего комментария выше: как только вы перевернете parallelism в API, вы откажетесь от своей способности контролировать внутреннюю работу этого параллельного механизма. В API нет ошибок (кроме использования ошибочной структуры для параллельных операций). Проблема заключается в том, что семафоры или любой другой метод управления parallelism в API являются опасными идеями.