Руководство по Fork/Join Framework в Java

Руководство по Fork / Join Framework в Java

1. обзор

Фреймворк fork / join был представлен в Java 7. Он предоставляет инструменты, помогающие ускорить параллельную обработку, пытаясь использовать все доступные ядра процессора - что выполненоthrough a divide and conquer approach.

На практике это означает, чтоthe framework first “forks”, рекурсивно разбивая задачу на более мелкие независимые подзадачи, пока они не станут достаточно простыми для выполнения асинхронно.

После этогоthe “join” part begins, в котором результаты всех подзадач рекурсивно объединяются в один результат, или в случае задачи, которая возвращает void, программа просто ожидает выполнения каждой подзадачи.

Чтобы обеспечить эффективное параллельное выполнение, структура fork / join использует пул потоков, называемыйForkJoinPool, который управляет рабочими потоками типаForkJoinWorkerThread.

2. ForkJoinPoolс

ForkJoinPool - это сердце фреймворка. Это реализацияExecutorService, которая управляет рабочими потоками и предоставляет нам инструменты для получения информации о состоянии и производительности пула потоков.

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

Эта архитектура жизненно важна для балансировки нагрузки потока с помощьюwork-stealing algorithm.

2.1. Алгоритм кражи работы

Проще говоря, свободные потоки пытаются «украсть» работу у занятых потоков.

По умолчанию рабочий поток получает задачи из заголовка своей собственной очереди. Когда он пуст, поток берет задачу из хвоста очереди другого занятого потока или из глобальной очереди на вход, поскольку именно здесь, вероятно, будут находиться самые большие части работы.

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

2.2. ForkJoinPoolс Конкретизация

В Java 8 наиболее удобный способ получить доступ к экземпляруForkJoinPool - использовать его статический методcommonPool().. Как следует из названия, это предоставит ссылку на общий пул, который является пул потоков по умолчанию для каждогоForkJoinTask.

СогласноOracle’s documentation, использование предопределенного общего пула снижает потребление ресурсов, поскольку это препятствует созданию отдельного пула потоков для каждой задачи.

ForkJoinPool commonPool = ForkJoinPool.commonPool();

Такого же поведения можно добиться в Java 7, создавForkJoinPool и назначив его полюpublic static служебного класса:

public static ForkJoinPool forkJoinPool = new ForkJoinPool(2);

Теперь к нему легко получить доступ:

ForkJoinPool forkJoinPool = PoolUtil.forkJoinPool;

С помощью конструкторовForkJoinPool’s можно создать собственный пул потоков с определенным уровнем параллелизма, фабрикой потоков и обработчиком исключений. В приведенном выше примере пул имеет уровень параллелизма 2. Это означает, что пул будет использовать 2 процессорных ядра.

3. ForkJoinTask<V>с

ForkJoinTask - это базовый тип для задач, выполняемых внутриForkJoinPool.. На практике следует расширить один из двух его подклассов:RecursiveAction для задачvoid иRecursiveTask<V> для задач, возвращающих значение. _ They both have an abstract method _compute(), в котором определяется логика задачи.

3.1. RecursiveAction – An Exampleс

В приведенном ниже примере единица работы, которую нужно обработать, представленаString, называемымworkload. В демонстрационных целях задача является бессмысленной: она просто вводит прописные буквы и регистрирует их.

Чтобы продемонстрировать разветвленное поведение каркаса, используется методthe example splits the task if workload.length() is larger than a specified threshold_ using the _createSubtask().

Строка рекурсивно делится на подстроки, создавая экземплярыCustomRecursiveTask, основанные на этих подстроках.

В результате метод возвращаетList<CustomRecursiveAction>.

Список передается вForkJoinPool с использованием методаinvokeAll():

public class CustomRecursiveAction extends RecursiveAction {

    private String workload = "";
    private static final int THRESHOLD = 4;

    private static Logger logger =
      Logger.getAnonymousLogger();

    public CustomRecursiveAction(String workload) {
        this.workload = workload;
    }

    @Override
    protected void compute() {
        if (workload.length() > THRESHOLD) {
            ForkJoinTask.invokeAll(createSubtasks());
        } else {
           processing(workload);
        }
    }

