Libraries

Kotlin Coroutines 1.5: GlobalScope als „heikel“ markiert, Channels-API optimiert – und vieles mehr

Read this post in other languages:
English, Français, 日本語, 한국어, Русский, 简体中文

Mitautorin: Svetlana Isakova

Kotlin Coroutines 1.5.0 ist da! Dies sind die Änderungen in der neuen Version:

  • Optimierte Channels-API. Zusammen mit einem neuen Namensschema für die Bibliotheksfunktionen wurden die nicht suspendierenden Funktionen trySend und tryReceive als überlegene Alternativen für offer und poll 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.

Mit Coroutines 1.5.0 loslegen

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 und tryReceive anstelle der alten Namen offer und poll.
  • 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 Publishern 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

Hilfe bei Problemen