Parallele und asynchrone Programmierung in Java 8

Parallelcode, also Code, der auf mehr als einem Thread ausgeführt wird, war einst der Albtraum vieler erfahrener Entwickler, aber Java 8 brachte viele Änderungen mit sich, die diesen leistungssteigernden Trick viel überschaubarer machen sollten.

Parallele Streams

Vor Java 8 gab es einen großen Unterschied zwischen parallelem (oder gleichzeitigem) Code und sequentiellem Code. Es war auch sehr schwierig, nicht sequenziellen Code zu debuggen. Wenn Sie einfach einen Haltepunkt setzen und den Fluss wie gewohnt durchlaufen, wird der parallele Aspekt entfernt, was ein Problem darstellt, wenn dies den Fehler verursacht.

Zum Glück gab uns Java 8 Streams, die größte Sache für Java-Entwickler seit der Bohne. Wenn Sie nicht wissen, was sie sind, ermöglicht die Stream-API die Verarbeitung von Sequenzen von Elementen in einer funktionalen Angelegenheit. (Überprüfen Sie unseren Vergleich zwischen Streams und .NET’s LINQ hier .) Einer der Vorteile von Streams ist, dass die Struktur des Codes gleich bleibt: ob sequentiell oder gleichzeitig, es bleibt genauso lesbar.

Damit Ihr Code parallel ausgeführt wird, verwenden Sie einfach .parallelStream() anstelle von .stream() (oder stream.parallel(), wenn Sie nicht der Ersteller des Streams sind).

Aber nur weil es einfach ist, bedeutet das nicht, dass paralleler Code immer die beste Wahl ist. Sie sollten immer überlegen, ob es sinnvoll ist, Parallelität für Ihren Code zu verwenden. Der wichtigste Faktor bei dieser Entscheidung ist die Geschwindigkeit: Verwenden Sie nur Parallelität, wenn Ihr Code dadurch schneller wird als sein sequentielles Gegenstück.

Die Geschwindigkeitsfrage

Paralleler Code erhält seinen Geschwindigkeitsvorteil durch die Verwendung mehrerer Threads anstelle des einzelnen Threads, den sequentieller Code verwendet. Die Entscheidung, wie viele Threads erstellt werden sollen, kann eine schwierige Frage sein, da mehr Threads nicht immer zu schnellerem Code führen: Wenn Sie zu viele Threads verwenden, kann die Leistung Ihres Codes tatsächlich sinken.

Es gibt ein paar Regeln, die Ihnen sagen, welche Anzahl von Threads Sie wählen sollen. Dies hängt hauptsächlich von der Art der Operation ab, die Sie ausführen möchten, und von der Anzahl der verfügbaren Kerne.

Rechenintensive Operationen sollten eine Anzahl von Threads verwenden, die kleiner oder gleich der Anzahl der Kerne ist, während E / A-intensive Operationen wie das Kopieren von Dateien keinen Nutzen für die CPU haben und daher eine höhere Anzahl von Threads verwenden können. Der Code weiß nicht, welcher Fall anwendbar ist, es sei denn, Sie sagen ihm, was zu tun ist. Andernfalls wird standardmäßig eine Anzahl von Threads verwendet, die der Anzahl der Kerne entspricht.

Es gibt zwei Hauptfälle, in denen es nützlich sein kann, Ihren Code parallel statt sequentiell auszuführen: zeitaufwändige Aufgaben und Aufgaben, die in großen Sammlungen ausgeführt werden. Java 8 brachte eine neue Art, mit diesen großen Sammlungen umzugehen, nämlich mit Streams. Streams haben eine eingebaute Effizienz durch Faulheit: Sie verwenden Lazy Evaluation, die Ressourcen spart, indem sie nicht mehr als nötig tun. Dies ist nicht dasselbe wie Parallelität, die sich nicht um die Ressourcen kümmert, solange sie schneller geht. Für große Sammlungen benötigen Sie wahrscheinlich keine klassische Parallelität.

Async gehen

Lektionen aus JavaScript

Es kommt selten vor, dass ein Java-Entwickler sagen kann, dass er etwas aus JavaScript gelernt hat, aber wenn es um asynchrone Programmierung geht, hat JavaScript es zuerst richtig gemacht. Als grundsätzlich asynchrone Sprache hat JavaScript viel Erfahrung damit, wie schmerzhaft es sein kann, wenn es schlecht implementiert ist. Es begann mit Rückrufen und wurde später durch Versprechen ersetzt. Ein wichtiger Vorteil von Promises ist, dass es zwei „Kanäle“ hat: einen für Daten und einen für Fehler. Ein JavaScript-Versprechen könnte ungefähr so aussehen:

func.then(f1).catch(e1).then(f2).catch(e2);

