Libraries

Kotlin Coroutines 1.5 : GlobalScope signalée comme API “sensible”, amélioration de l’API de canaux, et plus encore

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

La version 1.5.0 de Kotlin Coroutines est là ! Voici ce que cette nouvelle version apporte :

  • Amélioration de l’API de canaux. En plus des nouvelles règles de nommage pour les fonctions de la bibliothèque, les fonctions non suspensives trySend et tryReceive ont été introduites comme de meilleures alternatives à offer et poll.
  • Stabilisation des intégrations Reactive. Nous avons ajouté plus de fonctions pour la conversion des types Reactive Streams en Kotlin Flow et inversement, et stabilisé de nombreuses fonctions existantes et l’API ReactiveContext.

Vous trouverez toutes les recommandations pour passer à la nouvelle version en fin d’article.

Commencer à utiliser Coroutines 1.5.0

GlobalScope signalée comme API à appréhender avec précaution

La classe GlobalScope est désormais marquée avec l’annotation @DelicateCoroutinesApi. Dorénavant, toute utilisation de GlobalScope nécessitera un opt-in explicite avec @OptIn(DelicateCoroutinesApi::class).

Bien que l’utilisation de GlobalScope ne soit pas recommandée dans la plupart des cas, la documentation officielle propose un certain nombre de concepts utilisant cette API.

Un CoroutineScope global n’est lié à aucune tâche. Global scope est utilisée pour exécuter des coroutines de niveau supérieur qui fonctionnent pendant toute la durée de vie de l’application et ne sont pas annulées prématurément. Les coroutines actives lancées dans GlobalScope ne permettent pas d’éviter l’arrêt du processus. Elles sont similaires à des threads démons.

L’utilisation de l’API GlobalScope est délicate et requiert de la prudence car elle peut facilement engendrer des pertes de ressources ou de mémoire. Une coroutine lancée dans GlobalScope n’obéit pas au principe de concurrence structurée, donc en cas de blocage ou de ralentissement (en raison de la lenteur du réseau par exemple), elle continuera de fonctionner et de consommer des ressources. Voici un exemple :

L’appel à loadConfiguration crée une coroutine dans GlobalScope qui s’exécute en arrière-plan et aucune condition n’est spécifiée pour l’annuler ou attendre son achèvement. Si le réseau est lent, elle reste en attente en arrière-plan et consomme des ressources. Des appels répétés à loadConfiguration entraîneront la consommation de plus en plus de ressources.

Possibilités de remplacement

Dans de nombreux cas, l’utilisation de GlobalScope doit être évitée et l’opération contenante doit être marquée avec suspend, par exemple :

Dans les cas où GlobalScope.launch est utilisé pour lancer plusieurs opérations simultanées, les opérations correspondantes doivent plutôt être regroupées avec coroutineScope :

Dans le code de niveau supérieur, lors du lancement d’une opération concurrente simultanée à partir d’un contexte non suspensif, utilisez une instance CoroutineScope correctement délimitée au lieu de GlobalScope.

Cas d’utilisation appropriés

L’utilisation de GlobalScope est sûre et justifiée dans quelques cas, parmi lesquels les processus d’arrière-plan de niveau supérieur qui doivent s’exécuter pendant toute la durée de vie d’une application. C’est pourquoi toute utilisation de GlobalScope requiert un opt-in explicite avec @OptIn(DelicateCoroutinesApi::class) :

Nous vous recommandons de passer en revue toutes vos utilisations de GlobalScope et d’annoter uniquement celles qui rentrent dans la catégorie des « cas d’utilisation appropriés ». Pour tous les autres cas, pour éviter les bugs dans votre code, il faut remplacer l’utilisation de GlobalScope comme décrit ci-dessus.

Extensions pour JUnit 5

Nous avons ajouté une annotation CoroutinesTimeout qui vous permet d’exécuter des tests dans un thread séparé, de les désactiver une fois le temps imparti expiré et d’interrompre le thread. Auparavant, CoroutinesTimeout était seulement disponible pour JUnit 4. Avec cette version, nous avons ajouté l’intégration pour JUnit 5.

Pour utiliser la nouvelle annotation, ajoutez la dépendance suivante à votre projet :

Voici un exemple simple de l’utilisation de CoroutinesTimeout dans vos tests :

