W3docs

Java Structured Concurrency

Concurrent-Subtasks als Arbeitseinheit in Java mit Structured Concurrency (StructuredTaskScope) behandeln.

Structured Concurrency behandelt eine Gruppe von nebenläufigen Teilaufgaben als eine einzelne Arbeitseinheit: Sie werden gemeinsam gestartet, gemeinsam beendet, und wenn eine fehlschlägt oder der Aufrufer abgebrochen wird, werden die übrigen ebenfalls abgebrochen — keine verwaisten Threads, die länger leben als der Block, der sie gestartet hat. Das Modell wird durch java.util.concurrent.StructuredTaskScope bereitgestellt (eine Preview-API, die in Java 21 eingeführt wurde) und basiert auf denselben Virtual Threads, die weiter vorne in diesem Teil behandelt werden. Das Ziel ist einfach: nebenläufigen Code so leicht lesbar, debuggbar und nachvollziehbar zu machen wie eine einfache sequenzielle Methode.

Dieses Kapitel erklärt, warum „structured" wichtig ist, den Aufbau eines Task-Scopes, die zwei eingebauten Shutdown-Richtlinien, wie Deadlines und Abbrüche weitergegeben werden, und ein ausführbares Arbeitsbeispiel. Es setzt voraus, dass Sie mit dem Executor-Framework und Callable/Future vertraut sind.

Warum „structured"?

Klassische Thread-Pools sind unstrukturiert: Sie submitten eine Aufgabe an einen gemeinsam genutzten ExecutorService und erhalten ein Future zurück, dessen Lebensdauer nichts mit der Methode zu tun hat, die es erstellt hat. Eine Aufgabe kann ihren Aufrufer überleben, ein Fehler in einer Aufgabe ist für ihre Geschwister unsichtbar, und Abbrüche müssen manuell verdrahtet werden. Das Ergebnis sind verlorene Threads und verschachteltes Fehler-Handling.

Structured Concurrency übernimmt die Disziplin des strukturierten Kontrollflusses: So wie ein try-Block seine Anweisungen abgrenzt, begrenzt ein Task-Scope seine Teilaufgaben. Innerhalb eines Blocks verzweigte Teilaufgaben müssen alle abgeschlossen sein, bevor der Block endet. Lebenszeiten verschachteln sich sauber, sodass ein Thread-Dump und ein Stack-Trace tatsächlich zeigen, wer was gestartet hat.

AspektUnstrukturiert (ExecutorService gemeinsamer Pool)Strukturiert (StructuredTaskScope)
Lebensdauer der TeilaufgabeUnabhängig vom AufruferBegrenzt durch den umschließenden Block
Fehler in einer TeilaufgabeVersteckt in einem Future bis zum Aufruf von getKann den gesamten Scope kurzschließen
AbbruchManuell, leicht zu vergessenAutomatisch bei Fehler oder Interrupt
RessourcenbereinigungLiegt bei Ihnenclose() wartet auf jede Teilaufgabe

Der Aufbau eines Scopes

Ein Scope ist ein AutoCloseable und lebt daher in einem try-with-resources-Block. Sie forken Teilaufgaben (jede gibt ein Subtask-Handle zurück), rufen join() auf, um auf sie zu warten, und lesen dann jedes Ergebnis. Die ShutdownOnFailure-Richtlinie bricht verbleibende Teilaufgaben ab, sobald eine von ihnen eine Ausnahme wirft:

import java.util.concurrent.StructuredTaskScope;

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    StructuredTaskScope.Subtask<String> user  = scope.fork(() -> fetchUser(id));
    StructuredTaskScope.Subtask<Integer> order = scope.fork(() -> fetchOrderCount(id));

    scope.join();            // wait for both branches
    scope.throwIfFailed();   // rethrow if either branch failed

    return new Profile(user.get(), order.get());
}   // close() guarantees both subtasks have ended before we leave

Wenn fetchUser eine Ausnahme wirft, unterbricht ShutdownOnFailure das noch laufende fetchOrderCount, join() kehrt zurück, und throwIfFailed() wirft die ursprüngliche Ursache verpackt in einer ExecutionException erneut. Es geht kein Thread verloren.

Eingebaute Shutdown-Richtlinien

