Kotlin
A concise multiplatform language developed by JetBrains
Kotlin Coroutines 1.5: GlobalScope als „heikel“ markiert, Channels-API optimiert – und vieles mehr
Mitautorin: Svetlana Isakova
Kotlin Coroutines 1.5.0 ist da! Dies sind die Änderungen in der neuen Version:
- GlobalScope ist jetzt als „delicate API“ (heikle API) markiert. GlobalScope ist eine API für fortgeschrittene Anwendungsfälle, die leicht missbraucht werden kann. Der Compiler warnt Sie nun vor einem möglichen falschen Gebrauch und erfordert ein explizites Opt-in für die Nutzung dieser Klasse in Ihrem Programm.
- Erweiterungen für JUnit. CoroutinesTimeout ist jetzt für JUnit 5 verfügbar.
- Optimierte Channels-API. Zusammen mit einem neuen Namensschema für die Bibliotheksfunktionen wurden die nicht suspendierenden Funktionen
trySend
undtryReceive
als überlegene Alternativen füroffer
undpoll
eingeführt.
- Stabiler Status für Reactive-Integrationen. Wir haben weitere Funktionen hinzugefügt, um Reactive-Streams-Typen in Kotlin Flow und umgekehrt zu konvertieren, und viele vorhandene Funktionen sowie die ReactiveContext-API wurden als stabil markiert.
In diesem Blogbeitrag finden Sie auch Empfehlungen für die Migration auf die neue Version.
GlobalScope als „heikle“ API markiert
Die Klasse GlobalScope
ist jetzt mit der Annotation @DelicateCoroutinesApi
markiert. In Zukunft ist für jede Verwendung von GlobalScope
ein explizites Opt-in mit @OptIn(DelicateCoroutinesApi::class)
erforderlich.
Die Verwendung von GlobalScope
wird zwar in den meisten Fällen nicht empfohlen, die offizielle Dokumentation verwendet diese „heikle“ API jedoch weiterhin, um die Konzepte vorzustellen.
Ein globaler CoroutineScope
ist nicht an einen Job gebunden. Der globale Scope wird verwendet, um Top-Level-Coroutinen zu starten, die während der gesamten Anwendungslebensdauer ausgeführt werden und nicht vorzeitig beendet werden. Aktive Coroutinen, die im GlobalScope
gestartet wurden, halten den Prozess nicht am Laufen. Sie sind wie Daemon-Threads.
Diese API ist heikel in der Verwendung, da es durch GlobalScope
leicht zu versehentlichen Ressourcen- oder Speicherlecks kommen kann. Eine im GlobalScope
gestartete Coroutine unterliegt nicht dem Prinzip der strukturierten Parallelität. Wenn sie also aufgrund eines Problems (z. B. langsames Netzwerk) hängt oder eine Verzögerung erfährt, läuft sie weiter und verbraucht Ressourcen. Betrachten wir beispielsweise den folgenden Code:
Ein Aufruf von loadConfiguration
erstellt eine Coroutine im GlobalScope
, die im Hintergrund läuft, ohne dass es eine Möglichkeit gibt, die Ausführung abzubrechen oder auf ihre Beendigung zu warten. Wenn das Netzwerk langsam ist, verbraucht das Warten im Hintergrund Ressourcen. Durch wiederholte Aufrufe von loadConfiguration
werden immer mehr Ressourcen in Beschlag genommen.
Mögliche Alternativen
In vielen Fällen sollte die Verwendung von GlobalScope
vermieden werden. Stattdessen sollte der entsprechende Vorgang mit suspend
gekennzeichnet werden. Zum Beispiel:
In Fällen, in denen GlobalScope.launch
verwendet wird, um mehrere parallele Vorgänge zu starten, sollten diese stattdessen mit coroutineScope
gruppiert werden:
Wenn in Top-Level-Code ein parallel ausgeführter Vorgang aus einem nicht suspendierenden Kontext gestartet wird, sollte eine entsprechend eingeschränkte Instanz von CoroutineScope
anstelle von GlobalScope
verwendet werden.
Sinnvolle Anwendungsfälle
In einigen Fällen ist die Verwendung von GlobalScope
legitim und sicher – zum Beispiel bei Top-Level-Hintergrundprozessen, die während der gesamten Lebensdauer der Anwendung aktiv bleiben sollen. Aus den angeführten Gründen erfordert jede Verwendung von GlobalScope
ein explizites Opt-in mit @OptIn(DelicateCoroutinesApi::class)
:
Wir empfehlen, alle Verwendungen von GlobalScope
sorgfältig zu prüfen und nur diejenigen mit der Annotation zu versehen, die in die Kategorie „sinnvolle Anwendungsfälle“ fallen. Andere Verwendungen können sehr leicht zu Fehlern in Ihrem Code führen – ersetzen Sie diese Verwendungen von GlobalScope
daher wie oben beschrieben.
Erweiterungen für JUnit 5
Die Annotation CoroutinesTimeout
ermöglicht es, Tests in einem separaten Thread auszuführen. Nach einem angegebenen Zeitlimit wird der Thread mit einem Testfehler abgebrochen. Bisher war CoroutinesTimeout
für JUnit 4 verfügbar. In dieser Version haben wir eine Integration für JUnit 5 hinzugefügt.
Um die neue Annotation zu verwenden, fügen Sie Ihrem Projekt die folgende Abhängigkeit hinzu:
Hier ist ein einfaches Beispiel für die Verwendung von CoroutinesTimeout
in Ihren Tests:
In diesem Beispiel wird das Zeitlimit für Coroutinen auf Klassenebene für firstTest
definiert. Der annotierte Test hat kein Zeitlimit, da die Annotation der Funktion Vorrang vor der Annotation auf Klassenebene hat. secondTest
wiederum verwendet die Annotation auf Klassenebene, sodass ein Zeitlimit gilt.
Die Annotation wird wie folgt deklariert:
Der erste Parameter, testTimeoutMs
, gibt das Zeitlimit in Millisekunden an. Der zweite Parameter, cancelOnTimeout
, legt fest, ob nach Ablauf des Zeitlimits alle laufenden Coroutinen abgebrochen werden sollen. Wenn dieser den Wert true
hat, werden alle Coroutinen automatisch abgebrochen.
Bei Verwendung der Annotation CoroutinesTimeout
wird automatisch der Coroutinen-Debugger aktiviert, und nach Ablauf des Zeitlimits wird ein Dump aller Coroutinen gespeichert. Der Dump enthält die Stacktraces zur Erstellung der Coroutinen. Wenn zur Beschleunigung der Tests das Speichern der Erstellungs-Stacktraces deaktiviert werden soll, empfiehlt sich die Verwendung von CoroutinesTimeoutExtension
, die diese Möglichkeit bietet.
Vielen Dank an Abhijit Sarkar, der einen nützlichen PoC für CoroutinesTimeout für JUnit 5 erstellt hat. Die Idee wurde zur neuen Annotation CoroutinesTimeout
weiterentwickelt, die wir in Version 1.5 eingeführt haben.
Optimierte Channels-API
Channels sind wichtige Grundbausteine der Kommunikation, mit denen Sie Daten zwischen Coroutinen und Callbacks übertragen können. In dieser Version haben wir die Channels-API ein wenig überarbeitet und die Funktionen offer
und poll
, die oft für Verwirrung sorgten, durch bessere Alternativen ersetzt. Dabei haben wir gleich eine neue, einheitliche Namensgebung für suspendierende und nicht suspendierende Methoden entwickelt.
Das neue Namensschema
Wir haben versucht, ein einheitliches Namensschema zu finden, das auch in anderen Bibliotheken oder in der Coroutines-API verwendet werden kann. Dabei sollte der Name der Funktion einen Hinweis auf ihr Verhalten geben. Am Ende haben wir uns für Folgendes entschieden:
- Die regulären, suspendierenden Methoden behalten ihre Namen unverändert bei, z. B.
send
,receive
. - Ihren nicht suspendierenden Gegenstücken mit Fehlerkapselung wird einheitlich das Präfix „try“ vorangestellt:
trySend
undtryReceive
anstelle der alten Namenoffer
undpoll
. - Neue suspendierende Methoden mit Fehlerkapselung werden das Suffix „Catching“ erhalten.
Wenden wir uns nun den Details dieser neuen Methoden zu.
Try
-Funktionen: nicht suspendierende Gegenstücke zu send
und receive
Eine Coroutine kann Informationen an einen Channel senden, während eine andere Coroutine diese Informationen vom Channel abruft. Sowohl send
als auch receive
sind suspendierende Funktionen. send
suspendiert ihre Coroutine, wenn der Channel voll ist und keine neuen Elemente aufnehmen kann, während receive
ihre Coroutine suspendiert, wenn der Channel keine Elemente enthält:
Zur Verwendung in synchronem Code haben diese Funktionen nicht suspendierende Gegenstücke: offer
und poll
, die nun von trySend
und tryReceive
abgelöst wurden und als veraltet gekennzeichnet sind. Sehen wir uns die Gründe für diese Änderung an.
offer
und poll
haben dieselbe Funktion wie send
und receive
, nur ohne Suspendierung. Das klingt einfach und alles funktioniert einwandfrei, solange beim Senden und Empfangen kein Problem auftritt. Aber was passiert bei einem Fehler? send
und receive
würden mit einer Suspendierung darauf warten, dass sie ihren Job erledigen können. offer
und poll
gaben einfach false
bzw. null
zurück, wenn das Element nicht hinzugefügt werden konnte, weil der Channel voll war, bzw. wenn kein Element abgerufen werden konnte, weil der Channel leer war. Beide lösten eine Ausnahme aus, wenn der Channel geschlossen war, und dieses eine Detail erwies sich als verwirrend.
In diesem Beispiel wird poll
aufgerufen, bevor ein Element hinzugefügt wird, und daher wird sofort null
zurückgegeben. Der direkte Aufruf in diesem Fall ist natürlich eine Vereinfachung – im Normalfall würde man regelmäßig Elemente abfragen. Der Aufruf von offer
ist auch nicht erfolgreich, da unser Channel ein Rendezvous-Channel mit einer Pufferkapazität von null ist. Nur weil die Funktionen in der falschen Reihenfolge aufgerufen wurden, gibt offer
den Wert false
und poll
den Wert null
zurück.
Wenn Sie im obigen Beispiel die Kommentierung von channel.close()
aufheben, wird eine Ausnahme ausgelöst. In diesem Fall gibt poll
wie zuvor false
zurück. Aber dann versucht offer
einem bereits geschlossenen Channel ein Element hinzuzufügen, und dies löst eine Ausnahme aus. Wir haben viele Beschwerden über die Fehleranfälligkeit dieses Verhaltens erhalten. Man vergisst leicht, diese Ausnahme abzufangen, obwohl man sie eigentlich ignorieren oder anderweitig behandeln wollte, und das Programm stürzt ab.
Die neuen Funktionen trySend
und tryReceive
beheben dieses Problem und geben ein detaillierteres Ergebnis zurück. Beide geben eine ChannelResult
-Instanz zurück, die eines von drei Dingen sein kann: ein erfolgreiches Ergebnis, ein Fehler oder ein Hinweis darauf, dass der Channel geschlossen wurde.
Dieses Beispiel funktioniert genauso wie das vorherige, mit dem einzigen Unterschied, dass tryReceive
und trySend
ein detaillierteres Ergebnis zurückgeben. Wie Sie sehen können, enthält die Ausgabe Value(Failed)
statt false
und null
. Heben wir wie zuvor die Kommentierung der Zeile auf, die den Channel schließt, stellen wir fest, dass trySend
jetzt das Ergebnis Closed
zurückgibt, das eine Ausnahme enthält.
Dank Inline-Werteklassen werden bei der Verwendung von ChannelResult
keine zusätzlichen Wrapper erstellt, und wenn eine erfolgreiche Operation einen Wert zurückgibt, wird dieser unverändert und ohne Overhead zurückgegeben.
Catching-Funktionen: suspendierende Funktionen mit Fehlerkapselung
Ab diesem Release tragen suspendierende Methoden mit Fehlerkapselung das Suffix „Catching“ im Namen. Die neue Funktion receiveCatching
zum Beispiel fängt die Ausnahme bei einem geschlossenen Channel ab. Hier ist ein einfaches Beispiel:
Der Channel wird geschlossen, bevor wir versuchen, einen Wert abzurufen. Das Programm wird jedoch erfolgreich mit dem Hinweis abgeschlossen, dass der Channel geschlossen wurde. Wenn wir receiveCatching
durch die reguläre receive
-Funktion ersetzen, wird die Ausnahme ClosedReceiveChannelException
ausgelöst:
Im Moment beschränkt sich das Angebot auf receiveCatching
und onReceiveCatching
(anstelle der bisherigen internen Funktion receiveOrClosed
) – aber wir haben vor, weitere Funktionen hinzuzufügen.
Migrieren zu den neuen Funktionen
Sie können alle Verwendungen von offer
und poll
in Ihrem Projekt automatisch auf die neuen Funktionen umstellen. Da offer
einen Boolean
-Wert zurückgab, bietet channel.trySend("Element").isSuccess
gleichwertigen Ersatz.
poll
wiederum gibt ein nullbares Element zurück, sodass der entsprechende Ersatz channel.tryReceive().getOrNull()
lautet.
Wenn das Ergebnis des Aufrufs nicht weiterverwendet wurde, können Sie den Aufruf direkt durch die neue Funktion ersetzen.
Das Verhalten bei der Ausnahmenbehandlung ist jetzt anders, sodass Sie die erforderlichen Updates manuell vornehmen müssen. Wenn sich Ihr Code darauf verlässt, dass „offer“ und „poll“ bei einem geschlossenen Channel eine Ausnahme auslösen, müssen Sie den folgenden Code als Ersatz verwenden.
Der äquivalente Ersatz für channel.offer("Element")
sollte bei geschlossenem Channel eine Ausnahme auslösen, selbst wenn der Channel normal geschlossen wurde:
Der äquivalente Ersatz für channel.poll()
löst eine Ausnahme aus, wenn der Channel mit einem Fehler geschlossen wurde und gibt null
zurück, wenn er normal geschlossen wurde:
Dieser Code entspricht dem alten Verhalten der Funktionen offer
und poll
.
Wir gehen davon aus, dass Sie diese Feinheiten im Verhalten bei einem geschlossenen Channel in den meisten Fällen nicht absichtlich nutzten, sondern dass es sich eher um eine Fehlerquelle handelte. Aus diesem Grund wird die Semantik durch die automatischen Ersetzungen, die von der IDE angeboten werden, vereinfacht. Wenn dies in Ihrem Fall nicht zutrifft, sollten Sie Ihre Verwendungen manuell überprüfen und aktualisieren. Eventuell ist dann zu erwägen, diese Stellen komplett neu zu schreiben, um Fälle von geschlossenen Channels ohne Ausnahmen zu verarbeiten.
Reactive-Integrationen auf dem Weg zur Stabilität
Version 1.5 von Kotlin Coroutines erhebt die meisten Funktionen, die für die Integration mit Reactive-Frameworks zuständig sind, in den Status einer stabilen API.
Im JVM-Ökosystem gibt es mehrere Frameworks, die sich um die Verarbeitung von asynchronen Streams gemäß dem Reactive-Streams-Standard kümmern. Zwei populäre Java-Frameworks in diesem Bereich sind Project Reactor und RxJava.
Kotlin-Flows sind zwar anders, und die Typen sind nicht mit dem Standard kompatibel, aber sie sind dem Konzept nach trotzdem Streams. Flow
lässt sich in den reaktiven (spezifikations- und TCK-konformen) Publisher
und umgekehrt konvertieren. kotlinx.coroutines
bringt solche Konverter von Haus aus mit; sie sind in den entsprechenden Reactive-Modulen zu finden.
Wenn Sie beispielsweise Interoperabilität mit den Typen von Project Reactor benötigen, fügen Sie Ihrem Projekt die folgenden Abhängigkeiten hinzu:
Dann können Sie Flow<T>.asPublisher()
verwenden, wenn Sie die Reactive-Streams-Typen benötigen, oder aber Flow<T>.asFlux()
, um direkt die Project-Reactor-Typen zu erhalten.
Dies war ein sehr knapper Abriss zu diesem Thema. Um mehr zu erfahren, empfehlen wir Ihnen den Artikel von Roman Elizarov zu Reactive Streams und Kotlin-Flows.
Die Integrationen mit Reactive-Bibliotheken arbeiten zwar auf die Stabilisierung der API hin, aber technisch betrachtet besteht das Ziel darin, die @ExperimentalCoroutinesApi
aufzulösen und die verbliebenen Lücken zu schließen.
Verbesserte Integration mit Reactive Streams
Die Kompatibilität mit der Reactive-Streams-Spezifikation ist wichtig für die Interoperabilität zwischen Drittanbieter-Frameworks und Kotlin-Coroutinen. Sie hilft dabei, Kotlin-Coroutinen in Bestandsprojekte zu übernehmen, ohne den gesamten Code neu schreiben zu müssen.
Es gibt eine lange Liste von Funktionen, die dieses Mal den Sprung in den stabilen Status geschafft haben. Es ist jetzt möglich, Typen von einer beliebigen Reactive-Streams-Implementierung in Flow
und wieder zurück zu konvertieren. Zum Beispiel kann der neue Code mit Coroutines geschrieben, aber mittels Rückkonvertierung in eine alte Reactive-Codebasis integriert werden:
Wir haben auch zahlreiche Verbesserungen an ReactorContext
vorgenommen, der einen Reactor-Context in einen CoroutineContext verpackt, um eine nahtlose Integration zwischen Project Reactor und Kotlin-Coroutinen zu ermöglichen. Mit dieser Integration ist es möglich, Informationen zum Reactor-Context über Coroutinen weiterzuleiten.
Alle Reactive-Integrationen wie Mono
, Flux
, Publisher.asFlow
, Flow.asPublisher
und Flow.asFlux
übermitteln den Kontext implizit durch den Kontext der Subscriber. Hier ist ein einfaches Beispiel, in dem der Subscriber-Context
zu ReactorContext
weitergeleitet wird:
Im obigen Beispiel erstellen wir eine Flow
-Instanz, die dann ohne Kontext in eine Reactor-Flux-Instanz konvertiert wird. Durch den Aufruf der Methode subscribe()
ohne Argument wird der Publisher zur Übermittlung aller Daten aufgefordert. Als Ergebnis gibt das Programm „Reactor context in Flow: null“ aus.
Auch die nächste Aufrufkette konvertiert Flow
in Flux
, fügt dann aber dem Reactor-Kontext dieser Kette den Schlüssel-Wert-Paar answer=42 hinzu. Der Aufruf von subscribe()
löst die Kette aus. Da der Kontext nicht leer ist, gibt das Programm diesmal „Reactor context in Flow: Context1{answer=42}“ aus.
Neue Hilfsfunktionen
Bei der Arbeit mit reaktiven Typen wie Mono
im Kontext von Coroutinen gibt es einige Hilfsfunktionen, die den Abruf ohne Blockieren des Threads ermöglichen. In dieser Version haben wir awaitSingleOr
-Funktionen auf arbiträren Publisher
n als veraltet markiert und einige await
-Funktionen für Mono
und Maybe
spezialisiert.
Mono
erzeugt höchstens einen Wert, daher sind erstes und letztes Element identisch. Auch die Semantik zum Verwerfen der verbleibenden Elemente ist in diesem Fall wenig hilfreich. Deshalb wurden Mono.awaitFirst()
und Mono.awaitLast()
zugunsten von Mono.awaitSingle()
als veraltet gekennzeichnet.
Legen Sie mit kotlinx.coroutines 1.5.0 los!
Die neue Version enthält eine beeindruckende Liste von Neuerungen. Eine besonders bemerkenswerte Leistung des Teams ist das neue Namensschema, das im Zuge der Channels-API-Optimierung entwickelt wurde. Gleichzeitig liegt uns sehr viel daran, die Coroutines-API so einfach und intuitiv wie möglich zu gestalten.
Um die neue Version von Kotlin Coroutines zu verwenden, aktualisieren Sie einfach den Inhalt Ihrer build.gradle.kts-Datei. Stellen Sie als Erstes sicher, dass Sie die neueste Version des Kotlin-Gradle-Plugins verwenden:
Aktualisieren Sie anschließend die Versionen der Abhängigkeiten, einschließlich der Bibliotheken mit speziellen Integrationen für Reactive Streams.
Interessantes zum Lesen und Ansehen
- Video zu Kotlin Coroutines 1.5.0
- Leitfaden zu Coroutinen
- API-Dokumentation
- Das GitHub-Repository von Kotlin Coroutines
- Blogartikel zu Coroutines 1.4.0
- Blogartikel zu Kotlin 1.5.0
Hilfe bei Problemen
- Bitte melden Sie Probleme in unserem Issue-Tracker auf GitHub.
- Unterstützung finden Sie im #coroutines-Kanal des Kotlin-Slacks (eine Einladung erhalten Sie hier).