Das ultimative Ziel von Thread-Pools und Fork / Join ist gleich: Beide möchten die verfügbare CPU-Leistung so gut wie möglich für einen maximalen Durchsatz nutzen. Maximaler Durchsatz bedeutet, dass so viele Aufgaben wie möglich in einem langen Zeitraum erledigt werden sollten. Was wird dazu benötigt? (Für das Folgende gehen wir davon aus, dass es nicht an Berechnungsaufgaben mangelt: Es gibt immer genug zu tun für eine 100% ige CPU-Auslastung. Zusätzlich verwende ich "CPU" äquivalent für Kerne oder virtuelle Kerne im Falle von Hyper-Threading).
- Zumindest müssen so viele Threads ausgeführt werden, wie CPUs verfügbar sind, da durch das Ausführen weniger Threads ein Kern nicht verwendet wird.
- Maximal müssen so viele Threads ausgeführt werden, wie CPUs verfügbar sind, da durch das Ausführen von mehr Threads eine zusätzliche Last für den Scheduler entsteht, der den verschiedenen Threads CPUs zuweist, wodurch einige CPU-Zeit für den Scheduler und nicht für unsere Rechenaufgabe benötigt wird.
Daher haben wir herausgefunden, dass wir für einen maximalen Durchsatz genau die gleiche Anzahl von Threads benötigen wie CPUs. Im verwischenden Beispiel von Oracle können Sie beide einen Thread-Pool mit fester Größe verwenden, wobei die Anzahl der Threads der Anzahl der verfügbaren CPUs entspricht, oder einen Thread-Pool verwenden. Es wird keinen Unterschied machen, Sie haben Recht!
Wann werden Sie Probleme mit einem Thread-Pool bekommen? Dies ist der Fall , wenn ein Thread blockiert , da Ihr Thread darauf wartet, dass eine andere Aufgabe abgeschlossen wird. Nehmen Sie das folgende Beispiel an:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Was wir hier sehen, ist ein Algorithmus, der aus drei Schritten A, B und C besteht. A und B können unabhängig voneinander ausgeführt werden, aber Schritt C benötigt das Ergebnis von Schritt A UND B. Dieser Algorithmus übergibt Aufgabe A an den Threadpool und führen Sie Aufgabe b direkt aus. Danach wartet der Thread, bis auch Aufgabe A erledigt ist, und fährt mit Schritt C fort. Wenn A und B gleichzeitig abgeschlossen sind, ist alles in Ordnung. Aber was ist, wenn A länger dauert als B? Dies kann daran liegen, dass die Art von Aufgabe A dies vorschreibt, aber es kann auch daran liegen, dass zu Beginn kein Thread für Aufgabe A verfügbar ist und Aufgabe A warten muss. (Wenn nur eine einzige CPU verfügbar ist und Ihr Threadpool daher nur einen einzigen Thread hat, führt dies sogar zu einem Deadlock, aber im Moment ist das nicht der Punkt). Der Punkt ist, dass der Thread, der gerade Aufgabe B ausgeführt hatblockiert den gesamten Thread . Da wir die gleiche Anzahl von Threads wie CPUs haben und ein Thread blockiert ist, bedeutet dies, dass eine CPU inaktiv ist .
Fork / Join löst dieses Problem: Im Fork / Join-Framework würden Sie denselben Algorithmus wie folgt schreiben:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Sieht genauso aus, nicht wahr? Der Hinweis ist jedoch, dass aTask.join
nicht blockieren wird . Stattdessen kommt hier das Stehlen von Arbeit ins Spiel: Der Thread wird sich nach anderen Aufgaben umsehen, die in der Vergangenheit gegabelt wurden, und mit diesen fortfahren. Zunächst wird geprüft, ob die von ihm gegabelten Aufgaben verarbeitet wurden. Wenn A noch nicht von einem anderen Thread gestartet wurde, führt es als nächstes A aus. Andernfalls wird die Warteschlange anderer Threads überprüft und deren Arbeit gestohlen. Sobald diese andere Aufgabe eines anderen Threads abgeschlossen ist, wird geprüft, ob A jetzt abgeschlossen ist. Wenn es der obige Algorithmus ist, kann er aufrufen stepC
. Andernfalls wird nach einer weiteren Aufgabe gesucht, die gestohlen werden muss. Somit können Fork / Join-Pools eine 100% ige CPU-Auslastung erreichen, selbst angesichts blockierender Aktionen .
Es gibt jedoch eine Falle: Arbeitsdiebstahl ist nur für den join
Anruf von ForkJoinTask
s möglich. Dies kann nicht für externe Blockierungsaktionen wie das Warten auf einen anderen Thread oder das Warten auf eine E / A-Aktion durchgeführt werden. Was ist damit? Das Warten auf den Abschluss der E / A ist eine häufige Aufgabe. In diesem Fall ist es am zweitbesten, wenn wir dem Fork / Join-Pool einen zusätzlichen Thread hinzufügen könnten, der nach Abschluss der Blockierungsaktion erneut gestoppt wird. Und das ForkJoinPool
kann genau das, wenn wir ManagedBlocker
s verwenden.
Fibonacci
In JavaDoc for RecursiveTask finden Sie ein Beispiel für die Berechnung von Fibonacci-Zahlen mit Fork / Join. Eine klassische rekursive Lösung finden Sie unter:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Wie in den JavaDocs erläutert, ist dies eine hübsche Dump-Methode zur Berechnung von Fibonacci-Zahlen, da dieser Algorithmus eine O (2 ^ n) -Komplexität aufweist und einfachere Methoden möglich sind. Dieser Algorithmus ist jedoch sehr einfach und leicht zu verstehen, daher bleiben wir dabei. Nehmen wir an, wir möchten dies mit Fork / Join beschleunigen. Eine naive Implementierung würde folgendermaßen aussehen:
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();
}
}
Die Schritte, in die diese Aufgabe unterteilt ist, sind viel zu kurz und daher wird dies eine schreckliche Leistung bringen. Sie können jedoch sehen, wie das Framework im Allgemeinen sehr gut funktioniert: Die beiden Summanden können unabhängig voneinander berechnet werden, aber dann benötigen wir beide, um das Finale zu erstellen Ergebnis. Eine Hälfte wird also in einem anderen Thread gemacht. Viel Spaß mit Thread-Pools, ohne einen Deadlock zu bekommen (möglich, aber bei weitem nicht so einfach).
Der Vollständigkeit halber: Wenn Sie Fibonacci-Zahlen tatsächlich mit diesem rekursiven Ansatz berechnen möchten, finden Sie hier eine optimierte Version:
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);
}
}
}
Dadurch bleiben die Unteraufgaben viel kleiner, da sie nur dann aufgeteilt werden, wenn n > 10 && getSurplusQueuedTaskCount() < 2
dies der Fall ist. Dies bedeutet, dass deutlich mehr als 100 Methodenaufrufe zu erledigen sind ( n > 10
) und nicht sehr viele Aufgaben bereits warten ( getSurplusQueuedTaskCount() < 2
).
Auf meinem Computer (4 Core (8 beim Zählen von Hyper-Threading), Intel (R) Core (TM) i7-2720QM-CPU bei 2,20 GHz) fib(50)
dauert dies beim klassischen Ansatz 64 Sekunden und beim Fork / Join-Ansatz nur 18 Sekunden ist ein beachtlicher Gewinn, wenn auch nicht so viel wie theoretisch möglich.
Zusammenfassung
- Ja, in Ihrem Beispiel hat Fork / Join keinen Vorteil gegenüber klassischen Thread-Pools.
- Fork / Join kann die Leistung beim Blockieren drastisch verbessern
- Fork / Join umgeht einige Deadlock-Probleme