Как структура fork/join лучше, чем пул потоков?

В чем преимущества использования новой структуры 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 "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?

Ответ 1

Я думаю, что основное недоразумение состоит в том, что примеры Fork/Join НЕ показывают работу воровство, но только какой-то стандартный разрыв и победа.

Кража работы была бы такой: рабочий B закончил свою работу. Он добрый, поэтому он оглядывается и видит, что Рабочий A все еще работает очень тяжело. Он прогуливается и спрашивает: "Эй, парень, я мог бы дать тебе руку". Ответы. "Круто, у меня эта задача - 1000 единиц. До сих пор я закончил 345, оставив 655. Не могли бы вы поработать над номером 673 до 1000, я буду делать 346 до 672." B говорит: "Хорошо, начнем, чтобы мы могли пойти в паб раньше".

Вы видите - рабочие должны общаться друг с другом, даже когда они начали настоящую работу. Это недостающая часть в примерах.

Примеры, с другой стороны, показывают только что-то вроде "использовать субподрядчиков":

Работник A: "Данг, у меня 1000 единиц работы. Слишком много для меня. Я сам сделаю 500 и передаю субподряд 500 кому-то другому". Это продолжается до тех пор, пока большая задача не будет разбита на небольшие пакеты по 10 единиц каждый. Это будут выполняться работниками. Но если один пакет является своего рода ядовитой таблеткой и занимает значительно больше времени, чем другие пакеты - неудача, фаза разделения заканчивается.

Единственное оставшееся различие между Fork/Join и разбиением задачи upfront заключается в следующем: при расщеплении upfront у вас есть рабочая очередь, полная с самого начала. Пример: 1000 единиц, порог - 10, поэтому очередь имеет 100 записей. Эти пакеты распространяются на элементы threadpool.

Fork/Join более сложна и пытается уменьшить количество пакетов в очереди:

  • Шаг 1: Поместите один пакет, содержащий (1... 1000) в очередь
  • Шаг 2: Один рабочий выдает пакет (1... 1000) и заменяет его двумя пакетами: (1... 500) и (501... 1000).
  • Шаг 3: Один рабочий пакет pops (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). ....

Вы видите: в вилке/в очереди очередь меньше (6 в примере), а фазы "split" и "work" чередуются.

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

Ответ 2

Если у вас есть n занятых потоков, которые работают независимо на 100% независимо друг от друга, это будет лучше, чем n потоков в пуле Fork-Join (FJ). Но так не получается.

Невозможно точно разделить проблему на n равных частей. Даже если вы это сделаете, планирование потоков - это не то, чтобы быть честным. Вы в конечном итоге ожидаете самую медленную нить. Если у вас есть несколько задач, то каждый из них может работать с меньшим, чем n-way parallelism (обычно более эффективным), но до тех пор, пока другие задачи не закончатся, перейдите на n-way.

Итак, почему бы нам просто не разрезать проблему на части размера FJ и работать с пулом потоков. Типичное использование FJ решает проблему на крошечные кусочки. Для этого в произвольном порядке требуется большая координация на аппаратном уровне. Накладные расходы были бы убийцей. В FJ задачи помещаются в очередь, которую поток считывает в порядке последнего в первом порядке (LIFO/stack), а воровство работы (обычно в основном случае) выполняется First In First Out (FIFO/ "queue" ). В результате обработка длинного массива может быть выполнена в значительной степени последовательно, даже если она разбита на мелкие куски. (Это также так, что может быть не тривиально разбить проблему на небольшие мелкие куски в одном большом ударе. Скажем, иметь дело с некоторой формой иерархии без балансировки.)

Заключение: FJ позволяет более эффективно использовать аппаратные потоки в неравных ситуациях, которые будут всегда, если у вас есть несколько потоков.

Ответ 3

Fork/join отличается от пула потоков, поскольку он реализует кражу работы. Из Вилка/Присоединение

Как и в любом ExecutorService, fork/join framework распределяет задачи для рабочих потоков в пуле потоков. Структура fork/join отличается тем, что использует алгоритм обработки работы. Рабочие потоки которые могут закончиться, могут украсть задания из других потоков, которые все еще заняты.

Скажем, у вас есть два потока и 4 задания a, b, c, d, которые занимают 1, 1, 5 и 6 секунд соответственно. Первоначально a и b назначаются потокам 1 и c и d в поток 2. В пуле потоков это займет 11 секунд. С fork/join поток 1 заканчивается и может украсть работу из потока 2, поэтому задача d завершится выполнением потока 1. Thread 1 выполняет a, b и d, thread 2 просто c. Общее время: 8 секунд, а не 11.

EDIT: Как указывает Joonas, задачи не обязательно предварительно выделены для потока. Идея fork/join состоит в том, что поток может выбрать разбиение задачи на несколько подклассов. Итак, переформулируем выше:

