Beherrschen Sie die neue strukturierte gleichzeitige Programmierung von JDK21 und verbessern Sie ganz einfach die Entwicklungseffizienz!

1. Übersicht

Vereinfachen Sie die gleichzeitige Programmierung durch die Einführung einer API für strukturierte gleichzeitige Programmierung. Strukturierte Parallelität behandelt Gruppen zusammengehöriger Aufgaben, die in verschiedenen Threads ausgeführt werden, als eine einzige Arbeitseinheit, was die Fehlerbehandlung und -behebung vereinfacht, die Zuverlässigkeit verbessert und die Beobachtbarkeit verbessert. Dies ist eine Vorschau-API.

2 Geschichte

Strukturierte Parallelität wurde von JEP 428 vorgeschlagen und als Inkubations-API in JDK 19 veröffentlicht. Es wurde in JDK 20 durch JEP 437 neu inkubiert, mit einer leichten Aktualisierung der Gültigkeitsbereiche (JEP 429).

Wir schlagen hier strukturierte Parallelität als Vorschau-API im JUC-Paket vor. Die einzige wesentliche Änderung besteht darin, dass die Methode StructuredTaskScope::fork(...) eine [Unteraufgabe] anstelle einer Zukunft zurückgibt, wie unten erläutert.

3 Tore

Fördert einen Stil der gleichzeitigen Programmierung, der häufige Risiken aufgrund von Abbrüchen und Abschaltungen, wie Thread-Lecks und Abbruchverzögerungen, eliminiert.

Verbessern Sie die Beobachtbarkeit von gleichzeitigem Code.

4 Nichtziel

Ersetzt keines der Parallelitätskonstrukte im JUC-Paket, wie z. B. ExecutorService und Future.

Die endgültige strukturierte Parallelitäts-API für die Java-Plattform ist nicht definiert. Andere strukturierte Parallelitätskonstrukte können von Bibliotheken von Drittanbietern oder in zukünftigen JDK-Versionen definiert werden.

Eine Methode (d. h. ein Kanal) zum Teilen von Datenströmen zwischen Threads ist nicht definiert. Ich werde vorschlagen, dies in Zukunft zu tun.

Ersetzen Sie den vorhandenen Thread-Unterbrechungsmechanismus nicht durch einen neuen Thread-Abbruchmechanismus. Ich werde vorschlagen, dies in Zukunft zu tun.

5 Motive

Entwickler bewältigen die Komplexität, indem sie Aufgaben in Teilaufgaben aufteilen. Im normalen Single-Threaded-Code werden Unteraufgaben nacheinander ausgeführt. Wenn die Teilaufgaben jedoch ausreichend unabhängig voneinander sind und genügend Hardwareressourcen vorhanden sind, kann die Gesamtaufgabe schneller (d. h. mit geringerer Latenz) ausgeführt werden, indem die Teilaufgaben gleichzeitig in verschiedenen Threads ausgeführt werden. Wenn Sie beispielsweise die Ergebnisse mehrerer E/A-Vorgänge in einer einzigen Aufgabe kombinieren, wird die Ausführung schneller, wenn jeder E/A-Vorgang gleichzeitig in einem eigenen Thread ausgeführt wird. Virtuelle Threads (JEP 444) machen es kostengünstig, jedem dieser I/O-Vorgänge einen Thread zuzuweisen, aber die Verwaltung der potenziell großen Anzahl von Threads bleibt eine Herausforderung.

6 ExecutorService Unstrukturierte Parallelität

java.util.concurrent.ExecutorServiceDie API wurde in Java 5 eingeführt und hilft Entwicklern, Teilaufgaben gleichzeitig auszuführen.

Eine Methode wie die folgende handle()stellt eine Aufgabe in einer Serveranwendung dar. ExecutorServiceEs verarbeitet eingehende Anfragen , indem es zwei Unteraufgaben an sendet .

ExecutorServiceGibt jede Unteraufgabe sofort zurück Futureund führt sie gleichzeitig gemäß der Planungsrichtlinie des Ausführenden aus. Methoden warten auf die Ergebnisse von Unteraufgaben, indem sie die Methode handle()blockieren Future, die sie aufgerufen hat . Daher wird davon ausgegangen, dass die Aufgabe ihren Unteraufgaben beigetreten ist.get()

Response handle() throws ExecutionException, InterruptedException {
    Future<String> user = esvc.submit(() -> findUser());
    Future<Integer> order = esvc.submit(() -> fetchOrder());
    String theUser = user.get();   // 加入 findUser
    int theOrder = order.get();    // 加入 fetchOrder
    return new Response(theUser, theOrder);
}

Da Teilaufgaben gleichzeitig ausgeführt werden, kann jede Teilaufgabe unabhängig voneinander erfolgreich sein oder fehlschlagen. In diesem Zusammenhang bedeutet „Fehler“ das Auslösen einer Ausnahme. Normalerweise handle()sollte eine Aufgabe wie diese fehlschlagen, wenn eine ihrer Unteraufgaben fehlschlägt. Den Lebenszyklus eines Threads zu verstehen, wenn Fehler auftreten, kann sehr kompliziert werden:

  • Wenn findUser()eine Ausnahme ausgelöst wird, wird user.get()beim Aufruf auch eine Ausnahme ausgelöst handle(), die jedoch fetchOrder()weiterhin in einem eigenen Thread ausgeführt wird. Dies ist ein Thread-Leck, im besten Fall eine Verschwendung von Ressourcen, im schlimmsten Fall fetchOrder()kann der Thread andere Aufgaben beeinträchtigen.

  • Wenn handle()der Ausführungsthread unterbrochen wird, wird diese Unterbrechung nicht auf Unteraufgaben übertragen. findUser()Beide fetchOrder()Threads lecken und handle()laufen auch nach einem Fehler weiter.

  • Wenn findUser()die Ausführung von a lange dauert, in dieser Zeit jedoch ein Fehler auftritt fetchOrder(), handle()wird unnötig gewartet, findUser()da die Ausführung blockiert wird user.get(), anstatt sie abzubrechen. Erst nachdem findUser()der Vorgang abgeschlossen und user.get()zurückgegeben wurde order.get(), wird eine Ausnahme ausgelöst, handle()die zum Scheitern führt.

