La programmation asynchrone avec les coroutines
Qu’est-ce qu’une bibliothèque tierce ?
Lorsque l’on crée un programme complexe, un langage de programmation seul n’est pas toujours suffisant. Il n’est pas rare de lui ajouter des bibliothèques tierces.
Une bibliothèque tierce est une bibliothèque (c’est-à-dire du code) écrite par d’autres développeurs et réutilisable dans nos propres programmes.
L’objectif d’une bibliothèque tierce est de nous faire gagner du temps en bénéficiant de l’expertise d’autres développeurs. En effet, nous pouvons réutiliser du code métier ou du code graphique sans avoir besoin de l’écrire nous-mêmes.
Utiliser des bibliothèques tierces est une pratique très répandue. Parfois, l’utilisation est payante, d’autres fois, elles sont mises à disposition gratuitement.
Dans quel cas a-t-on besoin d’une bibliothèque tierce ?
Les raisons peuvent être multiples. Par exemple :
-
Pour faire des appels web à une API. Plutôt que de réimplémenter un client HTTP pour consommer une API, on utilise généralement un client existant (okhttp, ktor, etc.).
-
Pour mettre en place une base de données. Plutôt que de réimplémenter son propre système de gestion de base de données, on utilise...
Quand utiliser la programmation asynchrone ?
Pour construire un programme en Kotlin, il convient d’utiliser de nombreux concepts, dont des fonctions et des classes si l’on souhaite faire de la programmation orientée objet.
Les fonctions écrites dans le cadre d’un programme doivent, généralement, répondre à un besoin spécifique et proposer un traitement qui peut être de nature très diverse : lire ou écrire dans un fichier, faire un appel réseau, rechercher un élément dans un tableau, lire ou insérer des données dans une base de données, etc.
Toutes ces opérations peuvent être très rapides ou très longues en fonction du contexte. On imagine par exemple que rechercher un élément dans un tableau de dix cases ne prend pas le même temps que rechercher un élément dans un tableau de plusieurs milliers de cases. Bien évidemment, dans le cadre de la recherche d’éléments dans un tableau, il existe des algorithmes performants pour tenter de réduire le nombre d’opérations et donc limiter le temps de traitement. Par exemple, dans un tableau trié contenant énormément de cases, on privilégiera probablement une recherche dichotomique plutôt qu’une recherche séquentielle.
Malheureusement, nous...
Ajouter les coroutines dans un projet
Les coroutines ne sont pas incluses dans le langage Kotlin. En effet, les développeurs du langage considèrent que certaines fonctionnalités ne sont pas systématiquement utilisées par les développeurs. Aussi, pour réduire la taille des programmes, certains éléments sont distribués à côté. C’est le cas entre autres des coroutines.
Pour bénéficier des coroutines dans nos programmes, il convient d’ouvrir le fichier "build.gradle.kts" du projet IntelliJ IDEA. Actuellement, il ressemble à ceci :
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm") version "1.4.10"
application
}
group = "me.rolan"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test-junit"))
}
tasks.withType<KotlinCompile>(){
kotlinOptions.jvmTarget = "1.8"
}
application {
mainClassName = "MainKt"
}
Dans la section dependencies, il faut...
Créer des coroutines
Pour créer et lancer une coroutine, on utilise ce qu’on appelle un constructeur de coroutines (coroutine builder). Dans le cadre de cette section, nous allons en voir trois : runBlocking, launch et async.
1. Le constructeur runBlocking
Le constructeur runBlocking est un peu particulier car il permet de bloquer le programme tant que toutes les opérations effectuées dans la coroutine qu’il définit ne sont pas terminées.
On s’écarte de la notion de programmation asynchrone, mais c’est un constructeur qui existe et qui peut être très utile dans certaines situations.
Commençons par écrire un programme principal qui crée une coroutine à l’aide du constructeur runBlocking :
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
}
}
L’instruction import permet d’utiliser le constructeur de coroutines.
Nous venons de créer notre première coroutine ! Celle-ci commence au niveau de l’accolade ouvrante qui suit la fonction runBlocking et se termine avec l’accolade fermante de la même fonction.
Nous pouvons d’ores et déjà lancer le programme, mais nous ne constaterons rien de spécial.
Ajoutons trois instructions pour afficher du texte 1) avant la coroutine, 2) dans la coroutine et 3) après la coroutine.
import kotlinx.coroutines.runBlocking
fun main()
{
println("Before coroutine")
runBlocking {
println("In coroutine")
}
println("After coroutine")
}
Lançons le programme. Les trois phrases s’affichent sur le terminal, dans l’ordre où elles sont déclarées :
Before coroutine
In coroutine
After coroutine
Modifions maintenant le contenu de la coroutine pour simuler un traitement qui prendrait plusieurs secondes. Concrètement, mettons le programme en pause à l’aide de l’instruction Thread.sleep(1000), où 1000 correspond au nombre de millisecondes que dure la pause (soit 1 seconde) :
fun main()
{
println("Before coroutine")
runBlocking {
println("coroutine : Before sleep")
Thread.sleep(1000)
println("coroutine : After sleep")
}
println("After coroutine")
}
Lançons le programme. Les phrases s’affichent toujours dans l’ordre où elles sont déclarées, soit de manière séquentielle :
Before coroutine
coroutine : Before sleep
coroutine : After sleep
After coroutine
Si ce premier exemple peut sembler assez peu pertinent, ce n’est que pour mieux préparer l’étude du constructeur suivant : le constructeur launch.
2. Le constructeur launch
Ce constructeur de coroutines s’utilise à partir d’une portée de coroutine (coroutine scope). Pour le moment, nous allons simplement utiliser le singleton GlobalScope.
Écrivons un programme principal qui crée une coroutine à l’aide du constructeur launch :
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun main()
{
GlobalScope.launch {
}
}
Contrairement au constructeur runBlocking, le constructeur launch ne bloque pas le programme. Aussi, le code contenu dans la coroutine va s’exécuter en parallèle du code écrit sous la coroutine.
Pour illustrer ces propos, reprenons le dernier exemple de code de la section précédente en remplaçant le constructeur runBlocking par le constructeur launch :
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun main()
{
println("Before coroutine")
GlobalScope.launch {
println("coroutine : Before sleep")
Thread.sleep(1000)
println("coroutine : After sleep")
}
println("After coroutine") ...
Annuler et synchroniser des coroutines
Dans les exemples précédents utilisant les trois constructeurs runBlocking, launch et async, nous n’avons stocké le résultat que pour le constructeur async afin d’attendre la fin de la coroutine.
Il est également possible de stocker le résultat du constructeur launch dans une variable. L’objet renvoyé est de type Job. Tout comme l’objet renvoyé par le constructeur async (pour mémoire, le type de cet objet est Deferred, mais ce type hérite du type Job).
Nous avons eu un aperçu de l’utilité d’un objet de type Job grâce au constructeur async. Il permet non seulement d’attendre la fin de l’exécution de la coroutine, mais aussi d’obtenir de précieuses informations sur celle-ci et de la manipuler.
1. Connaître l’état d’une coroutine
Un objet de type Job permet d’obtenir des informations sur le cycle de vie de la coroutine, notamment grâce à trois propriétés booléennes :
-
isActive : sa valeur est true quand la coroutine a démarré et qu’elle n’est pas terminée.
-
isCompleted : sa valeur est true quand la coroutine est terminée.
-
isCancelled : sa valeur est true quand la coroutine a été annulée.
Soit un programme très simple dont le but est de lancer une coroutine, de la suspendre pendant 100 millisecondes et de vérifier son état à l’aide d’une boucle :
import kotlinx.coroutines.GlobalScope ...
Des coroutines dans des coroutines
Il est possible de lancer des coroutines dans des coroutines. Une relation de parenté est alors créée entre elles, elle est importante pour bien comprendre leur fonctionnement. La coroutine qui contient est appelée parent tandis que la coroutine contenue est appelée enfant.
Une coroutine parent est terminée uniquement lorsque ses coroutines enfants sont également terminées ou annulées.
Soit une coroutine qui lance elle-même deux coroutines qui sont mises en pause à l’aide de l’instruction delay :
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
println("Parent coroutine")
launch {
println("First child coroutine")
delay(1000)
println("End of first child coroutine")
}
launch {
println("Second child coroutine")
delay(1000)
println("End of second child coroutine")
}
println("End of parent coroutine")
}
println("End of program")
}
Ici, il n’est pas nécessaire de recourir à la classe GlobalScope pour utiliser la fonction launch, car nous sommes déjà dans une coroutine grâce au constructeur...
La gestion des erreurs dans une coroutine
1. La gestion locale
La première façon de gérer une exception dans une coroutine est tout simplement d’utiliser un bloc try catch.
Attention cependant ! Il convient d’utiliser ce bloc pour encapsuler les instructions qui peuvent lever une exception à l’intérieur d’une coroutine, et non pour encapsuler une coroutine en entier.
Dans le cas de l’exemple précédent, on utilise donc le bloc try catch pour encapsuler l’instruction 50 / 0 qui provoque la levée d’exception :
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
println("Parent coroutine")
launch {
println("First child coroutine")
delay(1000)
println("End of first child coroutine")
}
launch {
println("Second child coroutine")
delay(500)
try
...
Ajouter des paramètres aux constructeurs
Il est possible de passer deux paramètres aux constructeurs de coroutines que nous avons vus jusqu’à maintenant : un contexte et une option de démarrage.
1. Le contexte
Le premier paramètre est ce que l’on appelle un contexte. Ce contexte doit être passé à travers un objet de type CoroutineContext. Cet objet peut être construit par l’addition de plusieurs éléments, comme un objet de type Job, un objet de type Dispatchers ou encore un objet de type CoroutineExceptionHandler.
Il existe bien évidemment d’autres objets qui peuvent rentrer dans la composition d’un contexte de coroutine. Il n’est pas possible de tous les énumérer. Les trois éléments cités sont les plus communs. Reportez-vous à documentation officielle pour plus d’informations.
Jusqu’à maintenant, dans les exemples que nous avons étudiés, nous n’avons pas passé de contexte aux constructeurs de coroutines. Ce contexte n’est pas obligatoire, et quand il n’est pas explicitement passé en paramètre du constructeur, celui-ci utilise un contexte par défaut.
a. Ajouter un objet de type Job
Sauf cas très spécifique, il n’est pas commun de passer un objet de type Job dans le contexte d’une coroutine. Comme nous l’avons dit, il existe un comportement par défaut entre une coroutine parent et ses enfants, que ce soit lors de l’annulation d’un enfant ou du parent ou encore lorsqu’une exception est levée dans la coroutine parent ou dans l’un de ses enfants. C’est justement en l’absence d’objet de type Job dans le contexte que ce comportement par défaut est possible.
Cela dit, dans certains cas, il est nécessaire de changer ce comportement par défaut. Nous allons en examiner deux. Dans le premier scénario, nous verrons comment éviter que l’annulation de la coroutine parent entraîne l’annulation d’une coroutine enfant tandis que dans un second scénario, nous verrons comment éviter qu’une exception dans une coroutine enfant provoque l’annulation de la coroutine parent et des autres coroutines enfants.
Reprenons le précédent exemple composé...
Changer de contexte
Pourquoi vouloir changer de contexte au sein d’une coroutine ?
Pour diverses raisons, cela peut être nécessaire. Prenons deux exemples.
Le besoin d’un changement de contexte dans une coroutine peut venir de la nécessité de changer le thread dans lequel la coroutine s’exécute à l’aide d’un objet de type Dispatchers. Imaginons une coroutine lancée à l’aide du constructeur launch dont le but est de consommer une API web. Puisqu’il s’agit d’une opération d’entrée/sortie, le mieux est de lancer cette coroutine dans un thread dédié qui tourne en arrière-plan à l’aide du contexte Dispatchers.IO. Cependant, une fois l’appel terminé et le résultat parsé et interprété, on souhaite mettre à jour l’interface graphique du logiciel. Pour manipuler l’interface graphique d’un logiciel, il convient de retourner dans le thread dédié à cette interface graphique. Pour cela, on a la possibilité de changer le contexte de la coroutine actuelle afin que l’interface graphique puisse être manipulée sans risque. Dans ce cas précis, changer le contexte peut se faire en utilisant le Dispatchers.Main.
Le besoin d’un changement de contexte dans une coroutine peut aussi venir de la nécessité...
L’échange de données entre coroutines
Deux éléments clés du langage Kotlin le leur permettent : les channels et les flows.
1. Les channels
a. Qu’est-ce qu’un channel ?
Les coroutines peuvent communiquer entre elles. En d’autres termes, elles peuvent s’échanger des données. Un channel permet les échanges de données entre coroutines. On peut le voir comme une file dans laquelle une première coroutine envoie des données tandis qu’une seconde coroutine les consomme.
En Kotlin, un channel se déclare comme un objet de type Channel. Il convient ensuite, au sein des coroutines, d’utiliser les méthodes de cet objet Channel pour envoyer des données ou pour les consommer.
Pour pouvoir utiliser un objet de type Channel, il convient donc d’en créer une instance. Il s’agit d’un objet générique (au même titre que les collections). Il est donc nécessaire de préciser le type de données qui vont être échangées au sein du channel. Il est également nécessaire de préciser la capacité du channel, c’est-à-dire le nombre d’éléments qui peuvent être échangés par les coroutines.
Créons un objet de type Channel dans l’idée d’échanger trois entiers entre deux coroutines :
val channel = Channel<Int>(3)
Une fois la variable channel créée, il convient simplement d’utiliser la méthode send pour envoyer une donnée dedans et la méthode receive pour consommer une donnée, comme le montre le programme suivant, dans lequel deux coroutines construites à l’aide du constructeur launch s’échangent des données : la première envoie les nombres 1, 2 et 3 dans la variable channel tandis que la seconde coroutine les reçoit.
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
val channel = Channel<Int>(3)
launch {
for (i in 1..3)
{
println("Sending the value $i")
channel.send(i)
delay(1000)
}
}
launch {
val dataReceived = channel.receive()
println("Receiving the value $dataReceived")
}
}
println("End of program")
}
Lorsque l’on exécute le programme, l’affichage produit dans le terminal n’est pas celui attendu. Si les trois valeurs sont bien envoyées, seule la première est reçue :
Sending the value 1
Receiving the value 1
Sending the value 2
Sending the value 3
End of program
C’est en réalité tout à fait normal. À chaque méthode send doit correspondre un appel à la méthode receive. Dans cet exemple, puisque la méthode send est appelée trois fois, il convient d’appeler autant de fois la méthode receive.
Reprenons le programme. Pour appeler la méthode receive, aidons-nous de la méthode repeat du langage Kotlin, qui correspond à une boucle for :
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
val channel = Channel<Int>(3)
launch {
for (i in 1..3)
{
println("Sending the value $i")
channel.send(i)
delay(1000)
}
}
launch {
repeat(3) {
val dataReceived = channel.receive()
println("Receiving the value $dataReceived")
}
}
}
println("End of program")
}
Exécutons le programme. Cette fois, toutes les données envoyées sont bien reçues :
Sending the value 1
Receiving the value 1
Sending the value 2
Receiving the value 2
Sending the value 3
Receiving the value 3
End of program
Que se passe-t-il si on envoie plus de données que la capacité déclarée de l’objet Channel ?
La capacité que nous déclarons à la création de l’objet de type Channel ne nous empêche pas de continuer à envoyer des données une fois cette capacité atteinte. La capacité définit le nombre d’éléments qui peuvent être stockés dans la file. Dès qu’au moins une donnée est consommée, cela libère de la place dans la file et il est possible d’envoyer une nouvelle donnée. S’il n’y a plus de place dans l’objet Channel, l’envoi est tout simplement suspendu.
Modifions le programme précédent. Dans cette nouvelle version, la première coroutine envoie maintenant cinq nombres, la consommation est plus lente que l’envoi, et un changement de place des logs met en évidence la suspension de l’envoi des données quand la file est pleine :
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main()
{
runBlocking {
val channel = Channel<Int>(3)
launch {
for (i in 1..5)
{ ...
En résumé
-
Les coroutines permettent d’exécuter des instructions de manière asynchrone dans un programme informatique.
-
Les coroutines ne sont pas incluses directement dans le langage Kotlin, elles nécessitent l’ajout d’une dépendance tierce dans le code source du programme.
-
Il existe plusieurs constructeurs pour créer une coroutine, dont launch, async, et runBlocking.
-
Il est possible d’attendre la fin d’une coroutine ou d’annuler son exécution.
-
Il est possible de lancer des coroutines dans une coroutine afin de créer une relation parent/enfants entre ces coroutines.
-
Pour gérer de manière globale les exceptions dans une coroutine, il est possible d’utiliser la classe CoroutineExceptionHandler.
-
Il est possible de personnaliser le contexte d’exécution d’une coroutine via des éléments de type Job, Dispatchers et CoroutineExceptionHandler.
-
Il est possible de personnaliser les options de démarrage d’une coroutine via une valeur de l’énumération CoroutineStart.
-
Il est possible de changer le contexte d’exécution d’une coroutine à tout moment grâce à la fonction withContext.
-
Pour faire communiquer plusieurs coroutines, il est possible d’utiliser l’interface Channel ou l’interface Flow.
-
L’interface Channel est qualifiée...