Introduction aux coroutines dans Kotlin

Développé à l’origine en 2011 par une équipe de JetBrains, Kotlin est supporté depuis octobre 2017 par Google en tant que second langage pour la plateforme Android (avec Java). Il peut être tout aussi bien être utilisé pour du développement mobile (Android) que pour du développement backend. Cet article s’applique à ces 2 parties. Si vous souhaitez avoir une introduction plus détaillée de Kotlin orientée Android, je vous invite à lire l’article d’Audrey Lebret : Introduction à Kotlin pour Android.

La dernière version stable de Kotlin à l’heure de la rédaction de cet article est la 1.2.48, mais nous allons nous intéresser ici à une nouveauté sortie avec la version 1.1 (qui date de mars 2017) : les coroutines.

Cet article a pour vocation de vous servir d’introduction et de base à la notion de coroutine. Il ne s’agira pas de rentrer dans les détails, mais de vous donner des notions pour éveiller votre curiosité. Sous chaque exemple présenté, je vous donnerai un lien vers le site try.kotlinlang.org qui est un IDE en ligne et qui vous permettra vous-même d’exécuter le code et de pouvoir ainsi tester d’autres possibilités.

Quelques notions

Une coroutine, qu’est-ce que c’est ?

Une coroutine est une unité de traitement permettant d’exécuter du code non-bloquant et asynchrone. Sur le principe, il s’agit d’un Thread “allégé”. Son avantage étant qu’elle peut être suspendue et reprise plus tard. Une coroutine peut être suspendue dans un Thread et être reprise dans un autre. Elle ne dépend donc pas d’un Thread en particulier, ce qui apporte un avantage considérable lors de l’utilisation de plusieurs traitements asynchrones.

Du fait qu’une coroutine est plus légère qu’un Thread, il est possible d’en créer des centaines de milliers en parallèle sur un poste classique sans avoir de problème d’ “out of memory”, et donc de réaliser plusieurs traitements en même temps. Il est aussi possible de faire communiquer les coroutines entre elles, mais nous y reviendrons plus tard.

Les coroutines ont vocation à être utilisées notamment pour des traitements d’arrière-plan, tels que des appels à des web services pour charger des données, des traitements lourds qui ne nécessitent pas de bloquer le Thread principal, ou encore des traitements n’ayant pas le besoin de manipuler l’interface utilisateur (ou seulement lorsque le traitement est terminé). Dans un contexte Android, on pourra par exemple utiliser les coroutines lors d’une phase de login en affichant un composant de chargement, lors du chargement de données provenant d’un serveur, ou pour remplir une base locale en arrière-plan.

Les coroutines sont fournies par la librairie kotlinx.coroutines.

Démarrer une coroutine

L’une des premières façons de démarrer une coroutine est d’utiliser la fonction launch. On pourra utiliser la fonction delay() pour simuler un traitement lourd ou de longue durée dans une coroutine. Cette fonction prend en paramètre un nombre en millisecondes. Il est important de préciser que le delay ne bloque que la coroutine, et non le Thread principal.

Dans l’exemple suivant, on lance une coroutine que l’on suspend pendant 1 seconde et qui affiche ensuite “World!”, puis on bloque le thread principal pendant 2 secondes après avoir affiché “Hello,” :

fun main(args: Array<String>) {
    launch { // lancement de la coroutine
        delay(1000L) // suspension de la coroutine pendant 1 seconde
        println("World!")
    }
    println("Hello,") // le thread principal continue tant que la coroutine est suspendue
    Thread.sleep(2000L) // suspension du thread principal pendant 2 secondes pour conserver la JVM en exécution (pour attendre la fin de la coroutine)
}

Vous pouvez tester cet exemple ici.

On remarquera dans le code que le println du “World!” est en amont du println du “Hello,”. Cependant, à l’exécution, on a le résultat suivant :

Hello,
World!

Mais que s’est-il passé ?

Comme nous l’avons vu, le delay ne suspend que la coroutine et non le Thread principal. Le Thread principal a donc simplement continué son exécution (et affiché le “Hello,”) sans s’occuper de la coroutine. Une fois le delay terminé, la coroutine a affiché le “World!”.

Un delay est ce que l’on appelle une special suspending function (nous verrons la notion de suspending function plus tard). Cela signifie qu’elle ne peut suspendre que des coroutines et qu’elle ne peut être utilisée qu’à partir de coroutines.

Pour qu’une coroutine bloque le thread principal pendant son exécution, on utilise la fonction runBlocking :

fun main(args: Array<String>) { 
    launch { // lancement d’une première coroutine
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    runBlocking { // suspension du thread principal par une seconde coroutine
        delay(2000L) // suspension pendant 2 secondes pour conserver la JVM en exécution (pour attendre la fin de la première coroutine)
    } 
}

Vous pouvez tester cet exemple ici

Dans cet exemple, on utilise simplement la seconde coroutine pour suspendre le thread principal, ce qui équivaut au Thread.sleep(2000L) du premier exemple.

Synchroniser une coroutine

Comme nous l’avons vu, l’intérêt d’une coroutine est de pouvoir lancer du code de façon asynchrone. Mais il est parfois nécessaire d’attendre la fin de celle-ci sans bloquer le thread principal et sans utiliser le delay. Pour cela, on utilise la fonction join(). On stocke auparavant la coroutine dans une variable afin de conserver une référence :

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch { // lancement d’une nouvelle coroutine et assignation d’une référence
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    job.join() // attente jusqu’à la fin de la coroutine
}

Vous pouvez tester cet exemple ici

On remarquera ici que le runBlocking wrap (englobe) l’ensemble de la fonction. Elle est donc considérée comme une coroutine principale. Le Unit, correspondant au void en Java, est utilisé car il s’agit de la fonction main.

Les suspending functions

Nous venons de voir l’utilité de la fonction launch qui permet d’exécuter du code dans une coroutine. Seulement, lorsque ce morceau de code devient conséquent, il est préférable de le placer dans une fonction à part. Cette fonction doit alors être marquée avec le modifier suspend. Cela lui permet non seulement d’être utilisée dans une coroutine mais surtout d’utiliser les différentes fonctionnalités des coroutines, comme le delay. La fonction devient alors une suspending function.

Chose importante aussi, une suspending function ne peut être appelée que dans un launch :

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = launch { doWorld() }
    println("Hello,")
    job.join()
}

suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

Vous pouvez tester cet exemple ici

Bien sûr, il existe encore des dizaines de façon d’utiliser les coroutines, et des dizaines de mots-clés et fonctions à vous présenter (comme async, await, withTimeout, cancelAndJoin(), etc.), mais le but ici est de vous montrer les bases pour que vous puissiez approfondir vos connaissances pour la suite.

Les channels

L’une des autres fonctionnalités en lien avec les coroutines et que je souhaite vous faire découvrir ici concerne les channels.

Un channel, qu’est-ce que c’est ?

Un channel est une file d’attente permettant d’échanger des données entre différentes coroutines. Sur le principe, il s’agit de la même chose qu’une BlockingQueue, ou une file d’attente. Un channel possède 2 fonctions principales : send() et receive().

Dans l’exemple suivant, une coroutine envoie 5 résultats de carrés (à l’aide d’une boucle for) dans un channel à l’aide de la fonction send(), et la coroutine principale récupère ces 5 résultats grâce à la fonction receive() :

fun main(args: Array<String>) = runBlocking<Unit> {
    val channel = Channel<Int>() // déclaration d’un channel d’entier
    launch {
        for (x in 1..5) channel.send(x * x) // envoie des 5 résultats de carrés
    }
    repeat(5) { println(channel.receive()) } // on affiche le contenu du channel
    println("Done!")
}

Vous pouvez tester cet exemple ici.

On a alors le résultat suivant :

1
4
9
16
25
Done!

Lorsque nous n’avons plus besoin de nourrir un channel, on utilise la fonction close() pour informer que nous n’y insérerons plus d’informations. Toute itération sur ce channel est alors stoppée, et toutes les valeurs envoyées avant le close() sont garanties d’être lues grâce au receive().

Le pattern producer-consumer

L’utilisation des channels en Kotlin est liée au pattern producteur / consommateur (producer-consumer). Le principe ici est que le producteur insère des données dans un channel, le consommateur les lit et le channel fait office de file d’attente.

Pour définir un producer en Kotlin, on utilise la fonction produce. Ce dernier permet de lancer une nouvelle coroutine qui envoie un ensemble d’éléments dans son propre channel. Une fois que la coroutine est terminée, c’est-à-dire lorsque le producer a terminé d’envoyer ses données dans le channel, celui-ci est automatiquement closed. Un producer est de type ReceiveChannel.

Pour reprendre notre exemple sur les 5 résultats de carrés, avec le producer, l’exemple deviendra le suivant :

fun produceSquares() = produce<Int> {
    for (x in 1..5) send(x * x)
}

Ainsi, dans notre fonction main, il suffit d’appeler notre producer et d’utiliser l’extension consumeEach qui remplace le repeat de l’exemple précédent (ou encore une boucle for) :

fun main(args: Array<String>) = runBlocking<Unit> {
    val squares = produceSquares()
    squares.consumeEach { println(it) }
    println("Done!")
}

Vous pouvez tester cet exemple ici.

Le pattern pipeline

Le pattern pipeline est un autre pattern où une coroutine produit un nombre potentiellement infini de valeurs, et où une seconde coroutine consomme ces valeurs, effectue des calculs et produit un autre ensemble de valeurs. Voici un exemple :

Ce premier producer génère un nombre infini de valeurs incrémentées en partant de 1 :

fun produceNumbers() = produce<Int> {
    var x = 1
    while (true) send(x++) // valeurs infinies en partant de 1
}

Ce second producer consomme les valeurs du premier, calcule les carrés de chaque valeur et génère un autre ensemble de valeurs. Il reçoit alors un élément de type ReceiveChannel généré par le premier producer :

fun square(numbers: ReceiveChannel<Int>) = produce<Int> {
    for (x in numbers) send(x * x)
}

Ainsi, dans notre fonction main, on consommera les valeurs de la seconde coroutine, après avoir lancé la première :

fun main(args: Array<String>) = runBlocking<Unit> {
    val numbers = produceNumbers() // 1er producer (génération infinie)
    val squares = square(numbers) // 2nd producer (calcul des carrés)
    for (i in 1..5) println(squares.receive()) // affichage des 5 premiers carrés
    println("Done!")
    squares.cancel() // arrêt des coroutines
    numbers.cancel()
}

Vous pouvez tester cet exemple ici.

On aura alors le même résultat que précédemment :

1
4
9
16
25
Done!

Conclusion

Même si elles en sont pour l’instant au stade expérimental, les coroutines sont une sérieuse alternative aux Threads ou autres AsyncTasks pour les développeurs qui souhaitent alléger leur application. Elles permettent en effet de meilleures performances pour une utilisation radicalement inférieure des ressources. Elles ont donc pour vocation à être utilisées pour des traitements d’arrière-plan (appels réseaux, traitements lourds, etc.).

Sources