Blog
Strukturierte Gleichzeitigkeit: Wird Java Loom die Coroutines von Kotlin schlagen?


Kotlin und Java Loom: strukturierte Gleichzeitigkeit für die breite Masse
Java, das neue Konzepte nur langsam annimmt, erhält im Rahmen des Loom-Projekts strukturierte Gleichzeitigkeit. Diese Ergänzung ermöglicht die native Unterstützung von Coroutines (auch virtuelle Threads genannt) in Java. Was bedeutet das für Java- und Kotlin-Entwickler? Sollten wir jetzt alle unsere bestehenden Codebasen umwandeln, um virtuelle Threads zu unterstützen? Oder sogar zurück zu Java wechseln? Wir vergleichen zwei Ansätze für die strukturierte Gleichzeitigkeit auf der JVM: Die virtuellen Threads von Java Loom und die Coroutines von Kotlin. Es stellt sich heraus, dass die Coroutines von Kotlin entwicklerfreundlicher sind, während Java Loom seine Stärken vor allem in Java-Bibliotheken und -Frameworks hat. Möchten Sie wissen, warum? Dann schnallen Sie sich an, denn wir haben eine Menge zu besprechen!
Hintergrund
Mein Kollege und Kotlin-Spezialist Urs Peter schlug vor, sich Loom anzuschauen und es mit Kotlin-Coroutinen zu vergleichen. Das haben wir an einem unserer (großartigen, wie ich hinzufügen möchte) Innovationstage getan.
Dort wurde uns klar, dass Java nicht nur eine Programmiersprache ist, sondern auch eine Plattform. Eigentlich könnte man es auch andersherum sehen. Die Java-Programmiersprache ist die Referenzimplementierung für die Java-Plattform. Das sind bahnbrechende Neuigkeiten, denn dieses Konzept gibt Ihnen die Möglichkeit, Java auf zwei verschiedene Arten zu hassen...
Haha, nur ein Scherz. Aber Spaß beiseite, diese gespaltene Sicht auf Java macht Loom zu einem der einflussreichsten Java-Projekte. Aber auch eines der stillsten. Loom erweitert die JVM um strukturierte Gleichzeitigkeit mit nur geringen Änderungen an der Programmiersprache. Was ist Loom also genau und was hat es mit Kotlin zu tun?