Das Problem besteht in jedem Fall darin, dass unsere Programme logisch in Task-Subtask-Beziehungen strukturiert sind, diese Beziehungen jedoch nur im Kopf des Entwicklers existieren. Dies erhöht nicht nur die Fehlerwahrscheinlichkeit, sondern erschwert auch die Diagnose und Behebung solcher Fehler. handle()Beispielsweise zeigen Beobachtbarkeitstools wie Thread-Dumps , findUser()und in nicht zusammenhängenden Thread-Aufrufstapeln an, fetchOrder()ohne Hinweise auf Task-Subtask-Beziehungen.

Sie können versuchen, andere Unteraufgaben explizit abzubrechen, wenn ein Fehler auftritt, indem Sie beispielsweise die Aufgabe mit try-finally in den Catch-Block der fehlgeschlagenen Aufgabe einschließen und die FutureMethode der anderen Aufgabe aufrufen cancel(boolean). Wir müssen auch try-with-resourcesdie Aussage ExecutorService„like“ verwenden

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

Denn Futurees gibt keine Möglichkeit, auf den Abbruch einer Aufgabe zu warten. All dies ist jedoch schwierig umzusetzen und macht die logische Absicht des Codes tendenziell noch schwieriger zu verstehen. Das Verfolgen der Beziehungen zwischen Aufgaben und das manuelle Hinzufügen der erforderlichen Abbruchkanten zwischen Aufgaben stellt für den Entwickler eine große Herausforderung dar.

Unbegrenzter gleichzeitiger Modus

Diese Notwendigkeit, Lebenszyklen manuell zu koordinieren, ist auf die Tatsache zurückzuführen, dass ExecutorServiceunbegrenzte FutureParallelitätsmodi möglich sind. Unter allen beteiligten Threads, ohne Begrenzung und Reihenfolge:

  • Ein Thread kann einen erstellenExecutorService
  • Ein anderer Thread kann ihm Arbeit übermitteln
  • Der Thread, der die Arbeit erledigt, hat nichts mit dem ersten oder zweiten Thread zu tun

Nachdem ein Thread Arbeit übermittelt hat, kann ein völlig anderer Thread auf das Ergebnis der Ausführung warten. Jeder Code , der über Futureeine Referenz verfügt, kann mit ihm verbunden werden (d. h. get()durch Aufrufen von auf das Ergebnis warten) und sogar FutureCode in einem anderen Thread als dem ausführen, von dem er abgerufen wurde. Tatsächlich muss eine von einer Aufgabe gestartete Unteraufgabe nicht zu der Aufgabe zurückkehren, die sie übermittelt hat. Es könnte zu einer von vielen Aufgaben oder zu keiner zurückgegeben werden.

Da ExecutorServicesie Futureeine solche unstrukturierte Nutzung zulassen, werden Beziehungen zwischen Aufgaben und Unteraufgaben weder erzwungen noch verfolgt, obwohl solche Beziehungen üblich und nützlich sind. Selbst wenn Unteraufgaben übermittelt und in derselben Aufgabe zusammengefasst werden, kann das Scheitern einer Unteraufgabe daher nicht automatisch zum Abbruch einer anderen Unteraufgabe führen. Bei der oben genannten handle()Methode fetchOrder()führt der Ausfall von nicht automatisch findUser()zum Abbruch von . fetchOrder()Das hat keine Beziehung Futurezum findUser(), Futurenoch zu get()dem Thread, der es letztendlich über seine Methode verbindet. Anstatt von den Entwicklern zu verlangen, diese Stornierung manuell zu verwalten, möchten wir diesen Prozess zuverlässig automatisieren können.

Die Aufgabenstruktur sollte die Codestruktur widerspiegeln

Im ExecutorServiceGegensatz zur Free-Threaded-Komposition unter erzwingt die Ausführung von Single-Threaded-Code immer eine Hierarchie von Aufgaben und Unteraufgaben. Der Codeblock der Methode {...}entspricht einer Aufgabe, und die innerhalb des Codeblocks aufgerufene Methode entspricht der Unteraufgabe. Die aufgerufene Methode muss zu der Methode zurückkehren, die sie aufgerufen hat, oder eine Ausnahme für die Methode auslösen, die sie aufgerufen hat. Es kann nicht außerhalb der Methode leben, die es aufgerufen hat, und es kann auch keine Ausnahmen für andere Methoden zurückgeben oder auslösen. Somit werden alle Unteraufgaben vor den Aufgaben abgeschlossen, jede Unteraufgabe ist eine Unteraufgabe ihrer übergeordneten Aufgabe und der Lebenszyklus jeder Unteraufgabe im Verhältnis zu anderen Unteraufgaben und Aufgaben wird durch die Syntaxregeln der Codeblockstruktur bestimmt.

Wie in der Single-Threaded-Version handle()ist die Task-Subtask-Beziehung in der Syntaxstruktur offensichtlich:

