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.
| Aspekt | Unstrukturiert (ExecutorService gemeinsamer Pool) | Strukturiert (StructuredTaskScope) |
|---|---|---|
| Lebensdauer der Teilaufgabe | Unabhängig vom Aufrufer | Begrenzt durch den umschließenden Block |
| Fehler in einer Teilaufgabe | Versteckt in einem Future bis zum Aufruf von get | Kann den gesamten Scope kurzschließen |
| Abbruch | Manuell, leicht zu vergessen | Automatisch bei Fehler oder Interrupt |
| Ressourcenbereinigung | Liegt bei Ihnen | close() 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 leaveWenn 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.
| Richtlinie | Endet wenn | Verwenden Sie sie für |
|---|---|---|
ShutdownOnFailure | Alle erfolgreich, oder eine schlägt fehl | Fan-out, wenn Sie jedes Ergebnis benötigen (der häufige Fall) |
ShutdownOnSuccess<T> | Erste Erfolgsmeldung, oder alle schlagen fehl | Redundante 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.
Was aus der Ausführung zu entnehmen ist:
- Beide Teilaufgaben meldeten
is virtual : true— jedessubmitlief auf seinem eigenen Virtual Thread, demselben leichtgewichtigen Träger, denStructuredTaskScope.forkverwendet, sodass das Erstellen eines Threads pro Teilaufgabe günstig ist. - Der Happy-Path-Block gab
ran concurrently (<320ms): trueaus, 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, dieStructuredTaskScopekonstruktionsbedingt erzwingt. - Im Fehlerabschnitt gab das Programm
caught: IllegalStateException -> upstream said noaus: ein innerhalb einer Teilaufgabe geworfener Fehler erscheint am Join-Punkt verpackt inExecutionException, undgetCause()gibt Ihnen die ursprüngliche Ausnahme zurück. - Nach dem Abfangen des Fehlers wurde
sibling cancelled: trueausgegeben — wir haben den noch laufendengood-Zweig abgebrochen, damit kein Waisenkind den Block überlebt, was genau das ist, wasShutdownOnFailureautomatisch für Sie erledigt; hier haben wir es manuell getan, um den Mechanismus zu zeigen.
Verwandte Themen
- Virtual Threads — die leichtgewichtigen Threads, auf denen jede Teilaufgabe läuft.
- Moderne Virtual Threads — praktische Muster und Fallstricke.
- Executor-Framework — die unstrukturierte Grundlage, die dieses Modell ersetzt.
CallableundFuture— die Aufgaben- und Ergebnistypen, die am Join-Punkt verwendet werden.CompletableFuture— asynchrone Ergebnisse komponieren ohne blockierende Joins.