Im Grunde sind Scoped Values eine Form von impliziten Funktionsparametern, die es ermöglichen, einen Parameter an mehrere entfernte Methoden zu übergeben, ohne sie als explizite Funktionsparameter zu jeder Methode in der Aufrufkette hinzufügen zu müssen
Scoped Values stellen also im Grunde eine moderne Alternative zu ThreadLocal-Variablen dar. Wenn mehrere Threads dasselbe Scoped Value-Feld verwenden, kann es aus der Sicht der einzelnen Threads jeweils einen anderen Wert annehmen. Sie besitzen keine set-Methode, um die Daten zu ändern. Das ist Absicht, da die Unveränderbarkeit der Daten den Code weniger komplex, einfacher wartbar und vor allem frei von unerwünschten Seiteneffekten macht.
Scoped Values werden normalerweise als public static definiert, so dass sie von jeder Methode aufgerufen werden können. Gute Beispiele hierfür sind die Variablen User, Permission oder Environment, die man von einer Methode in die nächste übergibt und die die Parameterliste aufblähen. Auch im Sinne von Clean Code sind Scoped Values eine sinnvolle Vereinfachung, die die Lesbarkeit des Codes deutlich erhöht.
Ein Beispiel: Wir benötigen einen User. Wir wollen dieses Objekt aber nicht durch alle Methoden durchreichen, aber doch dort abrufen, wo es benötigt wird. In dem Falle würde man den User wie folgt definieren und dann in einer Methode später aufrufen.
class Service {
public final static ScopedValue<User> USER = ScopedValue.newInstance();
private void do(Request request) {
ScopedValue.where(USER, request.getUser()).run(() ->
otherService.process(request));
// ...
}
...
}
Die where(ScopedValue, Object, Runnable)-Methode legt den Wert eines ScopedValue für den begrenzten Zeitraum der Ausführung durch einen Thread der Run-Methode des Runnable fest.
Vorteile von Scoped Values gegenüber ThreadLocals
Sie sind nur während der Lebensdauer des an die run-Methode übergebenen Runnables gültig und werden unmittelbar danach freigegeben (sofern keine weiteren Referenzen auf sie existieren). Ein ThreadLocal hingegen bleibt im Speicher, bis entweder der Thread beendet oder er explizit mit remove() gelöscht wird. Viele Entwickler vergessen dies (oder unterlassen es, da das Programm zu komplex ist und es nicht offensichtlich genug ist, wann der ThreadLocal nicht mehr benötigt wird). Eine Folge davon können Memoryleaks sein.
Ein Scoped Value ist unveränderlich - er kann nur durch erneutes Binden für einen neuen Scope zurückgesetzt werden. Dies verbessert die Verständlichkeit und Wartbarkeit des Codes erheblich im Vergleich zu ThreadLocals, die jederzeit mit set() geändert werden können.
Die von StructuredTaskScope (s. u.) erzeugten Child-Threads haben Zugriff auf den Scoped Value des Parent-Threads. Verwendet man dagegen InheritableThreadLocal, so wird dessen Wert in jeden Child-Thread kopiert. Dies kann den Speicherbedarf erheblich erhöhen.
Wie ThreadLocals sind auch Scoped Values für Plattform- und virtuelle Threads nutzbar. Bei vielen virtuellen Child-Threads kann es zu einer hohen Speicherersparnis durch den Zugriff auf den ScopedValue des Parent-Threads kommen.
Die Child-Threads die durch StructuredTaskScope.fork() erzeugt werden, bekommen den ScopedValue des Parent-Threads automatisch vererbt.
Was sind eigentlich StructuredTaskScopes?
Dazu müsste man erst fragen, was sind eigentlich unstrukturierte TaskScopes? Wenn Tasks unstrukturiert und gleichzeitig laufen, heißt das, dass unsere Aufgaben in einem Netz von verwickelten Threads ablaufen, deren Anfang und Ende im Code schwer zu erkennen ist. Eine durchgängige Fehlerbehandlung ist in der Regel nicht vorhanden, und verwaiste Threads bleiben häufig vergessen zurück.
Die StructuredTaskScopes sollen nun genau hier Ordnung und gute Lesbarkeit erreichen. Hierzu ein Beispiel:
try (var scope = new StructuredTaskScope<Object>()) {
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
scope.join();
// ... process results/exceptions ...
}
Im obigen Beispiel haben wir mit StructuredTaskScope.join() auf den Abschluss aller Aufgaben gewartet. Wenn aber in einer der Aufgaben eine Exception auftritt, können wir mit den Ergebnissen der anderen beiden Aufgaben nichts anfangen - warum also auf sie warten?
Policies
Hier kommen die sogenannten Policies ins Spiel, mit denen wir unter anderem festlegen können, wann ein Scope beendet ist. StructuredTaskScope definiert zwei solcher Policies:
Mit der ShutdownOnFailure-Policy können wir festlegen, dass das Auftreten einer Exception immer zum Abbruch alle Aufgaben führt.
try (var scope = new StructuredTaskScope.ShutdownOnFailue<Object>()) {
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
scope.join();
scope.throwIfFailed();
// ... process results/exceptions ...
}
Der Code musste nur an 2 Stellen angepasst werden, um die ShutdownOnFailue-Policy zu implementieren.
Mit der ShutdownOnSuccess-Policy wird der Scope beendet, sobald einer der Tasks erfolgreich war. Die anderen Tasks werden beendet, und die Methode scope.result() gibt das Ergebnis der erfolgreichen Teilaufgabe zurück. Ein Beispiel hierfür wäre, wenn man sich gegenüber verschiedenen Stellen authentifizieren kann; aber nach dem ersten erfolgreichen Anmelden sind alle anderen Anfragen obsolet.
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Object>()) {
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
scope.join();
// ... process results/exceptions ...
}
Der Code musste nur einmal angepasst werden, um die ShutdownOnSuccess-Policy zu implementieren. Wenn wider Erwarten alle Tasks eine Exception werfen, wird scope.result() den ersten von ihnen erneut auslösen, eingebettet in eine ExecutionException.
Wir können davon ausgehen, dass bei den nächsten Java-Varianten neue Policies dazukommen. Allerdings gibt es schon jetzt die Möglichkeit eigene Policies zu implementieren.
Fazit
Wir haben mit den beiden neuen Klassen ein gutes Werkzeug bekommen, um den Code les-, wartbarer und Seiteneffektfrei zu gestalten. Anwenden muss man es allerdings selbst.