Response handle() throws IOException {
    String theUser = findUser();
    int theOrder = fetchOrder();
    return new Response(theUser, theOrder);
}

Wir findUser()beginnen mit Unteraufgaben erst, wenn sie abgeschlossen sind fetchOrder(), unabhängig davon, ob findUser()sie erfolgreich waren oder fehlgeschlagen sind. Wenn findUser()dies fehlschlägt, starten wir überhaupt nicht fetchOrder()und handle()die Aufgabe schlägt implizit fehl. Es ist wichtig, dass eine Unteraufgabe nur zu ihrer übergeordneten Aufgabe zurückkehren kann: Dies bedeutet, dass die übergeordnete Aufgabe das Scheitern einer Unteraufgabe implizit als Auslöser für den Abbruch anderer ausstehender Unteraufgaben sehen und dann selbst fehlschlagen kann.

In Single-Threaded-Code spiegelt sich die Task-Subtask-Hierarchie zur Laufzeit im Aufrufstapel wider. Somit erhalten wir entsprechende Eltern-Kind-Beziehungen, die die Fehlerausbreitung steuern. Hierarchische Beziehungen werden deutlich, wenn man sich einzelne Threads ansieht: findUser()(und später fetchOrder()) scheinen handle()unter . Dies macht die Beantwortung der Frage „Was ist handle()-Handling?“ einfach.

Die gleichzeitige Programmierung ist einfacher, zuverlässiger und besser beobachtbar, wenn die Eltern-Kind-Beziehung zwischen Aufgaben und Unteraufgaben in der syntaktischen Struktur des Codes offensichtlich ist und sich zur Laufzeit widerspiegelt, genau wie bei Single-Threaded-Code. Die Syntaxstruktur definiert den Lebenszyklus von Unteraufgaben und ermöglicht die Erstellung einer Darstellung einer Thread-Hierarchie zur Laufzeit, die einem Single-Thread-Aufrufstapel ähnelt. Diese Darstellung ermöglicht die Fehlerausbreitung, den Abbruch und die sinnvolle Beobachtung gleichzeitiger Programme.

7 Strukturierte Parallelität

Strukturierte Parallelität ist ein Ansatz zur gleichzeitigen Programmierung, der die natürliche Beziehung zwischen Aufgaben und Unteraufgaben beibehält und so zu besser lesbarem, wartbarem und zuverlässigerem gleichzeitigem Code führt. Der Begriff „strukturierte Parallelität“ wurde von Martin Sústrik geprägt und von Nathaniel J. Smith populär gemacht. Designideen für die Fehlerbehandlung in der strukturierten Parallelität können aus Konzepten in anderen Programmiersprachen gelernt werden, beispielsweise aus hierarchischen Monitoren in Erlang.

Strukturierte Parallelität beruht auf einem einfachen Prinzip:

Wenn eine Aufgabe in gleichzeitige Unteraufgaben zerlegt wird, kehren alle diese Unteraufgaben an denselben Ort zurück, den Codeblock der Aufgabe.

In der strukturierten Parallelität stellen Unteraufgaben Aufgabenarbeit dar. Aufgaben warten auf die Ergebnisse von Unteraufgaben und überwachen deren Fehler. Ähnlich wie bei strukturierten Programmiertechniken in Single-Threaded-Code beruht die Stärke der strukturierten Parallelität beim Multithreading auf zwei Ideen:

  • Definieren Sie klare Ein- und Ausstiegspunkte für den Ausführungsfluss innerhalb eines Codeblocks
  • in strikter Verschachtelung von Betriebslebenszyklen, um zu reflektieren, wie sie syntaktisch im Code verschachtelt sind

Da die Ein- und Ausstiegspunkte eines Codeblocks genau definiert sind, ist die Lebensdauer einer gleichzeitigen Unteraufgabe auf den Syntaxblock ihrer übergeordneten Aufgabe beschränkt. Da die Lebenszyklen von Geschwister-Unteraufgaben in den Lebenszyklen ihrer übergeordneten Aufgaben verschachtelt sind, können sie als Einheit durchdacht und verwaltet werden. Da der Lebenszyklus einer übergeordneten Aufgabe wiederum im Lebenszyklus ihrer übergeordneten Aufgabe verschachtelt ist, kann die Laufzeit die Aufgabenhierarchie als Baumstruktur implementieren, ähnlich dem gleichzeitigen Gegenstück eines Single-Thread-Aufrufstapels. Dies ermöglicht es dem Code, Richtlinien für Aufgabenteilbäume anzuwenden, z. B. Fristen, und ermöglicht es Beobachtbarkeitstools, Unteraufgaben als untergeordnete übergeordnete Aufgaben darzustellen.

Strukturierte Parallelität eignet sich gut für virtuelle Threads, bei denen es sich um leichtgewichtige Threads handelt, die vom JDK implementiert werden. Viele virtuelle Threads können denselben Betriebssystem-Thread gemeinsam nutzen, wodurch eine sehr große Anzahl virtueller Threads unterstützt werden kann. Unter anderem sind virtuelle Threads günstig genug, um jedes gleichzeitige Verhalten im Zusammenhang mit E/A usw. darzustellen. Dies bedeutet, dass eine Serveranwendung strukturierte Parallelität verwenden kann, um Zehntausende oder sogar Millionen eingehende Anforderungen gleichzeitig zu verarbeiten: Sie kann der Aufgabe, jede Anforderung zu verarbeiten, einen neuen virtuellen Thread zuweisen und bei gleichzeitiger Ausführung Unteraufgaben senden, wenn eine Aufgabe fortschreitet. Es kann jeder Unteraufgabe einen neuen virtuellen Thread zuweisen. Hinter den Kulissen werden Aufgaben-Unteraufgaben-Beziehungen als baumartige Struktur implementiert, indem jedem virtuellen Thread ein Verweis auf seine eindeutige übergeordnete Aufgabe zugewiesen wird, ähnlich wie ein Frame in einem Aufrufstapel auf seinen eindeutigen Aufrufer verweist.