У нас есть две задачи (ab) и (cd), которые занимают соответственно 2 и 11 секунд. В потоке 1 начинается выполнение ab и разбивается на две подзадачи a и b. Аналогично с потоком 2 он разбивается на две подзадачи c и d. Когда поток 1 закончил a и b, он может украсть d из потока 2.

Ответ 4

Конечная цель пулов потоков и вилки/объединения одинакова: оба хотят использовать доступную мощность ЦП, насколько это возможно, для максимальной пропускной способности. Максимальная пропускная способность означает, что как можно больше задач должно быть завершено в течение длительного периода времени. Что нужно для этого? (Для следующего мы будем предполагать, что нет недостатка в задачах вычисления: для 100% -ного использования ЦП всегда достаточно. Кроме того, я использую "ЦП" эквивалентно для ядер или виртуальных ядер в случае гиперпотока).

  • По крайней мере, должно быть столько потоков, сколько есть доступных процессоров, потому что при запуске меньшего количества потоков будет оставлено неиспользуемое ядро.
  • В максимуме должно быть столько потоков, сколько имеется доступных ЦПУ, потому что запуск большего количества потоков создаст дополнительную нагрузку для Планировщика, который назначает процессоры для разных потоков, что заставляет некоторое время процессора перейти к планировщику, а не к нашим вычислительным задача.

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

Итак, когда вы столкнетесь с проблемами с пулами потоков? То есть, если поток блокирует, потому что ваш поток ожидает завершения другой задачи. Предположим, что следующий пример:

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 AND B. То, что делает этот алгоритм, представляет задачи A в threadpool и выполнить задачу b напрямую. После этого поток будет ожидать выполнения задачи А и продолжить с шага C. Если A и B завершены одновременно, все будет хорошо. Но что, если A занимает больше времени, чем B? Это может быть из-за того, что характер задачи А диктует его, но это может быть и так, потому что нет  поток для задачи A, доступный в начале, и задача A должна ждать. (Если имеется только один процессор, и, таким образом, ваш threadpool имеет только один поток, это даже вызовет тупик, но пока это не относится к точке). Дело в том, что поток, который только что выполнил задачу B , блокирует весь поток. Поскольку мы имеем такое же количество потоков, как и процессоры, и один поток заблокирован, это означает, что один процессор неактивен.

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 не будет блокировать. Вместо этого здесь вступает в игру work-stealing: поток будет искать другие задачи, которые были разветвлены в прошлом, и продолжит их. Сначала он проверяет, начали ли выполняемые им задачи. Поэтому, если A еще не запущен другим потоком, он будет делать следующий, иначе он проверит очередь других потоков и украдет их работу. Как только эта другая задача другого потока будет завершена, будет проверяться, завершено ли A сейчас. Если это алгоритм, вы можете вызвать stepC. В противном случае он будет искать еще одну задачу, чтобы украсть. Таким образом, пулы fork/join могут достигать 100% загрузки процессора, даже перед лицом блокирующих действий.

Однако есть ловушка: Работа-кража возможна только для вызова join ForkJoinTask s. Это не может быть сделано для внешних действий блокировки, таких как ожидание другого потока или ожидание действия ввода-вывода. Итак, что же, ожидая завершения ввода-вывода, является общей задачей? В этом случае, если бы мы могли добавить дополнительный поток в пул Fork/Join, который будет снова остановлен, как только действие блокировки завершится, будет второй лучшей задачей. И ForkJoinPool действительно может это сделать, если мы используем ManagedBlocker s.

Фибоначчи

В JavaDoc for RecursiveTask - пример вычисления чисел Фибоначчи с использованием Fork/Join. Для классического рекурсивного решения см.:

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

Как объясняется int 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 является истинным, что означает, что существует гораздо более 100 вызовов методов (n > 10), и уже не ожидаются очень большие задачи (getSurplusQueuedTaskCount() < 2).

На моем компьютере (4 ядра (8 при подсчете гиперпотоков), Intel (R) Core (TM) i7-2720QM CPU @2.20GHz) fib(50) занимает 64 секунды с классическим подходом и всего 18 секунд с подход Fork/Join, который является довольно заметным выигрышем, хотя и не настолько теоретически, насколько это возможно.

Резюме

  • Да, в вашем примере Fork/Join не имеет преимуществ перед классическими пулами потоков.
  • Fork/Join может значительно повысить производительность при блокировании.
  • Вилка/Соединение обходит некоторые проблемы взаимоблокировки.

Ответ 5

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

Основным преимуществом является эффективная координация между рабочими потоками. Работа должна быть разделена и собрана, что требует координации. Как вы можете видеть в ответе A.H, у каждого потока есть свой собственный рабочий список. Важным свойством этого списка является то, что он сортируется (большие задачи сверху и небольшие задачи внизу). Каждый поток выполняет задачи в нижней части своего списка и крадет задачи из верхней части других списков потоков.