Dans cet exemple, le délai d’expiration des coroutines est défini au niveau de la classe et spécifiquement pour firstTest. Il n’y a pas de délai imparti pour le test annoté car l’annotation de la fonction prévaut sur celle de la classe. En revanche il y en a un pour secondTest, qui utilise l’annotation au niveau de la classe.

L’annotation est déclarée de la façon suivante :

Le premier paramètre, testTimeoutMs, spécifie la durée du délai imparti en millisecondes. Le deuxième paramètre, cancelOnTimeout, détermine si toutes les coroutines en cours d’exécution doivent être annulées à la fin du délai imparti. S’il est défini comme true, toutes les coroutines seront automatiquement annulées.

Lorsque vous utilisez l’annotation CoroutinesTimeout, elle active automatiquement le débogueur de coroutines et crée un dump de toutes les coroutines lorsque le délai imparti a expiré. Le dump contient les traces de pile de la création des coroutines. Si vous devez désactiver les traces de pile afin d’accélérer les tests vous pouvez utiliser CoroutinesTimeoutExtension directement pour définir les paramètres appropriés.

Un grand merci à Abhijit Sarkar pour son PoC particulièrement utile pour CoroutinesTimeout pour JUnit 5. L’idée a été développée dans la nouvelle annotation CoroutinesTimeout que nous avons ajoutée dans la version 1.5.

Amélioration de l’API de canaux

Les canaux constituent des primitives de communication importantes, qui permettent l’échange de données entre coroutines et callbacks. Pour cette version, nous avons retravaillé l’API de canaux, en remplaçant les fonctions offer et poll, qui prêtaient à confusion, par de meilleures alternatives. Au passage, nous avons développé un nouveau système de nommage cohérent pour les méthodes suspensives et non suspensives.

Nouveau schéma de nommage

Nous avons voulu créer des règles de nommage cohérentes qui pourraient ensuite être utilisées dans d’autres bibliothèques ou API de coroutine. Nous devions nous assurer que le nom de la fonction transmettrait les informations sur son comportement. Voici ce à quoi nous sommes parvenus :

  • Les méthodes suspensives régulières sont laissées telles quelles, par exemple send et receive.
  • Tous les noms des méthodes non suspensives avec encapsulation d’erreur sont systématiquement préfixés par « try » : trySend et tryReceive au lieu de offer et poll.
  • Les nouvelles méthodes suspensives d’encapsulation d’erreur auront le suffixe « Catching ».

Voyons ces nouvelles méthodes plus en détails.

Fonctions Try : équivalents non suspensifs de send et receive

Une coroutine peut envoyer des informations à un canal, tandis que l’autre peut recevoir ces informations de ce canal. Les fonctions send et receive sont toutes les deux suspensives. send suspend sa coroutine si le canal est plein et ne peut pas prendre en compte de nouvel élément et receive suspend sa coroutine si le canal n’a aucun élément à retourner :

Ces fonctions ont des équivalents non suspensifs pour une utilisation dans du code synchrone : offer et poll, qui sont remplacés par trySend et tryReceive , et la prise en charge de l’ancienne fonctionnalité est interrompue. Voyons quelles sont les raisons de ce changement.

Les fonctions offer et poll sont censées avoir le même comportement que send et receive, mais sans suspension. Cela semble simple et c’est le cas tant que l’élément peut être envoyé ou reçu. Mais qu’arriverait-t-il en cas d’erreur ? send et receive seraient alors suspendues jusqu’à ce qu’elles puissent de nouveau fonctionner correctement. offer et poll retournaient simplement false et null respectivement si l’élément n’avait pas pu être ajouté parce que le canal était plein ou si aucun élément n’avait pu être récupéré car le canal était vide. Elles ont tous les deux lancé une exception pour tenter de travailler avec un canal fermé, ce qui a causé des problèmes avec leur utilisation.

Dans cet exemple, poll est appelé avant qu’un élément ne soit ajouté et renvoie donc null immédiatement. Notez que cette fonction n’est pas censée être utilisée de cette façon : il est préférable de continuer à interroger les éléments régulièrement. Nous l’appelons directement pour simplifier cette explication. L’appel de offer est également infructueux car il s’agit d’un canal rendezvous ayant une capacité de mémoire tampon nulle. Par conséquent, offer renvoie false et poll renvoie null, simplement parce qu’elles n’ont pas été appelées dans bon ordre.