Kurz gesagt, virtuelle Threads stellen eine große Anzahl von Threads bereit. Strukturierte Parallelität koordiniert sie korrekt und robust und ermöglicht es Observability-Tools, Threads so anzuzeigen, wie der Entwickler sie versteht. Mit einer API für strukturierte Parallelität im JDK wird es einfacher, wartbare, zuverlässige und beobachtbare Serveranwendungen zu erstellen.

8 Beschreibung

Die Hauptklassen der strukturierten Parallelitäts-API sind java.util.concurrentim Paket enthalten StructuredTaskScope. Mit dieser Klasse können Entwickler eine Aufgabe als eine Reihe gleichzeitiger Unteraufgaben strukturieren und diese als Einheit koordinieren. Unteraufgaben werden in ihren eigenen Threads ausgeführt, indem sie separat gegabelt und als Einheit zusammengefügt werden, möglicherweise auch als Einheit aufgehoben werden. Erfolgsergebnisse oder Ausnahmen von Unteraufgaben werden aggregiert und von der übergeordneten Aufgabe verarbeitet. StructuredTaskScopeBeschränken Sie den Lebenszyklus von Unteraufgaben auf einen klaren lexikalischen Bereich, in dem alle Interaktionen einer Aufgabe mit ihren Unteraufgaben (Verzweigung, Verknüpfung, Abbruch, Fehlerbehandlung und Kombination von Ergebnissen) stattfinden.

Das oben genannte handle()Beispiel, StructuredTaskScopegeschrieben mit:

Response handle() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<String> user = scope.fork(() -> findUser());
        Supplier<Integer> order = scope.fork(() -> fetchOrder());

        scope.join()             // 加入两个子任务
             .throwIfFailed();   // ... 并传播错误

        // 两个子任务都成功完成,因此组合它们的结果
        return new Response(user.get(), order.get());
    }
}

Das Verständnis der Lebensdauer der beteiligten Threads ist hier einfacher als im ursprünglichen Beispiel: In allen Fällen ist ihre Lebensdauer auf einen einzigen lexikalischen Bereich beschränkt, den Codeblock der Anweisung try-with-resources. Darüber hinaus StructuredTaskScopegewährleistet die Verwendung einige wertvolle Eigenschaften:

  1. Fehlerbehandlung und Kurzschluss – wenn findUser()eine der beiden fetchOrder()Unteraufgaben fehlschlägt, wird die andere abgebrochen, sofern sie noch nicht abgeschlossen ist. (Dies wird ShutdownOnFailuredurch die von implementierte Shutdown-Strategie bestimmt; andere Strategien sind möglich).
  2. Abbruchweitergabe – Wenn handle()der laufende Thread join()vor oder während des Aufrufs von unterbrochen wird, bricht der Thread automatisch beide Unteraufgaben ab, wenn er den Gültigkeitsbereich verlässt.
  3. Klarheit – der obige Code hat eine klare Struktur: Unteraufgaben einrichten, warten, bis sie abgeschlossen sind oder abgebrochen werden, dann entscheiden, ob erfolgreich (und die Ergebnisse bereits abgeschlossener Unteraufgaben verarbeitet werden) oder fehlgeschlagen (Unteraufgaben wurden bereits abgeschlossen, es gibt also keine mehr zum Aufräumen).
  4. Beobachtbarkeit – Wie unten beschrieben, zeigen Thread-Dumps deutlich die Aufgabenhierarchie, in der die Threads ausgeführt werden findUser()und fetchOrder()als Unteraufgaben des Bereichs angezeigt werden.

9 Durchbrechen der Einschränkungen der Vorschauversion

StructuredTaskScope ist eine Vorschau-API und standardmäßig deaktiviert. Um die StructuredTaskScope-API verwenden zu können, muss die Vorschau-API aktiviert sein:

  1. Kompilieren Sie javac --release 21 --enable-preview Main.javadas Programm mit und java --enable-preview Mainführen Sie es mit ; ​​oder aus
  2. Wenn Sie den Quellstarter verwenden, führen Sie java --source 21 --enable-preview Main.javadas Programm mit aus
  3. Wenn IDEA ausgeführt wird, überprüfen Sie Folgendes:

10 Verwenden Sie StructuredTaskScope

10.1 API

public class StructuredTaskScope<T> implements AutoCloseable {

    public <U extends T> Subtask<U> fork(Callable<? extends U> task);
    public void shutdown();

    public StructuredTaskScope<T> join() throws InterruptedException;
    public StructuredTaskScope<T> joinUntil(Instant deadline)
        throws InterruptedException, TimeoutException;
    public void close();

    protected void handleComplete(Subtask<? extends T> handle);
    protected final void ensureOwnerAndJoined();

}

