Was sind die Vorteile der Verwendung des neuen Fork / Join-Frameworks, wenn Sie die große Aufgabe zu Beginn einfach in N Unteraufgaben aufteilen, sie an einen zwischengespeicherten Thread-Pool (von Executors ) senden und auf den Abschluss jeder Aufgabe warten? Ich verstehe nicht, wie die Verwendung der Fork / Join-Abstraktion das Problem vereinfacht oder die Lösung effizienter macht als seit Jahren.
Der parallelisierte Unschärfealgorithmus im Tutorial-Beispiel könnte beispielsweise folgendermaßen implementiert werden:
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
}
}
Am Anfang teilen und Aufgaben an einen Thread-Pool senden:
// 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's "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!
Die Aufgaben werden in die Warteschlange des Thread-Pools gestellt, aus der sie ausgeführt werden, sobald Arbeitsthreads verfügbar werden. Solange die Aufteilung detailliert genug ist (um nicht besonders auf die letzte Aufgabe warten zu müssen) und der Thread-Pool über genügend (mindestens N Prozessoren) Threads verfügt, arbeiten alle Prozessoren mit voller Geschwindigkeit, bis die gesamte Berechnung abgeschlossen ist.
Vermisse ich etwas Was ist der Mehrwert der Verwendung des Fork / Join-Frameworks?
Wenn Sie n ausgelastete Threads haben, die alle zu 100% unabhängig voneinander arbeiten, ist dies besser als n Threads in einem Fork-Join (FJ) -Pool. Aber so funktioniert es nie.
Möglicherweise kann das Problem nicht genau in n gleiche Teile aufgeteilt werden. Selbst wenn Sie dies tun, ist die Thread-Planung weit davon entfernt, fair zu sein. Sie werden am Ende auf den langsamsten Thread warten. Wenn Sie mehrere Aufgaben haben, können diese jeweils mit weniger als n-Wege-Parallelität (im Allgemeinen effizienter) ausgeführt werden. Wenn andere Aufgaben abgeschlossen sind, können Sie jedoch auf n-Wege gehen.
Warum schneiden wir das Problem nicht einfach in Stücke in FJ-Größe und lassen einen Thread-Pool daran arbeiten. Die typische Verwendung von FJ schneidet das Problem in winzige Stücke. Um dies in zufälliger Reihenfolge zu tun, ist auf Hardwareebene viel Koordination erforderlich. Die Gemeinkosten wären ein Killer. In FJ werden Aufgaben in eine Warteschlange gestellt, die der Thread in der Reihenfolge Last In First Out (LIFO / Stack) abliest, und das Stehlen von Arbeiten (in der Kernarbeit im Allgemeinen) erfolgt First In First Out (FIFO / "Warteschlange"). Das Ergebnis ist, dass die Verarbeitung langer Arrays weitgehend sequentiell erfolgen kann, obwohl sie in winzige Teile aufgeteilt ist. (Es ist auch der Fall, dass es möglicherweise nicht trivial ist, das Problem in kleinen, gleich großen Stücken in einem großen Knall aufzuteilen. Sagen wir, Sie müssen sich mit einer Form von Hierarchie befassen, ohne zu balancieren.)
Schlussfolgerung: FJ ermöglicht eine effizientere Verwendung von Hardware-Threads in ungleichmäßigen Situationen. Dies ist immer dann der Fall, wenn Sie mehr als einen Thread haben.
quelle
maxSize
Parameters in meinem Beispiel würde eine fast ähnliche Teilaufgabenteilung erzeugen wie die "binäre Aufteilung" im FJ-Beispiel (erfolgt innerhalb dercompute()
Methode, die entweder etwas berechnet oder Unteraufgaben an sendetinvokeAll()
).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).
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:
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:
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 aufrufenstepC
. 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 vonForkJoinTask
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 dasForkJoinPool
kann genau das, wenn wirManagedBlocker
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:
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:
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:
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
quelle
Fork / Join unterscheidet sich von einem Thread-Pool, da es das Stehlen von Arbeit implementiert. Von Fork / Join
Angenommen, Sie haben zwei Threads und 4 Aufgaben a, b, c, d, die jeweils 1, 1, 5 und 6 Sekunden dauern. Zu Beginn werden a und b Thread 1 und c und d Thread 2 zugewiesen. In einem Thread-Pool würde dies 11 Sekunden dauern. Mit Fork / Join wird Thread 1 beendet und kann Arbeit von Thread 2 stehlen, sodass Aufgabe d von Thread 1 ausgeführt wird. Thread 1 führt a, b und d, Thread 2 nur c aus. Gesamtzeit: 8 Sekunden, nicht 11.
BEARBEITEN: Wie Joonas betont, sind Aufgaben nicht unbedingt einem Thread vorab zugewiesen. Die Idee von Fork / Join ist, dass ein Thread eine Aufgabe in mehrere Unterteile aufteilen kann. Um das oben Gesagte noch einmal zu wiederholen:
Wir haben zwei Aufgaben (ab) und (cd), die 2 bzw. 11 Sekunden dauern. Thread 1 beginnt mit der Ausführung von ab und teilt es in zwei Unteraufgaben a & b auf. Ähnlich teilt sich Thread 2 in zwei Unteraufgaben c & d auf. Wenn Thread 1 a & b beendet hat, kann er d von Thread 2 stehlen.
quelle
compute()
die Aufgabe innerhalb jeder Aufgabe entweder berechnet oder in zwei Unteraufgaben aufgeteilt. Welche Option ausgewählt wird, hängt nur von der Größe der Aufgabe ab (if (mLength < sThreshold)...
). Es ist also nur eine ausgefallene Möglichkeit, eine feste Anzahl von Aufgaben zu erstellen. Für ein 1000x1000-Bild gibt es genau 16 Unteraufgaben, die tatsächlich etwas berechnen. Zusätzlich gibt es 15 (= 16 - 1) "Zwischen" -Aufgaben, die nur Unteraufgaben generieren und aufrufen und selbst nichts berechnen.computeDirectly()
Methode auszuführen , gibt es keine Möglichkeit mehr, etwas zu stehlen. Die gesamte Aufteilung erfolgt a priori , zumindest im Beispiel.Alle oben genannten haben Recht, die Vorteile werden durch das Stehlen von Arbeit erzielt, aber um zu erläutern, warum dies so ist.
Der Hauptvorteil ist die effiziente Koordination zwischen den Arbeitsthreads. Die Arbeit muss aufgeteilt und wieder zusammengesetzt werden, was eine Koordinierung erfordert. Wie Sie in der obigen Antwort von AH sehen können, hat jeder Thread seine eigene Arbeitsliste. Eine wichtige Eigenschaft dieser Liste ist, dass sie sortiert ist (große Aufgaben oben und kleine Aufgaben unten). Jeder Thread führt die Aufgaben am Ende seiner Liste aus und stiehlt Aufgaben am Anfang anderer Thread-Listen.
Das Ergebnis ist:
Die meisten anderen Divide- und Conquer-Schemata, die Thread-Pools verwenden, erfordern mehr Kommunikation und Koordination zwischen den Threads.
quelle
In diesem Beispiel fügt Fork / Join keinen Wert hinzu, da kein Forking erforderlich ist und die Arbeitslast gleichmäßig auf die Arbeitsthreads verteilt ist. Fork / Join erhöht nur den Overhead.
Hier ist ein schöner Artikel zu diesem Thema. Zitat:
quelle
Ein weiterer wichtiger Unterschied scheint zu sein, dass Sie mit FJ mehrere komplexe "Join" -Phasen durchführen können. Betrachten Sie die Zusammenführungssortierung von http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html . Es wäre zu viel Orchestrierung erforderlich, um diese Arbeit vorab aufzuteilen. zB müssen Sie folgende Dinge tun:
Wie legen Sie fest, dass Sie die Sortierungen vor den Zusammenführungen vornehmen müssen, die sie betreffen usw.
Ich habe mir überlegt, wie man für jede Liste von Elementen am besten eine bestimmte Sache macht. Ich denke, ich werde die Liste einfach vorab teilen und einen Standard-ThreadPool verwenden. FJ scheint am nützlichsten zu sein, wenn die Arbeit nicht in genügend unabhängige Aufgaben vorab aufgeteilt werden kann, sondern rekursiv in Aufgaben aufgeteilt werden kann, die untereinander unabhängig sind (z. B. das Sortieren der Hälften ist unabhängig, das Zusammenführen der beiden sortierten Hälften zu einem sortierten Ganzen jedoch nicht).
quelle
F / J hat auch einen deutlichen Vorteil, wenn Sie teure Zusammenführungsvorgänge haben. Da es sich in eine Baumstruktur aufteilt, führen Sie nur log2 (n) -Zusammenführungen durch, im Gegensatz zu n-Zusammenführungen mit linearer Thread-Aufteilung. (Dies setzt die theoretische Annahme voraus, dass Sie so viele Prozessoren wie Threads haben, aber dennoch einen Vorteil.) Für eine Hausaufgabe mussten wir mehrere tausend 2D-Arrays (alle die gleichen Dimensionen) zusammenführen, indem wir die Werte an jedem Index summierten. Bei Fork Join- und P-Prozessoren nähert sich die Zeit log2 (n), wenn sich P der Unendlichkeit nähert.
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
quelle
Sie werden von der Leistung von ForkJoin in Anwendungen wie Crawlern begeistert sein. Hier ist das beste Tutorial, aus dem Sie lernen würden.
quelle
Wenn das Problem so ist, dass wir warten müssen, bis andere Threads abgeschlossen sind (wie beim Sortieren des Arrays oder der Summe des Arrays), sollte der Fork-Join verwendet werden, da Executor (Executors.newFixedThreadPool (2)) aufgrund von Einschränkungen erstickt Anzahl der Themen. Der Forkjoin-Pool erstellt in diesem Fall mehr Threads, um den blockierten Thread zu vertuschen und die gleiche Parallelität beizubehalten
Quelle: http://www.oracle.com/technetwork/articles/java/fork-join-422606.html
Das Problem mit den Ausführenden beim Implementieren von Divide- und Conquer-Algorithmen hängt nicht mit dem Erstellen von Unteraufgaben zusammen, da ein Callable eine neue Unteraufgabe an seinen Ausführenden senden und synchron oder asynchron auf das Ergebnis warten kann. Das Problem ist das der Parallelität: Wenn ein Callable auf das Ergebnis eines anderen Callable wartet, wird es in einen Wartezustand versetzt, wodurch die Gelegenheit verpasst wird, einen anderen Callable zu verarbeiten, der zur Ausführung in die Warteschlange gestellt wird.
Das Fork / Join-Framework, das durch die Bemühungen von Doug Lea zum Paket java.util.concurrent in Java SE 7 hinzugefügt wurde, schließt diese Lücke
Quelle: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/ForkJoinPool.html
Der Pool versucht, genügend aktive (oder verfügbare) Threads aufrechtzuerhalten, indem interne Worker-Threads dynamisch hinzugefügt, angehalten oder fortgesetzt werden, selbst wenn einige Aufgaben blockiert sind und darauf warten, anderen beizutreten. Bei blockierten E / A-Vorgängen oder anderen nicht verwalteten Synchronisierungen sind solche Anpassungen jedoch nicht garantiert
public int getPoolSize () Gibt die Anzahl der Arbeitsthreads zurück, die gestartet, aber noch nicht beendet wurden. Das von dieser Methode zurückgegebene Ergebnis kann von getParallelism () abweichen, wenn Threads erstellt werden, um die Parallelität aufrechtzuerhalten, wenn andere kooperativ blockiert werden.
quelle
Ich möchte eine kurze Antwort für diejenigen hinzufügen, die nicht viel Zeit haben, lange Antworten zu lesen. Der Vergleich stammt aus dem Buch Applied Akka Patterns:
quelle