Конечная цель пулов потоков и Fork / Join одинакова: оба хотят максимально использовать доступную мощность процессора для максимальной пропускной способности. Максимальная пропускная способность означает, что за длительный период времени нужно выполнить как можно больше задач. Что для этого нужно? (В дальнейшем мы будем предполагать, что недостатка в вычислительных задачах нет: всегда достаточно сделать для 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 И B. Этот алгоритм выполняет задачу A для пул потоков и выполнить задачу b напрямую. После этого поток будет ждать выполнения задачи A и перейдет к шагу C. Если A и B выполняются одновременно, тогда все в порядке. Но что, если A занимает больше времени, чем B? Это может быть связано с тем, что природа задачи A диктует это, но также может быть так, потому что нет потока для задачи A, доступного в начале, и задача A должна ждать. (Если доступен только один процессор и, таким образом, ваш пул потоков имеет только один поток, это даже вызовет тупик, но пока это не главное). Дело в том, что поток, только что выполнивший задачу 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
блокировка не будет . Вместо этого здесь вступает в игру кража работы : поток будет искать другие задачи, которые были разветвлены в прошлом, и продолжит их. Сначала он проверяет, начали ли обрабатываться разветвленные задачи. Поэтому, если A еще не был запущен другим потоком, он выполнит A следующим образом, иначе он проверит очередь других потоков и украдет их работу. Как только эта другая задача другого потока будет завершена, он проверит, завершена ли сейчас A. Если это вышеперечисленный алгоритм, можно позвонить stepC
. В противном случае он будет искать очередную задачу украсть. Таким образом, пулы fork / join могут достичь 100% -ной загрузки ЦП даже в условиях блокирующих действий .
Однако есть ловушка: кража работы возможна только по join
вызову ForkJoinTask
s. Это невозможно сделать для действий внешней блокировки, таких как ожидание другого потока или ожидание действия ввода-вывода. Так что насчет того, что ожидание завершения ввода-вывода - обычная задача? В этом случае, если бы мы могли добавить дополнительный поток в пул Fork / Join, который будет остановлен снова, как только действие блокировки будет завершено, будет вторым лучшим вариантом. И ForkJoinPool
действительно может это сделать, если мы используем ManagedBlocker
s.
Фибоначчи
В JavaDoc для RecursiveTask приведен пример вычисления чисел Фибоначчи с использованием Fork / Join. Для классического рекурсивного решения см .:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Как объясняется в 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 вызовов методов для do ( n > 10
), и не очень много ручных задач уже ожидают ( getSurplusQueuedTaskCount() < 2
).
На моем компьютере (4 ядра (8 при подсчете Hyper-threading), процессор Intel (R) Core (TM) i7-2720QM @ 2,20 ГГц) fib(50)
занимает 64 секунды при классическом подходе и всего 18 секунд при подходе Fork / Join, который это довольно заметный выигрыш, хотя и не настолько, насколько теоретически возможно.
Резюме
- Да, в вашем примере Fork / Join не имеет преимуществ перед классическими пулами потоков.
- Fork / Join может значительно улучшить производительность, когда задействована блокировка
- Fork / Join позволяет обойти некоторые проблемы с тупиками