10.2 Arbeitsablauf

  1. Erstellen Sie einen Bereich. Der Thread, der den Bereich erstellt hat, ist sein Eigentümer.
  2. Verwenden Sie fork(Callable)die Methode, um Teilaufgaben im Bereich zu forken.
  3. Jede Unteraufgabe oder der Eigentümer des Bereichs kann jederzeit die shutdown()Methode des Bereichs aufrufen, um ausstehende Unteraufgaben abzubrechen und die Verzweigung neuer Unteraufgaben zu verhindern.
  4. Der Eigentümer des Bereichs schließt sich dem Bereich (d. h. allen Unteraufgaben) als Einheit an. Der Eigentümer kann die Methode des Bereichs aufrufen join()und darauf warten, dass alle Unteraufgaben abgeschlossen sind (ob erfolgreich oder nicht) oder shutdown()über abgebrochen werden. Alternativ kann die joinUntil(java.time.Instant)Methode des Bereichs aufgerufen und bis zum Ablauf der Frist gewartet werden.
  5. Behandeln Sie nach dem Beitritt etwaige Fehler in Teilaufgaben und verarbeiten Sie deren Ergebnisse.
  6. Schließen des Bereichs, normalerweise durch die implizite Verwendung von Try-with-Ressourcen. Dadurch wird der Bereich geschlossen (sofern er nicht bereits geschlossen wurde) und auf den Abschluss aller Unteraufgaben gewartet, die abgebrochen, aber noch nicht abgeschlossen wurden.

Jeder Aufruf fork(...)von startet einen neuen Thread zur Ausführung einer Unteraufgabe, bei der es sich standardmäßig um einen virtuellen Thread handelt. Eine Unteraufgabe kann eine eigene verschachtelte Aufgabe erstellen StructuredTaskScope, um ihre eigenen Unteraufgaben zu verzweigen und so eine Hierarchie zu schaffen. Diese Hierarchie spiegelt sich in der Blockstruktur des Codes wider und begrenzt die Lebensdauer von Unteraufgaben: Nach dem Schließen des Bereichs werden garantiert alle Unteraufgaben-Threads beendet, sodass beim Verlassen des Blocks keine Threads zurückbleiben.

shutdown()Jede Unteraufgabe innerhalb eines Bereichs, alle untergeordneten Unteraufgaben innerhalb verschachtelter Bereiche und der Eigentümer des Bereichs können jederzeit die Methode des Bereichs aufrufen, um zu signalisieren, dass die Aufgabe abgeschlossen ist, auch wenn andere Unteraufgaben noch ausgeführt werden. shutdown()Die Methode unterbricht Threads, die noch Unteraufgaben ausführen, und bewirkt, dass join()die Methode oder joinUntil(Instant)zurückkehrt. Daher sollten alle Unteraufgaben so programmiert werden, dass sie auf Interrupts reagieren. shutdown()Neue Unteraufgaben, die nach dem Aufruf geforkt werden, befinden sich im UNAVAILABLEStatus und werden nicht ausgeführt. Tatsächlich handelt es sich um eine gleichzeitige Simulation von Anweisungen shutdown()in sequentiellem Code .break

join()Der Aufruf oder innerhalb eines Bereichs joinUntil(Instant)ist obligatorisch. Wenn der Codeblock des Bereichs vor dem Beitritt beendet wird, wartet der Bereich auf die Beendigung aller Unteraufgaben, bevor er eine Ausnahme auslöst.

Der Eigentümerthread des Bereichs kann vor oder während des Beitritts unterbrochen werden. Beispielsweise könnte es sich um eine Unteraufgabe des umschließenden Bereichs handeln. Wenn dies geschieht, join()wird joinUntil(Instant)eine Ausnahme ausgelöst, da es keinen Sinn macht, die Ausführung fortzusetzen. Die try-with-resources-Anweisung schließt dann den Bereich, bricht alle Unteraufgaben ab und wartet auf deren Beendigung. Dies hat zur Folge, dass der Abbruch einer Aufgabe automatisch an ihre Unteraufgaben weitergegeben wird. Wenn joinUntil(Instant)die Frist für die Methode abläuft, bevor die Unteraufgabe beendet oder aufgerufen wird shutdown(), wird eine Ausnahme ausgelöst und die try-with-resources-Anweisung schließt den Bereich erneut.

Bei join()erfolgreichem Abschluss wurde jede Unteraufgabe erfolgreich abgeschlossen, ist fehlgeschlagen oder wurde abgebrochen, weil der Bereich geschlossen wurde.

Nach dem Beitritt verarbeitet der Eigentümer des Bereichs fehlgeschlagene Teilaufgaben und verarbeitet die Ergebnisse erfolgreich abgeschlossener Teilaufgaben; dies geschieht normalerweise durch eine Shutdown-Richtlinie (siehe unten). Mit der Methode können die Ergebnisse erfolgreich erledigter Aufgaben Subtask.get()gewonnen werden. get()Die Methode blockiert nie; sie wird ausgelöst, wenn sie versehentlich vor dem Beitritt aufgerufen wird oder wenn die Unteraufgabe nicht erfolgreich abgeschlossen wurde IllegalStateException.

Wenn Unteraufgaben einer verzweigten Aufgabe im Gültigkeitsbereich liegen, werden Bindungen vererbt ScopedValue(JEP 446). Wenn der Eigentümer des Bereichs einen Wert aus einer Grenze liest ScopedValue, liest jede Unteraufgabe denselben Wert.

Wenn der Eigentümer des Bereichs selbst eine Unteraufgabe eines vorhandenen Bereichs ist, d. h. als abgezweigte Unteraufgabe erstellt wurde, wird dieser Bereich zum übergeordneten Bereich des neuen Bereichs. Daher bilden Bereiche und Unteraufgaben eine Baumstruktur.

