Кінцева мета потокових пулів і Fork / Join однакова: обидва хочуть використовувати наявну потужність процесора якнайкраще, щоб досягти максимальної пропускної здатності. Максимальна пропускна здатність означає, що якомога більше завдань має бути виконано протягом тривалого періоду часу. Що для цього потрібно? (Для наступного ми припустимо, що задач на обчислення не бракує. Завжди достатньо зробити для 100% використання процесора. Додатково я використовую "CPU" рівномірно для ядер або віртуальних ядер у разі гіперточки).
- Принаймні, має працювати стільки потоків, скільки доступних процесорів, тому що запущена менше ниток залишить ядро невикористаним.
- Як максимум, має працювати стільки потоків, скільки доступних процесорів, тому що запуск більшої кількості потоків створить додаткове навантаження для Планувальника, який призначає процесори різним потокам, що призводить до того, що деякий час процесора перейде до планувальника, а не до нашого обчислювального завдання.
Таким чином ми з'ясували, що для максимальної пропускної здатності нам потрібно мати точно таку ж кількість потоків, ніж процесори. У розмитому прикладі 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. нитковий пул і виконайте завдання b безпосередньо. Після цього потік буде чекати виконання завдання А також, і продовжить з кроком С. Якщо 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 next, інакше він перевірить чергу інших потоків і вкраде їх роботу. Після завершення цього завдання іншого потоку він перевірить, чи завершено 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);
}
Як пояснено в 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 CPU @ 2,20 ГГц) fib(50)
займає 64 секунди при класичному підході і всього 18 секунд при підході Fork / Join, який це досить помітний виграш, хоча не настільки теоретично, наскільки це можливо.
Підсумок
- Так, у вашому прикладі Fork / Join не має переваги перед класичними пулами потоків.
- Форк / приєднання може різко підвищити продуктивність, коли входить блокування
- Fork / Join обходить деякі проблеми з тупиком