Результат:

  • Заголовок и хвост списков задач могут синхронизироваться независимо, уменьшая количество конфликтов в списке.
  • Значительные поддеревья работы разделяются и повторно собираются одним и тем же потоком, поэтому для этих поддеревьев не требуется координация между потоками.
  • Когда поток ворует работу, он занимает большую часть, которая затем подразделяется на собственный список.
  • Сталелитейная обработка означает, что потоки почти полностью используются до конца процесса.

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

Ответ 6

В этом примере Fork/Join не добавляет значения, потому что forking не требуется, и рабочая нагрузка равномерно распределяется между рабочими потоками. Fork/Join добавляет дополнительные накладные расходы.

Вот хорошая статья по этому вопросу. Цитата:

В целом, мы можем сказать, что ThreadPoolExecutor предпочтительнее где рабочая нагрузка равномерно распределена между рабочими потоками. Иметь возможность чтобы гарантировать это, вам нужно точно знать, какие входные данные выглядит как. Напротив, ForkJoinPool обеспечивает хорошую производительность независимо от входных данных и, следовательно, является значительно более надежным Решение.

Ответ 7

Еще одно важное отличие заключается в том, что с F-J вы можете выполнять несколько сложных этапов "Join". Рассмотрим сортировку слияния из http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html, было бы слишком много оркестровки, требуемой для предварительного разделения этой работы. например Вам нужно сделать следующее:

  • сортировать первую четверть.
  • сортировать вторую четверть.
  • объединить первые 2 квартала
  • сортировать третий квартал
  • сортировать четвертый квартал
  • объединить последние 2 квартала
  • объединить две половинки

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

Я смотрел, как лучше всего сделать что-то для каждого из списка предметов. Я думаю, что я просто предварительно разделил список и использовал стандартный ThreadPool. FJ кажется наиболее полезным, когда работа не может быть предварительно разделена на достаточные независимые задачи, но может быть рекурсивно разделена на задачи, которые являются независимыми между собой (например, сортировка половинок независима, но объединение двух отсортированных половин в отсортированное целое не является).

Ответ 8

F/J также имеет явное преимущество, когда у вас есть дорогостоящие операции слияния. Поскольку он разбивается на древовидную структуру, вы делаете только log2 (n), сливаясь в отличие от n слияний с линейным расщеплением нитей. (Это делает теоретическое предположение о том, что у вас столько процессоров, сколько потоков, но все же преимущество). Для домашнего задания нам пришлось объединить несколько тысяч 2D-массивов (все те же измерения), суммируя значения по каждому индексу. При соединении fork и процессорах P время приближается к log2 (n), когда P приближается к бесконечности.

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

Ответ 9

Вы были бы поражены производительностью ForkJoin в приложении, например, гусеничном шасси. вот лучший учебник, о котором вы узнаете.

Логика Fork/Join очень проста: (1) отдельная (fork) каждая большая задача в меньшие задачи; (2) обрабатывать каждую задачу в отдельном потоке (при необходимости отделяя их на еще более мелкие задачи); (3) присоединиться к Результаты.

Ответ 10

Если проблема такова, что нам нужно дождаться завершения других потоков (как в случае сортировки массива или суммы массива), необходимо использовать соединение fork, поскольку Executor (Executors.newFixedThreadPool(2)) будет дросселировать из-за ограниченного числа потоков. Пул forkjoin создаст больше потоков в этом случае, чтобы покрыть заблокированный поток, чтобы поддерживать тот же parallelism

Источник: http://www.oracle.com/technetwork/articles/java/fork-join-422606.html

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

Структура fork/join, добавленная к пакету java.util.concurrent в Java SE 7 через усилия Дуга Лиса, заполняет этот пробел

Источник: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html

Пул пытается поддерживать достаточно активных (или доступных) потоков путем динамического добавления, приостановки или возобновления внутренних рабочих потоков, даже если некоторые задачи застопориваются, ожидая присоединения к другим. Тем не менее, никакие такие корректировки не гарантируются перед заблокированным IO или другой неуправляемой синхронизацией.

public int getPoolSize() Возвращает число рабочих потоков, которые были запущены, но еще не завершены. Результат, возвращаемый этим методом, может отличаться от getParallelism(), когда потоки создаются для поддержки parallelism, когда другие блокируются совместно.

Ответ 11

Я хотел бы добавить короткий ответ для тех, у кого мало времени, чтобы прочитать длинные ответы. Сравнение взято из книги Applied Akka Patterns:

Ваше решение относительно того, использовать ли fork-join-executor или thread-executor, во многом зависит от того, будут ли операции в этом диспетчере блокироваться. Aork-join-executor дает вам максимальное количество активных потоков, тогда как thread-pool-executor дает вам фиксированное количество потоков. Если потоки заблокированы, fork-join-executor создаст больше, а thread-pool-executor - нет. Для блокирующих операций вам, как правило, лучше работать с пулом потоков-исполнителем, потому что он предотвращает взрыв ваших потоков. Более "реактивные" операции лучше выполнять в fork-join-executor.