Erzwingt zur Laufzeit StructuredTaskScopestrukturelle und sequentielle Parallelität. Daher werden ExecutorServicedie oder- ExecutorSchnittstellen nicht implementiert, da Instanzen dieser Schnittstellen normalerweise unstrukturiert verwendet werden (siehe unten). Es ist jedoch unkompliziert, ExecutorServiceCode zu migrieren, der die Struktur nutzt und davon profitiert.StructuredTaskScope

In der Praxis wird die Klasse bei den meisten Verwendungen StructuredTaskScopewahrscheinlich nicht StructuredTaskScopedirekt verwendet, sondern stattdessen eine der beiden im nächsten Abschnitt beschriebenen Unterklassen verwendet, die eine Strategie zum Herunterfahren implementieren. In anderen Fällen schreiben Benutzer möglicherweise ihre eigenen Unterklassen, um benutzerdefinierte Strategien zum Herunterfahren zu implementieren.

11 Abschlussrichtlinie

Kurzschlüsse werden häufig verwendet, um unnötige Arbeit bei der Bearbeitung gleichzeitiger Teilaufgaben zu vermeiden. Wenn beispielsweise eine der Unteraufgaben fehlschlägt, werden manchmal alle Unteraufgaben abgebrochen (d. h. alle Aufgaben werden gleichzeitig aufgerufen), oder alle Unteraufgaben werden abgebrochen, wenn eine der Unteraufgaben erfolgreich ist (d. h. alle Aufgaben werden gleichzeitig aufgerufen). . StructuredTaskScopeZwei Unterklassen von , ShutdownOnFailureund ShutdownOnSuccessunterstützen diese Muster und bieten Strategien zum Schließen des Bereichs, wenn die erste Unteraufgabe fehlschlägt oder erfolgreich ist.

Eine Shutdown-Strategie bietet auch die Möglichkeit, die Behandlung von Ausnahmen und mögliche erfolgreiche Ergebnisse zu zentralisieren. Dies geschieht im Sinne der strukturierten Parallelität, bei der der gesamte Bereich als Einheit behandelt wird.

11.1 Fall

Das obige handle()Beispiel verwendet auch diese Strategie, die eine Reihe von Aufgaben gleichzeitig ausführt und fehlschlägt, wenn eine davon fehlschlägt:

<T> List<T> runAll(List<Callable<T>> tasks) 
        throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        List<? extends Supplier<T>> suppliers = tasks.stream().map(scope::fork).toList();
        scope.join()
             .throwIfFailed();  // 任何子任务失败,抛异常
        // 在这里,所有任务都已成功完成,因此组合结果
        return suppliers.stream().map(Supplier::get).toList();
    }
}

Geben Sie das Ergebnis zurück, nachdem die erste erfolgreiche Unteraufgabe zurückgegeben wurde:

<T> T race(List<Callable<T>> tasks, Instant deadline) 
        throws InterruptedException, ExecutionException, TimeoutException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) {
            scope.fork(task);
        }
        return scope.joinUntil(deadline)
                    .result();  // 如果没有任何子任务成功完成,抛出异常
    }
}

Sobald eine der Unteraufgaben erfolgreich ist, wird dieser Bereich automatisch geschlossen und die nicht abgeschlossenen Unteraufgaben werden abgebrochen. Eine Aufgabe schlägt fehl, wenn alle Unteraufgaben fehlschlagen oder wenn die angegebene Frist abgelaufen ist. Dieses Muster ist in Serveranwendungen nützlich, die Ergebnisse von einem beliebigen Satz redundanter Dienste abrufen müssen.

Während diese beiden Abschaltstrategien integriert sind, können Entwickler benutzerdefinierte Strategien erstellen, um andere Muster zu abstrahieren.

11.2 Ergebnisse verarbeiten

Nach zentralisierter Ausnahmebehandlung und Beitritt durch Schließrichtlinie (z. B. über ShutdownOnFailure::throwIfFailed) kann der Eigentümer des Bereichs das vom Aufruf fork(...)zurückgegebene [Subtask]-ObjektShutdownOnSuccess::result()

Normalerweise ruft der Bereichseigentümer einfach get()die Subtask-Methode der Methode auf. Alle anderen Subtask-Methoden werden normalerweise nur bei der Implementierung benutzerdefinierter Shutdown-Strategiemethoden verwendet handleComplete(...). Tatsächlich empfehlen wir, dass Variablen, die auf von fork(...)zurückgegebene Unteraufgaben verweisen, als Typ definiert werden, der Supplier<String>nicht vorhanden ist Subtask<String>(es sei denn, man entscheidet sich für die Verwendung von natürlich var). Wenn die Shutdown-Strategie selbst Subtask-Ergebnisse verarbeitet (wie im ShutdownOnSuccessFall von ), fork(...)sollte die Verwendung des von zurückgegebenen Subtask-Objekts vollständig vermieden und fork(...)die Methode als return behandelt werden void. Unteraufgaben sollten ihre Ergebnisse als Rückgabeergebnis haben, ebenso wie alle Informationen, die die Strategie nach der Behandlung der zentralen Ausnahme verarbeiten soll.

Wenn der Bereichseigentümer Subtask-Ausnahmen behandelt, um ein kombiniertes Ergebnis zu erzeugen, kann die Ausnahme anstelle einer Shutdown-Strategie als der von der Subtask zurückgegebene Wert zurückgegeben werden. FutureHier ist beispielsweise eine Methode, die eine Reihe von Aufgaben parallel ausführt und eine Abschlussliste mit den jeweiligen Erfolgs- oder Ausnahmeergebnissen jeder Aufgabe zurückgibt :