Wenn also die ursprüngliche Funktion ein erfolgreiches Ergebnis hat, wird f1 aufgerufen, aber wenn ein Fehler ausgelöst wurde, wird e1 aufgerufen. Dies kann dazu führen, dass es zum erfolgreichen Track zurückkehrt (f2) oder zu einem anderen Fehler führt (e2). Sie können von der Datenspur zur Fehlerspur und zurück wechseln.

Die Java-Version von JavaScript Promises heißt CompletableFuture.

CompletableFuture

CompletableFuture implementiert sowohl die Future als auch die CompletionStage Schnittstelle. Future existierte bereits vor Java8, war aber an sich nicht sehr entwicklerfreundlich. Sie konnten das Ergebnis der asynchronen Berechnung nur mithilfe der Methode .get() , die den Rest blockierte (wodurch der asynchrone Teil die meiste Zeit ziemlich sinnlos wurde), und Sie mussten jedes mögliche Szenario manuell implementieren. Das Hinzufügen der CompletionStage -Schnittstelle war der Durchbruch, der die asynchrone Programmierung in Java praktikabel machte.

CompletionStage ist ein Versprechen, nämlich das Versprechen, dass die Berechnung schließlich durchgeführt wird. Es enthält eine Reihe von Methoden, mit denen Sie Rückrufe anhängen können, die bei dieser Vervollständigung ausgeführt werden. Jetzt können wir das Ergebnis ohne Blockierung verarbeiten.

Es gibt zwei Hauptmethoden, mit denen Sie den asynchronen Teil Ihres Codes starten können: supplyAsync wenn Sie etwas mit dem Ergebnis der Methode tun möchten, und runAsync wenn Sie dies nicht tun.

CompletableFuture.runAsync(() → System.out.println("Run async in completable future " + Thread.currentThread()));CompletableFuture.supplyAsync(() → 5);

Rückrufe

Jetzt können Sie diese Rückrufe hinzufügen, um das Ergebnis Ihres supplyAsync zu verarbeiten.

CompletableFuture.supplyAsync(() → 5).thenApply(i → i * 3).thenAccept(i → System.out.println("The result is " + i).thenRun(() → System.out.println("Finished."));

.thenApply ähnelt der Funktion .map für Streams: Sie führt eine Transformation durch. Im obigen Beispiel wird das Ergebnis (5) mit 3 multipliziert. Dieses Ergebnis (15) wird dann weiter in die Leitung geleitet.

.thenAccept führt eine Methode für das Ergebnis aus, ohne es zu transformieren. Es wird auch kein Ergebnis zurückgegeben. Hier wird „Das Ergebnis ist 15“ auf die Konsole gedruckt. Es kann mit der .foreach -Methode für Streams verglichen werden.

.thenRun verwendet das Ergebnis der asynchronen Operation nicht und gibt auch nichts zurück, es wartet nur darauf, Runnable aufzurufen, bis der vorherige Schritt abgeschlossen ist.

Async synchronisieren

Alle oben genannten Rückrufmethoden sind auch in einer asynchronen Version erhältlich: thenRunAsync, thenApplyAsync usw. Diese Versionen können in einem eigenen Thread ausgeführt werden und geben Ihnen zusätzliche Kontrolle, da Sie angeben können, welche ForkJoinPool verwendet werden soll.

Wenn Sie die asynchrone Version nicht verwenden, werden die Rückrufe alle im selben Thread ausgeführt.

Wenn etwas schief geht

Wenn etwas schief geht, wird die exceptionally -Methode verwendet, um die Ausnahme zu behandeln. Sie können ihm eine Methode geben, die einen Wert zurückgibt, um wieder auf die Datenspur zu gelangen, oder eine (neue) Ausnahme auslösen.

….exceptionally(ex → new Foo()).thenAccept(this::bar);

Kombinieren und Komponieren

Sie können mehrere CompletableFuturesverketten, indem Sie die thenCompose -Methode verwenden. Ohne sie wäre das Ergebnis verschachtelt CompletableFutures . Dies macht thenCompose und thenApply wie flatMap und map für Streams.

CompletableFuture.supplyAsync(() -> "Hello").thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "World"));

Wenn Sie das Ergebnis von zwei CompletableFutures kombinieren möchten, benötigen Sie eine Methode namens thenCombine .

future.thenCombine(future2, Integer::sum).thenAccept(value → System.out.println(value));

Wie Sie im obigen Beispiel sehen können, kann das Ergebnis des Rückrufs in thenCombine wie ein normales CompletableFuture mit all Ihren bevorzugten CompletionStage -Methoden behandelt werden.

Fazit

Die parallele Programmierung muss kein unüberwindliches Hindernis mehr auf der Jagd nach schnellerem Code sein. Java 8 macht den Prozess so einfach wie möglich, so dass jedes Stück Code, das möglicherweise davon profitieren könnte, in die Multi-Core-Zukunft gezogen werden kann, die tatsächlich nur die Gegenwart ist. Damit meine ich: Es ist einfach zu machen, also probieren Sie es aus und überzeugen Sie sich selbst von den Vorteilen.



+