Dans l’exemple ci-dessus, essayez de décommenter l’instruction channel.close() pour vous assurer que l’exception est levée. Dans ce cas, poll renvoie false, comme précédemment. Mais ensuite offer essaie d’ajouter un élément à un canal déjà fermé, échoue et lance une exception. Nous avons reçu de nombreuses remarques selon lesquelles ce comportement est source d’erreurs. Il est facile d’oublier de gérer cette exception, or l’ignorer ou la traiter différemment aura pour conséquence de planter votre programme.

Les nouvelles fonctions trySend et tryReceive corrigent ce problème et renvoient un résultat plus détaillé. Chacune renvoie l’instance ChannelResult, qui peut indiquer trois choses : un résultat positif, un échec ou la fermeture du canal.

Cet exemple fonctionne de la même manière que le précédent. La seule différence est que tryReceive et trySend renvoient un résultat plus détaillé. Vous pouvez voir le résultat Value(Failed) au lieu de false et null. Décommentez la ligne fermant à nouveau le canal et assurez-vous que trySend renvoie maintenant un résultat Closed capturant une exception.

Grâce aux classes de valeurs inline, l’utilisation de ChannelResult ne crée pas de wrappers supplémentaires en dessous et si la valeur réussie est renvoyée, elle l’est telle quelle, sans surcharge.

Fonctions Catching : suspendre les fonctions qui encapsulent des erreurs

À partir de cette version, les méthodes suspensives d’encapsulation des erreurs auront le suffixe « Catching ». Par exemple, la nouvelle fonction receiveCatching gère l’exception dans le cas d’un canal fermé. Prenons cet exemple simple :

Le canal est fermé avant que nous essayions de récupérer une valeur. Cependant, le programme aboutit avec succès, indiquant que le canal a été fermé. Si vous remplacez receiveCatching par la fonction receive ordinaire, elle lancera ClosedReceiveChannelException :

Actuellement, nous fournissons seulement receiveCatching et onReceiveCatching (au lieu de la fonction interne receiveOrClosed auparavant), mais nous prévoyons d’ajouter d’autres fonctions.

Migration de votre code vers de nouvelles fonctions

Vous pouvez remplacer automatiquement toutes les utilisations des fonctions offer et poll dans votre projet avec de nouveaux appels. Puisque offer a renvoyé Boolean, son équivalent de remplacement est canal.trySend("Element").isSuccess.

De même, la fonction poll renvoie un élément nullable, son remplacement devient donc canal.tryReceive().getOrNull().

Si le résultat de l’appel n’a pas été utilisé, vous pouvez les remplacer directement par de nouveaux appels.

Le comportement de traitement des exceptions est désormais différent, vous devrez donc effectuer les mises à jour nécessaires manuellement. Si votre code repose sur les méthodes « offer » et « poll » qui lancent des exceptions sur un canal fermé, vous devrez utiliser les remplacements suivants.

Le remplacement équivalent pour canal.offer("Element") devrait lever une exception lorsque le canal est fermé, même s’il a été fermé normalement :

Le remplacement équivalent pour channel.poll() lance une exception si le canal a été fermé avec une erreur et renvoie null s’il a été fermé normalement :

Ces changements correspondent à l’ancien comportement des fonctions offer et poll.

Nous sommes partis du postulat que, dans la plupart des cas, votre code ne reposait pas sur ces subtilités de comportement sur un canal fermé, mais que cela était plutôt une source de bugs. C’est pourquoi les remplacements automatiques fournis par l’IDE simplifient la sémantique. Si cela ne correspond pas à votre cas, veuillez passer en revue vos utilisations et les mettre à jour manuellement et envisager de les réécrire complètement pour traiter les cas de canaux fermés différemment, sans lever d’exceptions.

Les intégrations Reactive sur la voie de la stabilité

Avec la version 1.5 de Kotlin Coroutines, la plupart des fonctions responsables des intégrations avec les frameworks reactive sont maintenant stables.

Dans l’écosystème de la JVM, quelques frameworks traitent la gestion des threads asynchrones selon la norme des Reactive Streams. Les frameworks Java Project Reactor et RxJava sont sont de ceux là.

Bien que les Kotlin Flows soient différents et que les types ne soient pas compatibles avec ceux spécifiés par la norme, ce sont néanmoins des flux. Il est possible de convertir Flow en Reactive (en conformité avec les spécifications et TCK) Publisher et vice versa. Ces convertisseurs sont directement fournis par kotlinx.coroutines et peuvent être trouvés dans les modules Reactive correspondants.