<T> List<Future<T>> executeAll(List<Callable<T>> tasks)
        throws InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
          List<? extends Supplier<Future<T>>> futures = tasks.stream()
              .map(task -> asFuture(task))
               .map(scope::fork)
               .toList();
          scope.join();
          return futures.stream().map(Supplier::get).toList();
    }
}

static <T> Callable<Future<T>> asFuture(Callable<T> task) {
   return () -> {
       try {
           return CompletableFuture.completedFuture(task.call());
       } catch (Exception ex) {
           return CompletableFuture.failedFuture(ex);
       }
   };
}

11.3 Benutzerdefinierte Shutdown-Strategie

StructuredTaskScopeKann erweitert werden und seine geschützten handleComplete(...)Methoden können überschrieben werden, um andere Strategien als ShutdownOnSuccessund zu implementieren. ShutdownOnFailureUnterklassen können zum Beispiel:

  • Sammeln Sie die Ergebnisse erfolgreich abgeschlossener Unteraufgaben und ignorieren Sie fehlgeschlagene Unteraufgaben.
  • Sammeln Sie die Ausnahme, wenn die Unteraufgabe fehlschlägt, oder
  • shutdown()Rufen Sie die Methode zum Herunterfahren auf und veranlassen Sie, dass die Methode aufwacht , wenn eine bestimmte Bedingung auftritt join().

Wenn eine Unteraufgabe abgeschlossen ist, shutdown()wird dies der Methode auch nach dem Aufruf wie folgt Subtaskgemeldet :handleComplete(...)

public sealed interface Subtask<T> extends Supplier<T> {
    enum State { SUCCESS, FAILED, UNAVAILABLE }

    State state();
    Callable<? extends T> task();
    T get();
    Throwable exception();
}

Die Methode wird aufgerufen , wenn die Unteraufgabe im SUCCESSStatus oder Status FAILEDabgeschlossen wird . Die Methode kann aufgerufen werden handleComplete(...), wenn sich die Unteraufgabe im Status befindet , und die Methode kann aufgerufen werden , wenn sich die Unteraufgabe im Status befindet . Der Aufruf oder ein anderer Aufruf löst eine Ausnahme aus. Ein Zustand stellt einen der folgenden Fälle dar: (1) Die Unteraufgabe wurde gegabelt, aber noch nicht abgeschlossen; (2) Die Unteraufgabe wurde abgeschlossen, nachdem sie geschlossen wurde, oder (3) Die Unteraufgabe wurde gegabelt, nachdem sie geschlossen wurde, und wurde daher noch nicht gestartet . Die Methode wird niemals für Unteraufgaben im Status aufgerufen.SUCCESSget()FAILEDexception()get()exception()IllegalStateExceptionUNAVAILABLEhandleComplete(...)UNAVAILABLE

Unterklassen definieren normalerweise Methoden, um Ergebnisse, Status oder andere Ergebnisse join()nach der Rückkehr der Methode für nachfolgenden Code verfügbar zu machen. Unterklassen, die Ergebnisse sammeln und fehlgeschlagene Unteraufgaben ignorieren, können eine Methode definieren, die ein Array von Ergebnissen zurückgibt. Unterklassen, die eine Richtlinie zum Herunterfahren bei fehlgeschlagenen Unteraufgaben implementieren, können eine Methode definieren, um eine Ausnahme für die erste fehlgeschlagene Unteraufgabe zu erhalten.

StructuredTaskScopeEine Unterklasse, die erweitert wird

Diese Unterklasse sammelt die Ergebnisse erfolgreich abgeschlossener Unteraufgaben. Es definiert results()die Methode, die die Hauptaufgabe zum Abrufen von Ergebnissen verwendet.

class MyScope<T> extends StructuredTaskScope<T> {

    private final Queue<T> results = new ConcurrentLinkedQueue<>();

    MyScope() { super(null, Thread.ofVirtual().factory()); }

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS)
            results.add(subtask.get());
    }

    @Override
    public MyScope<T> join() throws InterruptedException {
        super.join();
        return this;
    }

    // 返回从成功完成的子任务获取的结果流
    public Stream<T> results() {
        super.ensureOwnerAndJoined();
        return results.stream();
    }

}

Diese benutzerdefinierte Richtlinie kann wie folgt verwendet werden:

<T> List<T> allSuccessful(List<Callable<T>> tasks) throws InterruptedException {
    try (var scope = new MyScope<T>()) {
        for (var task : tasks) scope.fork(task);
        return scope.join()
                    .results().toList();
    }
}

Fächer in Szene setzen

Die obigen Beispiele konzentrieren sich auf Fanout-Szenarien, die mehrere gleichzeitige ausgehende E/A-Vorgänge verwalten. StructuredTaskScopeAuch nützlich in Fan-In-Szenarien, die mehrere gleichzeitige eingehende E/A-Vorgänge verwalten. In diesem Fall erstellen wir typischerweise dynamisch eine unbekannte Anzahl von Unteraufgaben als Reaktion auf eingehende Anfragen.

Hier ist ein Beispiel für einen Server, der StructuredTaskScopeTeilaufgaben zur Verarbeitung eingehender Verbindungen aufteilt:

void serve(ServerSocket serverSocket) throws IOException, InterruptedException {
    try (var scope = new StructuredTaskScope<Void>()) {
        try {
            while (true) {
                var socket = serverSocket.accept();
                scope.fork(() -> handle(socket));
            }
        } finally {
            // 如果发生错误或被中断,我们停止接受连接
            scope.shutdown();  // 关闭所有活动连接
            scope.join();
        }
    }
}