    private List createSubtasks() {
        List subtasks = new ArrayList<>();

        String partOne = workload.substring(0, workload.length() / 2);
        String partTwo = workload.substring(workload.length() / 2, workload.length());

        subtasks.add(new CustomRecursiveAction(partOne));
        subtasks.add(new CustomRecursiveAction(partTwo));

        return subtasks;
    }

    private void processing(String work) {
        String result = work.toUpperCase();
        logger.info("This result - (" + result + ") - was processed by "
          + Thread.currentThread().getName());
    }
}

Этот шаблон можно использовать для разработки ваших собственных классовRecursiveAction.. Для этого создайте объект, представляющий общий объем работы, выберите подходящий порог, определите метод разделения работы и определите метод работы.

3.2. RecursiveTask<V>с

Для задач, которые возвращают значение, логика здесь аналогична, за исключением того, что результат для каждой подзадачи объединен в один результат:

public class CustomRecursiveTask extends RecursiveTask {
    private int[] arr;

    private static final int THRESHOLD = 20;

    public CustomRecursiveTask(int[] arr) {
        this.arr = arr;
    }

    @Override
    protected Integer compute() {
        if (arr.length > THRESHOLD) {
            return ForkJoinTask.invokeAll(createSubtasks())
              .stream()
              .mapToInt(ForkJoinTask::join)
              .sum();
        } else {
            return processing(arr);
        }
    }

    private Collection createSubtasks() {
        List dividedTasks = new ArrayList<>();
        dividedTasks.add(new CustomRecursiveTask(
          Arrays.copyOfRange(arr, 0, arr.length / 2)));
        dividedTasks.add(new CustomRecursiveTask(
          Arrays.copyOfRange(arr, arr.length / 2, arr.length)));
        return dividedTasks;
    }

    private Integer processing(int[] arr) {
        return Arrays.stream(arr)
          .filter(a -> a > 10 && a < 27)
          .map(a -> a * 10)
          .sum();
    }
}

В этом примере работа представлена ​​массивом, хранящимся в полеarr классаCustomRecursiveTask. МетодcreateSubtask() рекурсивно делит задачу на более мелкие части работы до тех пор, пока каждая часть не станет меньше порогового значения.. Затем методinvokeAll() отправляет подзадачи в общий опрос и возвращает списокFutureс.

Для запуска выполнения для каждой подзадачи вызывается методjoin().

В этом примере это достигается с помощью Java 8Stream API;, методsum() используется как представление объединения подрезультатов в окончательный результат.

4. Отправка задач вForkJoinPool

Для отправки задач в пул потоков можно использовать несколько подходов.

Методsubmit() илиexecute() (их варианты использования одинаковы):

forkJoinPool.execute(customRecursiveTask);
int result = customRecursiveTask.join();

Методinvoke() разветвляет задачу и ожидает результата и не требует ручного присоединения:

int result = forkJoinPool.invoke(customRecursiveTask);

МетодinvokeAll() - наиболее удобный способ передать последовательностьForkJoinTasks вForkJoinPool.. Он принимает задачи в качестве параметров (две задачи, аргументы переменных или коллекцию), их вилки возвращает сбор объектовFuture в том порядке, в котором они были созданы.

В качестве альтернативы вы можете использовать отдельные методыfork() and join(). Методfork() отправляет задачу в пул, но не запускает ее выполнение. Для этого используется методjoin(). В случаеRecursiveActionjoin() не возвращает ничего, кромеnull; дляRecursiveTask<V>, возвращает результат выполнения задачи:

customRecursiveTaskFirst.fork();
result = customRecursiveTaskLast.join();

В нашем примереRecursiveTask<V> мы использовали методinvokeAll() для отправки последовательности подзадач в пул. Та же работа может быть проделана сfork() иjoin(), хотя это имеет последствия для упорядочивания результатов.

Чтобы избежать путаницы, обычно рекомендуется использовать методinvokeAll() для отправки более одной задачи вForkJoinPool.

5. Выводы

Использование структуры fork / join может ускорить обработку больших задач, но для достижения этого результата необходимо следовать некоторым рекомендациям:

  • Use as few thread pools as possible - в большинстве случаев лучшим решением является использование одного пула потоков для каждого приложения или системы.

  • Use the default common thread pool,, если особая настройка не требуется

  • Use a reasonable threshold для разделенияForkJoingTask на подзадачи

  • Избегайте блокирования в вашем ForkJoingTasks

Примеры, использованные в этой статье, доступны вlinked GitHub repository.