Die zwei mitgelieferten Richtlinien decken die gängigen Muster ab; für alles andere leiten Sie von StructuredTaskScope ab.

RichtlinieEndet wennVerwenden Sie sie für
ShutdownOnFailureAlle erfolgreich, oder eine schlägt fehlFan-out, wenn Sie jedes Ergebnis benötigen (der häufige Fall)
ShutdownOnSuccess<T>Erste Erfolgsmeldung, oder alle schlagen fehlRedundante Quellen gegeneinander antreten lassen; die schnellste Antwort nehmen

ShutdownOnSuccess gibt den Gewinner über result() zurück und bricht die Verlierer ab:

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> queryMirrorA());
    scope.fork(() -> queryMirrorB());
    scope.join();
    return scope.result();   // the first one to return; the slower is cancelled
}

Deadlines und Abbrüche werden weitergegeben

Ein Scope kann mit einer Deadline gewartet werden; wenn diese abläuft, werden unfertige Teilaufgaben abgebrochen:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> slowService());
    scope.joinUntil(Instant.now().plusSeconds(2));  // throws TimeoutException if late
    scope.throwIfFailed();
}

Der Abbruch ist kooperativ und fließt nach unten: Wenn der Thread, dem der Scope gehört, unterbrochen wird, werden alle Teilaufgaben der Reihe nach unterbrochen. Da jede Teilaufgabe auf ihrem eigenen Virtual Thread läuft, ist das Erstellen von Tausenden von ihnen günstig — der Scope, nicht eine feste Poolgröße, ist die Einheit, über die Sie nachdenken.

Ein Arbeitsbeispiel: Fan-out, Fehler und das Joinen einer Liste

StructuredTaskScope ist ein Preview-Feature. Um dieses Beispiel auf einem stabilen JDK ausführbar zu halten, modellieren wir dieselbe Idee mit einem Virtual-Thread-per-task-Executor: ein try-with-resources-Block, der eine Gruppe von Teilaufgaben abgrenzt und erst endet, wenn jeder Teilaufgaben-Thread beendet ist. Er fächert zwei Aufrufe gleichzeitig auf, zeigt dann, wie ein Fehler die Arbeitseinheit kurzschließt, und wie invokeAll eine ganze Liste auf einmal joinen kann.

java— editable, runs on the server

Was aus der Ausführung zu entnehmen ist:

  • Beide Teilaufgaben meldeten is virtual : true — jedes submit lief auf seinem eigenen Virtual Thread, demselben leichtgewichtigen Träger, den StructuredTaskScope.fork verwendet, sodass das Erstellen eines Threads pro Teilaufgabe günstig ist.
  • Der Happy-Path-Block gab ran concurrently (<320ms): true aus, obwohl die beiden Fetches 120ms und 200ms schlafen: Sie überlappten sich, sodass die Wandzeit dem langsamsten Zweig (~200ms) folgt, nicht der Summe (320ms). Diese Überlappung ist der eigentliche Zweck des Fan-outs.
  • Das Verlassen des try-with-resources-Blocks rief close() auf, was blockierte, bis jeder Teilaufgaben-Thread beendet war — der Scope ist die Lebenszeit-Einheit, genau die Disziplin, die StructuredTaskScope konstruktionsbedingt erzwingt.
  • Im Fehlerabschnitt gab das Programm caught: IllegalStateException -> upstream said no aus: ein innerhalb einer Teilaufgabe geworfener Fehler erscheint am Join-Punkt verpackt in ExecutionException, und getCause() gibt Ihnen die ursprüngliche Ausnahme zurück.
  • Nach dem Abfangen des Fehlers wurde sibling cancelled: true ausgegeben — wir haben den noch laufenden good-Zweig abgebrochen, damit kein Waisenkind den Block überlebt, was genau das ist, was ShutdownOnFailure automatisch für Sie erledigt; hier haben wir es manuell getan, um den Mechanismus zu zeigen.

Verwandte Themen

Übungen

Übung
Was passiert mit den anderen geforkte Teilaufgaben bei StructuredTaskScope.ShutdownOnFailure, wenn eine von ihnen eine Ausnahme wirft?
Was passiert mit den anderen geforkte Teilaufgaben bei StructuredTaskScope.ShutdownOnFailure, wenn eine von ihnen eine Ausnahme wirft?
Was this page helpful?