Par exemple, si vous avez besoin d’interopérabilité avec les types de Project Reactor, vous devez ajouter les dépendances suivantes à votre projet :

Vous pourrez alors utiliser Flow<T>.asPublisher() si vous voulez utiliser les types Reactive Streams ou Flow<T>.asFlux() si vous devez utiliser directement les types Project Reactor.

Pour en savoir plus sur les Reactive Streams et les Kotlin Flows, consultez l’article de Roman Elizarov.

Bien que les intégrations avec les bibliothèques Reactive contribuent à la stabilisation de l’API, d’un point de vue technique, l’objectif est de se débarrasser des @ExperimentalCoroutinesApi et de corriger les bugs.

Meilleure intégration avec Reactive Streams<

La compatibilité avec les spécifications de Reactive Streams est importante afin d’assurer l’interopérabilité entre les frameworks tiers et les coroutines Kotlin. Cela permet d’adopter les coroutines Kotlin dans les projets hérités sans avoir à réécrire tout le code.

Nous sommes parvenus à faire évoluer et à stabiliser de nombreuses fonctions. Il est maintenant possible de convertir un type de n’importe quelle implémentation Reactive Streams en Flow et inversement. Par exemple, le nouveau code peut être écrit avec des coroutines, mais intégré à l’ancienne base de code Reactive via les convertisseurs opposés :

Nous avons également apporté de nombreuses améliorations àReactorContext, qui encapsule le Context de Reactor dans CoroutineContext, pour une intégration fluide et complète entre Project Reactor et Kotlin Coroutines. Grâce à cette intégration, il est possible de propager les informations du Context de Reactor à travers des coroutines.

Le contexte est implicitement propagé via le contexte des subscribers par toutes les intégrations Reactive, telles que Mono, Flux, Publisher.asFlow, Flow.asPublisher et Flow.asFlux. Voici un exemple simple de distribution du context du subscriber dans ReactorContext :

Dans l’exemple ci-dessus, nous construisons une instance Flow qui est ensuite convertie en instance Flux de Reactor, sans contexte. Appeler la méthode subscribe() sans argument a pour conséquence de demander au publisher d’envoyer toutes les données. En conséquence, le programme affiche la phrase « Reactor context in Flow: null ».

De même, la chaîne d’appels suivante convertit Flow en Flux, mais ajoute ensuite une paire clé-valeur réponse=42 au contexte Reactor pour cette chaîne. L’appel à subscribe() déclenche la chaîne. Dans ce cas, puisque le contexte est renseigné, le programme affiche « Reactor context in Flow: Context1{answer=42} ».

Nouvelles fonctions d’assistance

Lorsque vous utilisez des types réactifs comme Mono dans le contexte des coroutines, des fonctions d’aide vous permettent de récupérer les données sans bloquer le thread. Avec cette version, nous arrêtons la prise en charge des fonctions awaitSingleOr* dans les Publisher arbitraires et avons spécialisé certaines fonctions await* pour Mono et Maybe.

Mono produit au plus une valeur, le dernier élément est donc le même que le premier. Dans ce cas, la sémantique de suppression des éléments restants est également inutile. Par conséquent, la prise en charge de Mono.awaitFirst() et Mono.awaitLast() est interrompue et remplacée par celle de Mono.awaitSingle().

Commencez à utiliser kotlinx.coroutines 1.5.0 !

Cette nouvelle version apporte un nombre impressionnant de nouveautés. Le nouveau schéma de nommage mis au point lors du perfectionnement de l’API de canaux est l’une des réalisations les plus remarquable de l’équipe. Parallèlement, nous nous efforçons de rendre l’API des coroutines aussi simple et intuitive que possible.

Pour commencer à utiliser la nouvelle version de Kotlin Coroutines, il suffit de mettre à jour le contenu de votre fichier build.gradle.kts. Assurez-vous d’abord de disposer de la dernière version du plugin Kotlin Gradle :

Puis, mettez à jour les versions des dépendances, y compris les bibliothèques avec des intégrations spécifiques pour les Reactive Streams.

Plus de vidéos et d’articles

En cas de problème

  • Signalez-nous tout problème sur GitHub.
  • Vous pouvez également demander de l’aide dans le canal Slack #coroutines de Kotlin (obtenir une invitation).

Auteurs de l’article original en anglais : Anton Arhipov et Svetlana Isakova