Die einfachen Dinge zuerst: Kotlin
Kotlin ist das neue Kind auf dem Block und bietet Zugang zu einem sehr vielversprechenden Konzept: strukturierte Gleichzeitigkeit, in Kotlin als Coroutines bezeichnet. Coroutines erleichtern den Umgang mit Gleichzeitigkeit. Dies ist ein großes Plus bei der reaktiven Programmierung und der Entwicklung von Hochleistungs(web)-Anwendungen. Und sie verbessern die Erfahrung der Entwickler erheblich. Das Konzept der Coroutines ist nicht neu, Sie finden es in anderen Sprachen und Bibliotheken wieder, z.B. in Fibres(cats-effect, ZIO und sogar boost?!?), Goroutines in Go oder Nurses in Python Trio.
Im Allgemeinen ist eine Coroutine ein (sehr sehr) leichter Thread. Sie wird auf die (Träger-)Threads eines Betriebssystems M:N abgebildet. Der Begriff M:N bedeutet, dass M Coroutines auf N Threads abgebildet werden. Für einen Entwickler geschieht dies auf transparente Weise. Sie haben nur mit Coroutines zu tun, die Zuordnung und Verteilung wird von der Sprache oder Bibliothek vorgenommen.
Und was ist mit Java? Webstuhl!
Loom führt Coroutines, so genannte virtuelle Threads, als natives Element in die JVM ein. Das Loom-Entwicklungsteam hat sich entschieden, nicht von der bestehenden Syntax abzuweichen. Die Thread-API bleibt mehr oder weniger die gleiche. Der große Unterschied zu Kotlin besteht darin, dass die virtuellen Threads von Loom von der JVM verwaltet und geplant werden und nicht vom Betriebssystem. Sie überspringen die Umleitung über die traditionelle JVM-Thread-Abstraktion. Die direkte Interaktion mit Betriebssystem-Threads verschafft Java (im Prinzip) einen Leistungsvorteil gegenüber Kotlin (
Kotlin & Loom: die Perspektive eines Entwicklers
Was kann ich bei strukturierter Gleichzeitigkeit erwarten? Es ist wahrscheinlich besser zu fragen: Was sollte ich von der Gleichzeitigkeit ohne Struktur erwarten? Nathaniel J. Smith beschrieb die aktuelle Situation als eine Form der goto Anweisung. Sie feuern n gleichzeitige Aufgaben und müssen sich nun um n Probleme kümmern. Diese können Nebeneffekte haben. Das hat zur Folge, dass es schwierig ist, sie zu verfolgen, zu abstrahieren und zu kapseln. Strukturierte Gleichzeitigkeit ist im Grunde eine Form der Verhinderung von "Feuer-und-Messer-Situationen". Ähnlich wie das "Verbot" der Anweisung goto. Dieser Ansatz zur Gleichzeitigkeit spiegelt eines meiner Grundprinzipien wider:
Global organization, local chaos.
Wenn Sie einen Punkt erreichen, an dem Gleichzeitigkeit ins Spiel kommt, fächern Sie sich auf, erledigen einige gleichzeitige Aufgaben und fächern sich wieder auf. Für den Anfang habe ich eine erste Reihe von Beispielen in Java und Kotlin erstellt, die Folgendes ausgeben
Hello world 2
Hello world 1
Hallo Welt: Kotlin-Stil
In Kotlin ist es ganz einfach: der Coroutine-Bereich blockiert, bis alle gleichzeitigen Aufgaben (hier die "Hello world 1") beendet sind.
coroutineScope { // <---- only needed on top-level
launch {
delay(500)
println("Hello world 1")
}
println("Hello world 2")
}
Die Funktion delay ermöglicht es Kotlin, diese Coroutine zu parken (oder anzuhalten). An der Funktionssignatur können Sie leicht erkennen, ob Sie eine Funktion suspend können.
public suspend fun delay(timeMillis: kotlin.Long): kotlin.Unit { /* code */ }
In short, when using the suspend keyword, the Kotlin compiler generates a finite state machine in the bytecode. The benefit is that functions called in a coroutine block look like they are executed sequentially, though they are executed in parallel. In a way, coroutines are a purely syntactic construct. — On Project Loom, the Reactive model and coroutines by Nicolas Fränkel
Hallo Welt: Webstühle nehmen
Auch Loom bietet diese Möglichkeit (ich habe den Code etwas gequetscht, um eine 1:1-Übereinstimmung mit dem Kotlin-Beispiel zu erreichen). Der "try-with-resources"-Block wartet, bis alles fertig ist. Praktisch.
try (ExecutorService executor = Executors.newVirtualThreadExecutor()) {
executor.submit(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) { }
System.out.println("Hello world 1");
});
System.out.println("Hello world 2");
}
Was können wir schließen, wenn wir das Kotlin- und das Java-Beispiel vergleichen? Überraschenderweise sehen sie sich ähnlicher als erwartet. Offensichtlich ist der syntaktische Zucker, den Kotlin hinzufügt, aber mit Java können Sie den gewünschten Effekt in mehr oder weniger der gleichen Anzahl von Zeilen erreichen.
Es ist problemlos möglich, das Snippet um weitere (gleichzeitige) Aufgaben zu erweitern:
var deadline = Instant.now().plusSeconds(2);
try (ExecutorService executor1 = Executors
.newVirtualThreadExecutor()
.withDeadline(deadline)) {
List<Callable<String>> tasks = List.of(
() -> "task list, elem 1",
() -> "task list, elem 2"
);
Stream<Future<String>> x = executor1.submit(tasks);
x.map(Future::join).forEach(System.out::println);
}
Strukturierte Gleichzeitigkeit auf die nächste Stufe bringen
Wenn Sie nichts Exotisches tun, spielt es für die Leistung keine Rolle, ob Sie alle Aufgaben mit einem oder mit zwei Executors ausführen. Das Konstrukt try-with-resources ermöglicht es, "Struktur in Ihre Gleichzeitigkeit" zu bringen. Wenn Sie es noch exotischer haben wollen, bietet Loom die Möglichkeit, virtuelle Threads auf einen Pool von Träger-Threads zu beschränken. Diese Funktion kann jedoch zu unerwarteten Konsequenzen führen, wie in Going inside Java's Project Loom and virtual threads beschrieben.
var deadline = Instant.now().plusSeconds(2);
try (ExecutorService executor1 = Executors
.newVirtualThreadExecutor()
.withDeadline(deadline)) {
try (ExecutorService executor2a = Executors.newVirtualThreadExecutor()) {
executor2a.submit(() -> System.out.println("other async tasks"));
}
try (ExecutorService executor2b = Executors.newVirtualThreadExecutor()) {
Future<String> future1 = executor2b.submit(() -> "task sub 1");
Future<String> future2 = executor2b.submit(() -> "task sub 2");
System.out.println(future1.get() + " - " + future2.get());
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e.getMessage());
}
}
Das Kotlin-Äquivalent verwendet Coroutine-Kontexte als Synchronisationspunkte.
// (coroutineScope not needed)
launch {
println("other async tasks")
}
val op1 = async { "task sub 1" }
val op2 = async { "task sub 2" }
println("${op1.await()} - ${op2.await()}")
Die strukturierten Gleichzeitigkeitskonstrukte von Kotlin entsprechen in den meisten Fällen "mehr oder weniger" den Java-Loom-Konstrukten:
coroutineScope ~ try (ExecutorService exs = Executors.newVirtualThreadExecutor())
launch -> exs.submit(...)
async & await -> Future<...> f = exs.submit(...) & f.join()
In Kotlin - insbesondere wenn Sie nur Suspend-Methoden verwenden - sind (Gleichzeitigkeits-)Abstraktionen überhaupt nicht im Weg. Das Schlüsselwort coroutineScope wird nur auf oberster Ebene benötigt und nicht in Unterbereichen. Deshalb haben wir es im Kotlin-Beispiel übersprungen und verwenden in der obigen Übersicht ~ statt ->. Normalerweise reicht ein einziger Scope für Launch/Async aus, was in Ihrem Code transparent ist.
Erfahrungen und Fallstricke für Entwickler
Wie Sie vielleicht schon an den Beispielen bemerkt haben, bietet die Syntax von Kotlin mehr Flexibilität bei der Verschachtelung. Um das gleiche Maß an Komfort in Java zu erreichen, müssen Sie den verschachtelten Code wahrscheinlich in separate Funktionen verschieben, um ihn tatsächlich lesbar zu halten. Aber sobald Sie das getan haben, verlieren Sie den Kontext von "Loom". Das bedeutet, dass Sie nicht mehr sehen können, ob eine Funktion für virtuelle Threads geeignet ist oder nicht. Loom hat eine Liste mit virtuellen Thread-freundlichen Funktionen, was bedeutet, dass Sie im Voraus wissen müssen, ob eine Funktion verwendet werden kann oder nicht. Im Gegensatz dazu gibt es in Kotlin das Schlüsselwort suspend, das eine Funktion für die Verwendung in einem Coroutine-Kontext kennzeichnet.
Behalten Sie den Überblick über Ihre Gleichzeitigkeit
Und ich denke, das ist einer der Eckpfeiler von Kotlin: Entwicklerfreundlichkeit. Auf Kosten des Erlernens von mehr Syntax gibt Kotlin Ihnen Sicherheit zurück. Es ist schwieriger, sich in den Fuß zu schießen. Diese Eigenschaft ist nicht nur sichtbar, wenn Sie Funktionen mit dem Schlüsselwort suspend markieren. Sie zeigt sich, wenn Sie CPU-intensive Aufgaben, wie Bildverarbeitung oder Videokodierung, oder blockierende IO-Aufgaben behandeln wollen. In Java müssen Sie sich selbst darum kümmern, Thread-Pools mit virtuellen Threads zu mischen, während Sie in Kotlin einen der vordefinierten Dispatcher verwenden können. Für Java-Programmierer bedeutet dies, dass Sie leicht in eine Thread-Hunger oder langsamer Leistung. Niemand wird Sie daran hindern, dumme Dinge zu tun.
Oberflächlich betrachtet sieht Java/Loom also ähnlich aus wie Kotlin, aber die Schönheit ist nur oberflächlich.
- Java verlangt von Ihnen, dass Sie sich mit Abstraktionen der Nebenläufigkeit auf niedriger Ebene beschäftigen, wie
ExecutorService,ThreadundFuture, um (strukturierte) Nebenläufigkeit zu realisieren. - Java verwendet die bestehende Gleichzeitigkeits-API wieder, was für neue Entwickler eine Belastung darstellt. Einfache Konstrukte wie
.join()oder.get()mit normalen Threads werden zum Verhängnis
Dieser Ansatz bietet Entwicklern viel Raum für Fehler oder die Verwechslung von bestehenden und nicht verwandten Abstraktionen der Gleichzeitigkeit mit den neuen Konstrukten. Darüber hinaus wird die geschäftliche Absicht durch die zusätzliche Ausführlichkeit von Java verwischt. Diese zusätzliche Belastung entfällt in Kotlin.
Wie verhält es sich mit der Leistung?
Es ist wie beim Fahren eines dieser schnellen Oldtimer: Wenn Sie nicht wissen, was Sie tun, sollten Sie vorsichtig fahren. Sonst fahren Sie entweder (a) sehr schnell, aber gefährlich, oder (b) langsam, aber sicher. Kotlin ist eher mit einem modernen Auto vergleichbar. Es ist mit einem Sicherheitsgurt, Airbags und all dem Schnickschnack ausgestattet, der Sie sicher, aber auch schnell fahren lässt.
Je nach Benchmark könnten Sie sogar zu dem Schluss kommen, dass die direkte Schnittstelle von Loom zu OS-Threads nicht schneller ist als Kotlin. Außerdem werden, sobald Loom fertiggestellt ist, auch Kotlin-Coroutinen direkt dieselbe Schnittstelle verwenden. Folglich werden die Toolkits in naher Zukunft auf dieselben Gleichzeitigkeitsprimitive zurückgreifen.
Auch wenn virtuelle Threads die auffälligste Änderung sind, die Loom einführen wird, könnten andere Funktionen den Alltag eines Programmierers stärker beeinflussen als erwartet, wie z.B. skalierte Variablen (die so aussehen könnten). Dies wird auch auf HN erwähnt:
Yeah, while virtual threads are the bread and butter of Loom, they are also adding a lot of QoL things. In particular, the notion of "ScopedVariables" will be a godsend to a lot of concurrent work I do. It's the notion of "I want this bit of context to be carried through from one thread of execution to the next". Beyond that, one thing the loom authors have suggested is that when you want to limit concurrency the better way to do that is using concurrency constructs like semaphores rather than relying on a fixed pool size.
Kotlin bietet über den CoroutineContext skalierte Variablenkonstrukte, die sehr praktisch sein können , wenn Sie den Überblick behalten müssen oder die Protokollierung in Ihrer Anwendung verbessern wollen.
Schlussfolgerungen
Wie lautet also das Urteil?
Das Hinzufügen von Loom zu Java wird definitiv eine neue Domäne von Problemen, Bugs und Best Practices eröffnen. Das bedeutet, dass sich für die Java- oder Kotlin-Entwicklung auf kurze Sicht wahrscheinlich nichts ändern wird. Wenn sich Loom erst einmal etabliert hat und Unternehmen und Entwicklungsteams beschließen, ihre bestehende Codebasis umzuschreiben, um Loom als Gleichzeitigkeitsansatz zu verwenden, könnten sie sich stattdessen entscheiden, ganz zu Kotlin zu wechseln. Das wird der springende Punkt sein, sagen wir voraus. Insbesondere in den so genannten "Business-Domänen" wie E-Commerce, Versicherungen und Banken bietet Kotlin zusätzliche Sicherheit, während Java dies nicht tut.
Unter der Haube könnten die Dinge anders aussehen. Große Frameworks und JVM-basierte Sprachen könnten beschließen, ihre Interna auf virtuelle Loom-Threads umzustellen. Die Auswirkungen werden enorm sein. Und es wird die klassische "Eisberg"-Situation sein: große Änderungen unter Wasser, aber über Wasser, für den Entwickler, sind nur kleine Änderungen in der API sichtbar. In Kotlin wird es wahrscheinlich überhaupt keine Änderungen geben.
Ich denke, das ist ein guter Anreiz, um Ihre Zehen in die Gewässer von Kotlin zu tauchen.
Viel Spaß!
Verfasst von

Joachim Bargsten
Unsere Ideen
Weitere Blogs
Contact