Aus Sicht der Parallelität unterscheidet sich diese Situation von der Richtung der Anfrage, aber auch von der Dauer und der Anzahl der Aufgaben, da Teilaufgaben basierend auf externen Ereignissen dynamisch geforkt werden.

Alle Unteraufgaben, die Verbindungen verarbeiten, werden im Gültigkeitsbereich erstellt, sodass sie in einem Thread-Dump im untergeordneten Thread eines Besitzers mit Gültigkeitsbereich leicht zu sehen sind. Der Eigentümer des Bereichs kann auch leicht als Einheit behandelt werden, um den gesamten Dienst herunterzufahren.

Beobachtbarkeit

Wir haben das durch JEP 444 hinzugefügte neue JSON-Thread-Dump-Format erweitert, um die StructuredTaskScopeGruppierung von Threads in Hierarchien anzuzeigen:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

Das JSON-Objekt jedes Bereichs enthält ein Array von Threads, die im Bereich gegabelt wurden, zusammen mit ihren Stack-Traces. Der Besitzer-Thread des Bereichs wäre normalerweise join()in der Methode blockiert und wartet auf den Abschluss der Unteraufgabe; Thread-Dumps machen es einfach, zu sehen, was die Threads der Unteraufgabe tun, indem sie die durch strukturierte Parallelität auferlegte Baumhierarchie anzeigen. Ein bereichsbezogenes JSON-Objekt verfügt auch über einen Verweis auf sein übergeordnetes Objekt, sodass die Struktur des Programms aus dem Dump rekonstruiert werden kann.

com.sun.management.HotSpotDiagnosticsMXBeanDie API kann auch zum Generieren eines solchen Thread-Dumps verwendet werden, der direkt oder indirekt über den MBeanServer der Plattform und lokale oder Remote-JMX-Tools genutzt werden kann.

Warum fork(...)gibt es keine Rückkehr Future?

Die Methode wurde zurückgegeben, während StructuredTaskScopedie API inkubierte . Dadurch ähnelt es eher der bestehenden Methode und vermittelt so ein Gefühl der Vertrautheit. Da die Verwendung jedoch in einer völlig anderen Art und Weise erfolgt als in der oben beschriebenen strukturierten Art und Weise, führt die Verwendung weitaus mehr zu Verwirrung als zu Klarheit.fork(...)Futurefork(...)ExecutorService::submitStructuredTaskScopeExecutorServiceFuture

Die bekannte FutureVerwendung von beinhaltet den Aufruf seiner get()Methode, die blockiert, bis das Ergebnis verfügbar ist. Aber im StructuredTaskScopeKontext von wird von einer solchen Verwendung Futurenicht nur abgeraten, sondern sie ist auch unpraktisch. Structured FutureObjekte sollten erst join()abgefragt werden, nachdem sie zurückgegeben wurden. Zu diesem Zeitpunkt ist bekannt, dass sie abgeschlossen oder abgebrochen sind, und es sollten Methoden verwendet werden, die nicht bekannt sind get(), aber neu eingeführt wurden resultNow()und niemals blockieren.

Einige Entwickler fragten sich, warum fork(...)kein leistungsfähigeres CompletableFutureObjekt zurückgegeben wurde. fork(...)Da die zurückgegebenen Exemplare nur verwendet werden sollten, wenn bekannt ist, dass sie abgeschlossen sind Future, CompletableFuturebietet dies keinen Vorteil, da die erweiterten Funktionen nur für ausstehende Futures nützlich sind. Außerdem CompletableFutureist es für ein asynchrones Programmierparadigma konzipiert, während StructuredTaskScopeein Blockierungsparadigma empfohlen wird.

Zusammenfassend lässt sich sagen, Futuredass sie CompletableFuturedarauf ausgelegt sind, Freiheitsgrade bereitzustellen, die bei der strukturierten Parallelität nachteilig sind.

Strukturierte Parallelität ist die Behandlung mehrerer Aufgaben, die in verschiedenen Threads ausgeführt werden, als eine einzige Arbeitseinheit und ist Futurevor allem dann nützlich, wenn mehrere Aufgaben als separate Aufgaben behandelt werden. Daher sollte ein Bereich nur einmal blockieren, während er auf die Ergebnisse seiner Unteraufgaben wartet, und sich dann auf die Ausnahmebehandlung konzentrieren. Daher ist in den allermeisten Fällen die einzige Methode, die fork(...)vom zurückgegebenen auf aufgerufen werden sollte . Dies ist eine deutliche Änderung gegenüber der normalen Verwendung von und die Methode verhält sich genauso wie während der API-Inkubation .FutureresultNow()FutureSubtask::get()Future::resultNow()

alternativer Plan

Erweiterte ExecutorServiceSchnittstelle. Wir haben einen Prototyp einer Implementierung dieser Schnittstelle erstellt, die stets eine Strukturierung erzwingt und einschränkt, welche Threads Aufgaben senden können. Wir haben jedoch festgestellt, dass dies für die meisten Anwendungsfälle im JDK und Ökosystem unstrukturiert ist. Die Wiederverwendung derselben API in völlig unterschiedlichen Konzepten kann zu Verwirrung führen. Wenn Sie beispielsweise eine Strukturinstanz ExecutorServicean eine vorhandene Methode übergeben, die diesen Typ akzeptiert, wird in den meisten Fällen mit ziemlicher Sicherheit eine Ausnahme ausgelöst.

Dieser Artikel wurde von OpenWrite veröffentlicht, einer Multi-Post-Plattform zum Bloggen !

Guess you like

Origin blog.csdn.net/qq_33589510/article/details/132431158