Présentation des coroutines

1. Avant de commencer

Pour que votre application soit de qualité, son interface utilisateur doit être responsive. Peut-être n'y avez-vous pas particulièrement fait attention dans les applications que vous avez créées jusqu'à présent. Toutefois, lorsque vous commencerez à ajouter des fonctionnalités plus avancées, telles que des fonctionnalités de mise en réseau ou de base de données, il pourra s'avérer difficile d'écrire un code à la fois fonctionnel et performant. L'exemple ci-dessous illustre ce qui peut se produire si les tâches de longue durée, telles que le téléchargement d'images depuis Internet, ne sont pas traitées correctement. Bien que les images s'affichent, le défilement est saccadé, ce qui rend l'interface utilisateur peu réactive (et peu professionnelle).

fcf3738b61270a1f.gif

Pour éviter les problèmes comme celui de l'application ci-dessus, vous devez vous familiariser avec les "threads". Bien qu'il s'agisse d'un concept relativement abstrait, un thread est comparable à un chemin d'exécution unique pour le code de votre application. Chaque ligne de code que vous écrivez correspond à une instruction qui doit être exécutée dans l'ordre dans le même thread.

Vous utilisez déjà des threads dans Android. Chaque application Android dispose d'un thread "principal" par défaut. Il s'agit (généralement) du thread UI. Tout le code que vous avez écrit jusqu'à présent se trouve dans le thread principal. Chaque instruction (ou ligne de code) attend la fin de l'instruction précédente avant d'exécuter la ligne de code suivante.

Toutefois, dans une application en cours d'exécution, d'autres threads s'ajoutent au thread principal. En arrière-plan, le processeur ne fonctionne pas avec des threads distincts, mais passe d'une série d'instructions à l'autre pour donner l'impression d'être multitâche. Un thread est une abstraction que vous pouvez utiliser lorsque vous écrivez du code pour déterminer le chemin d'exécution de chaque instruction. L'utilisation de threads autres que le thread principal permet à votre application d'effectuer des tâches complexes en arrière-plan, comme télécharger des images, tout en préservant la réactivité de l'interface utilisateur de l'application. C'est ce qu'on appelle le code simultané, ou tout simplement la simultanéité.

Dans cet atelier de programmation, vous découvrirez les threads et apprendrez à utiliser une fonctionnalité Kotlin appelée coroutines pour rédiger du code simultané clair et non bloquant.

Conditions préalables

Points abordés

  • Qu'est-ce que la simultanéité et pourquoi est-elle importante ?
  • Utiliser des coroutines et des threads pour écrire du code simultané non bloquant
  • Accéder au thread principal pour effectuer des mises à jour de l'interface utilisateur de manière sécurisée lors des tâches en arrière-plan
  • Quand et comment utiliser un modèle de simultanéité différent (Scope/Dispatchers/Deferred)
  • Écrire du code qui interagit avec les ressources réseau

Objectifs de l'atelier

  • Dans cet atelier de programmation, vous apprendrez à utiliser des threads et des coroutines en Kotlin pour créer de petits programmes.

Ce dont vous avez besoin

  • Un ordinateur doté d'un navigateur Web récent, par exemple la dernière version de Chrome
  • Un accès Internet sur votre ordinateur

2. Introduction

Multithreading et simultanéité

Jusqu'à présent, nous avons traité une application Android telle un programme n'ayant qu'un seul chemin d'exécution. Ce chemin d'exécution unique permet de faire beaucoup de choses, mais à mesure que l'application se développe, il est important de penser à la simultanéité.

La simultanéité permet à plusieurs unités de code de s'exécuter en parallèle (en apparence), ce qui permet une utilisation plus efficace des ressources. Le système d'exploitation peut utiliser les caractéristiques du système, du langage de programmation et de l'unité de simultanéité pour gérer le mode multitâche.

966e300fad420505.png

Pourquoi recourir à la simultanéité ? À mesure que votre application gagne en complexité, il est important que votre code soit non bloquant. En d'autres termes, l'exécution d'une tâche de longue durée, telle qu'une requête réseau, ne doit pas interrompre l'exécution d'autres tâches dans l'application. Si vous n'implémentez pas correctement la simultanéité, votre application risque de ne pas répondre aux utilisateurs.

Vous passerez en revue plusieurs exemples illustrant la programmation simultanée en Kotlin. Tous ces exemples peuvent être exécutés dans Kotlin Playground :

https://developer.android.com/training/kotlinplayground

Un thread est la plus petite unité de code qui peut être planifiée et exécutée dans les confins d'un programme. Voici un petit exemple dans lequel du code simultané est exécuté.

Vous pouvez créer un thread simple en fournissant un lambda. Procédez comme suit dans Kotlin Playground.

fun main() {
    val thread = Thread {
        println("${Thread.currentThread()} has run.")
    }
    thread.start()
}

Le thread n'est exécuté que lorsque la fonction atteint l'appel de fonction start(). La sortie devrait ressembler à ce qui suit.

Thread[Thread-0,5,main] has run.

Notez que currentThread() renvoie une instance Thread qui est convertie dans sa représentation sous forme de chaîne. Elle renvoie le nom, la priorité et le groupe du thread. La sortie ci-dessus peut légèrement varier.

Créer et exécuter plusieurs threads

Pour démontrer la simultanéité simple, nous allons créer quelques threads à exécuter. Ce code crée trois threads qui imprimeront la ligne d'information de l'exemple précédent.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

Sortie dans Playground :

Thread[Thread-2,5,main] has started Thread[Thread-2,5,main] - Starting Thread[Thread-0,5,main] - Doing Task 1 Thread[Thread-1,5,main] - Doing Task 1 Thread[Thread-2,5,main] - Doing Task 1 Thread[Thread-0,5,main] - Doing Task 2 Thread[Thread-1,5,main] - Doing Task 2 Thread[Thread-2,5,main] - Doing Task 2 Thread[Thread-0,5,main] - Ending Thread[Thread-2,5,main] - Ending Thread[Thread-1,5,main] - Ending Thread[Thread-0,5,main] has started
Thread[Thread-0,5,main] - Starting
Thread[Thread-1,5,main] has started
Thread[Thread-1,5,main] - Starting

Sortie dans Android Studio (console) :

Thread[Thread-0,5,main] has started
Thread[Thread-1,5,main] has started
Thread[Thread-2,5,main] has started
Thread[Thread-1,5,main] - Starting
Thread[Thread-0,5,main] - Starting
Thread[Thread-2,5,main] - Starting
Thread[Thread-1,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 1
Thread[Thread-2,5,main] - Doing Task 1
Thread[Thread-0,5,main] - Doing Task 2
Thread[Thread-1,5,main] - Doing Task 2
Thread[Thread-2,5,main] - Doing Task 2
Thread[Thread-0,5,main] - Ending
Thread[Thread-2,5,main] - Ending
Thread[Thread-1,5,main] - Ending

Exécutez le code plusieurs fois. La sortie variera. Parfois, les threads semblent s'exécuter dans l'ordre et, dans d'autres cas, le contenu est dispersé.

3. Problématiques liées aux threads

Les threads sont un moyen simple de commencer à travailler avec plusieurs tâches et avec la simultanéité, mais ils présentent quelques obstacles. Plusieurs problèmes peuvent survenir lorsque vous utilisez Thread directement dans le code.

Les threads nécessitent beaucoup de ressources

La création, le changement et la gestion de threads consomment des ressources système et limitent le nombre brut de threads pouvant être gérés en même temps. Le coût de création de plusieurs threads peut vite monter.

Bien qu'une application en cours d'exécution implique plusieurs threads, elle est associée à un thread dédié, qui est spécifiquement responsable de l'interface utilisateur de l'application. Ce thread est souvent appelé thread principal ou thread UI.

Comme ce thread est responsable de l'exécution de l'interface utilisateur de l'application, il est important qu'il soit performant afin que l'application fonctionne correctement. Toute tâche de longue durée risque de le bloquer et d'empêcher votre application de répondre.

Le système d'exploitation fait tout son possible pour que l'utilisateur bénéficie d'une expérience responsive. Les téléphones actuels tentent de mettre à jour l'interface utilisateur 60 à 120 fois par seconde (60 au minimum). Le temps nécessaire pour préparer et afficher l'interface utilisateur est très court (avec 60 images par seconde, chaque mise à jour d'écran doit prendre 16 ms maximum). En cas de retard, Android abandonne les images ou ne finalise pas le cycle de mise à jour qui prend plus de temps que prévu. Il est normal que certaines images soient abandonnées, mais si elles sont trop nombreuses, l'application cessera de répondre.

Conditions de concurrence et comportement imprévisible

Comme nous l'avons vu, un thread est une abstraction de la façon dont un processeur semble traiter plusieurs tâches à la fois. Lorsque le processeur passe d'un ensemble d'instructions à un autre sur différents threads, il est impossible de déterminer le moment exact où un thread sera exécuté et où il sera mis en veille. Lorsque vous travaillez directement avec des threads, vous ne pouvez pas toujours vous attendre à une sortie prévisible.

Par exemple, le code suivant utilise une simple boucle pour compter de 1 à 50, mais dans ce cas, un thread est créé à chaque incrémentation du décompte. Réfléchissez à la sortie que vous pensez recevoir, puis exécutez le code plusieurs fois.

fun main() {
   var count = 0
   for (i in 1..50) {
       Thread {
           count += 1
           println("Thread: $i count: $count")
       }.start()
   }
}

La sortie correspond-elle à vos attentes ? Était-elle la même à chaque fois ? Voici un exemple de sortie.

Thread: 50 count: 49 Thread: 43 count: 50 Thread: 1 count: 1
Thread: 2 count: 2
Thread: 3 count: 3
Thread: 4 count: 4
Thread: 5 count: 5
Thread: 6 count: 6
Thread: 7 count: 7
Thread: 8 count: 8
Thread: 9 count: 9
Thread: 10 count: 10
Thread: 11 count: 11
Thread: 12 count: 12
Thread: 13 count: 13
Thread: 14 count: 14
Thread: 15 count: 15
Thread: 16 count: 16
Thread: 17 count: 17
Thread: 18 count: 18
Thread: 19 count: 19
Thread: 20 count: 20
Thread: 21 count: 21
Thread: 23 count: 22
Thread: 22 count: 23
Thread: 24 count: 24
Thread: 25 count: 25
Thread: 26 count: 26
Thread: 27 count: 27
Thread: 30 count: 28
Thread: 28 count: 29
Thread: 29 count: 41
Thread: 40 count: 41
Thread: 39 count: 41
Thread: 41 count: 41
Thread: 38 count: 41
Thread: 37 count: 41
Thread: 35 count: 41
Thread: 33 count: 41
Thread: 36 count: 41
Thread: 34 count: 41
Thread: 31 count: 41
Thread: 32 count: 41
Thread: 44 count: 42
Thread: 46 count: 43
Thread: 45 count: 44
Thread: 47 count: 45
Thread: 48 count: 46
Thread: 42 count: 47
Thread: 49 count: 48

Contrairement à ce qui est écrit dans le code, il semble que le dernier thread ait été exécuté en premier et que certains autres aient été exécutés dans le désordre. Si vous examinez le "décompte" de certaines itérations, vous remarquerez qu'il ne change pas après plusieurs threads. Encore plus étrange : le décompte atteint 50 au niveau du thread 43, même si la sortie indique qu'il ne s'agit que du deuxième thread qui s'exécute. D'après la sortie seule, il est impossible de connaître la valeur finale du décompte (count).

Ce n'est là qu'un exemple parmi d'autres illustrant comment les threads peuvent entraîner un comportement imprévisible. Lorsque vous utilisez plusieurs threads, vous pouvez également rencontrer une condition de concurrence. En d'autres termes, plusieurs threads tentent d'accéder simultanément à la même valeur en mémoire. Les conditions de concurrence peuvent générer des bugs difficiles à reproduire et aléatoires, qui peuvent provoquer le plantage de votre application, souvent de manière imprévisible.

Les problèmes de performances, les conditions de concurrence et les bugs difficiles à reproduire font partie des raisons pour lesquelles nous vous déconseillons de travailler directement avec des threads. À la place, vous découvrirez une fonctionnalité Kotlin appelée "Coroutines", qui vous aidera à écrire du code simultané.

4. Les coroutines en Kotlin

La création et l'utilisation directes de threads pour les tâches en arrière-plan sont utiles dans Android, mais Kotlin propose également des coroutines, qui offrent un moyen plus flexible et plus simple de gérer la simultanéité.

Les coroutines permettent d'effectuer plusieurs tâches en même temps, mais fournissent un autre niveau d'abstraction par rapport à l'utilisation simple de threads. L'une des fonctionnalités clés des coroutines est la possibilité de stocker l'état, de sorte qu'elles puissent être interrompues et réactivées. Une coroutine peut s'exécuter ou non.

L'état, représenté par des continuations, permet à des portions de code de signaler le moment où elles doivent céder le contrôle ou attendre qu'une autre coroutine se termine avant de reprendre le travail. Ce flux s'appelle le multitâche coopératif. L'implémentation de coroutines via Kotlin permet d'ajouter plusieurs fonctionnalités pour faciliter le multitâche. En plus des continuations, la création d'une coroutine s'effectue dans une tâche (Job), une unité de travail annulable avec un cycle de vie, dans un objet CoroutineScope. Un objet CoroutineScope est un contexte qui applique l'annulation et d'autres règles à ses enfants et à leurs enfants de manière récursive. Un Dispatcher gère le thread sous-jacent que la coroutine utilisera pour son exécution, ce qui évite de devoir déterminer où et quand utiliser un nouveau thread du développeur.

Job

Unité de travail annulable, telle que celle créée avec la fonction launch().

CoroutineScope

Les fonctions permettant de créer des coroutines telles que launch() et async() étendent CoroutineScope.

Dispatcher

Détermine le thread que la coroutine utilisera. Le dispatcher Main exécute toujours les coroutines sur le thread principal, tandis que les dispatchers comme Default, IO ou Unconfined utilisent d'autres threads.

Nous reviendrons sur ces points plus tard. Cependant, les Dispatchers sont l'un des moyens permettant d'améliorer les performances des coroutines. L'un d'eux évite le coût en termes de performances lié à l'initialisation de nouveaux threads.

Nous allons maintenant adapter nos exemples précédents afin d'utiliser des coroutines.

import kotlinx.coroutines.*

fun main() {
    repeat(3) {
        GlobalScope.launch {
            println("Hi from ${Thread.currentThread()}")
        }
    }
}
Hi from Thread[DefaultDispatcher-worker-2@coroutine#2,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#1,5,main]
Hi from Thread[DefaultDispatcher-worker-1@coroutine#3`,5,main]

L'extrait de code ci-dessus crée trois coroutines dans le champ d'application global à l'aide du dispatcher par défaut. GlobalScope autorise l'exécution de toutes les coroutines qu'il contient tant que l'application est en cours d'exécution. Pour les raisons dont nous avons parlé concernant le thread principal, cette approche n'est pas recommandée en dehors de l'exemple de code. Lorsque vous utilisez des coroutines dans vos applications, nous utilisons d'autres champs d'application.

La fonction launch() crée une coroutine à partir du code inclus encapsulé dans un objet Job annulable. launch() est utilisé lorsqu'une valeur de retour n'est pas requise en dehors de la coroutine.

Examinons la signature complète de launch() pour comprendre le prochain concept important concernant les coroutines.

fun CoroutineScope.launch() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
}

En arrière-plan, le bloc de code que vous avez transmis pour le lancement est associé au mot clé suspend. Suspendez les signaux indiquant qu'un bloc de code ou une fonction peuvent être mis en veille ou réactivés.

Remarque concernant runBlocking

Les exemples suivants utilisent runBlocking(), qui, comme son nom l'indique, démarre une nouvelle coroutine et bloque le thread actuel jusqu'à la fin. Cet élément sert principalement à combler le fossé entre le code bloquant et le code non bloquant dans les fonctions et tests principaux. Vous ne l'utiliserez pas souvent dans le code Android standard.

import kotlinx.coroutines.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

val formatter = DateTimeFormatter.ISO_LOCAL_TIME
val time = { formatter.format(LocalDateTime.now()) }

suspend fun getValue(): Double {
    println("entering getValue() at ${time()}")
    delay(3000)
    println("leaving getValue() at ${time()}")
    return Math.random()
}

fun main() {
    runBlocking {
        val num1 = getValue()
        val num2 = getValue()
        println("result of num1 + num2 is ${num1 + num2}")
    }
}

getValue() renvoie un nombre aléatoire après un délai défini. Il utilise un élément DateTimeFormatter pour illustrer les heures d'entrée et de sortie appropriées La fonction principale appelle getValue() deux fois et renvoie la somme.

entering getValue() at 17:44:52.311
leaving getValue() at 17:44:55.319
entering getValue() at 17:44:55.32
leaving getValue() at 17:44:58.32
result of num1 + num2 is 1.4320332550421415

Pour voir comment cela fonctionne, remplacez la fonction main() (en conservant le reste du code) par ce qui suit.

fun main() {
    runBlocking {
        val num1 = async { getValue() }
        val num2 = async { getValue() }
        println("result of num1 + num2 is ${num1.await() + num2.await()}")
    }
}

Les deux appels à getValue() sont indépendants et n'ont pas nécessairement besoin de la coroutine pour permettre la suspension. Kotlin inclut une fonction asynchrone semblable à la fonction de lancement. La fonction async() est définie comme suit.

fun CoroutineScope.async() {
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
}: Deferred<T>

La fonction async() renvoie une valeur de type Deferred. Un élément Deferred est une tâche (Job) annulable pouvant contenir une référence à une valeur future. En utilisant Deferred, vous pouvez toujours appeler une fonction comme si elle renvoyait immédiatement une valeur. Deferred sert simplement d'espace réservé, car vous ne pouvez pas savoir avec certitude quand une tâche asynchrone sera renvoyée. Deferred (également appelé "promesse" ou "futur" dans d'autres langages) garantit qu'une valeur sera renvoyée à cet objet ultérieurement. En revanche, une tâche asynchrone ne bloque ni n'attend l'exécution par défaut. Pour indiquer que la ligne de code actuelle doit attendre la sortie d'une tâche Deferred, vous pouvez appeler await() au niveau de celle-ci. Cette fonction renvoie la valeur brute.

entering getValue() at 22:52:25.025
entering getValue() at 22:52:25.03
leaving getValue() at 22:52:28.03
leaving getValue() at 22:52:28.032
result of num1 + num2 is 0.8416379026501276

Quand marquer une fonction avec "suspend"

Dans l'exemple précédent, vous avez peut-être remarqué que la fonction getValue() est également définie avec le mot clé suspend. La raison en est qu'elle appelle delay(), qui est également une fonction suspend. Chaque fois qu'une fonction appelle une autre fonction suspend, il doit également s'agir d'une fonction suspend.

Si tel est le cas, pourquoi la fonction main() de notre exemple n'est-elle pas marquée avec suspend ? Après tout, elle appelle getValue().

Pas nécessairement. La fonction getValue() est en réalité appelée dans le lambda transmis dans runBlocking(). Le lambda est une fonction suspend, semblable à celles transmises dans launch() et async(). Cependant, runBlocking() n'est pas une fonction suspend. La fonction getValue() n'est pas appelée dans main(), et runBlocking() n'est pas non plus une fonction suspend. Par conséquent, main() n'est pas marqué avec suspend. Si une fonction n'appelle pas de fonction suspend, il n'est pas nécessaire qu'elle soit une fonction suspend elle-même.

5. Pour s'entraîner

Au début de cet atelier de programmation, vous avez vu l'exemple suivant, qui utilisait plusieurs threads. Maintenant que vous connaissez les coroutines, réécrivez le code de manière à utiliser des coroutines au lieu de Thread.

Remarque : Il n'est pas nécessaire de modifier les instructions println(), même si elles font référence à Thread.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

6. Solution de l'exercice pratique

import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
           }
       }
   }
}

7. Résumé

Vous vous êtes familiarisé avec les points suivants :

  • Pourquoi la simultanéité est-elle nécessaire ?
  • Qu'est-ce qu'un thread et pourquoi est-il important pour la simultanéité ?
  • Comment écrire du code simultané en langage Kotlin à l'aide de coroutines
  • Dans quels cas une fonction ne doit-elle pas être marquée avec "suspend" ?
  • Rôles des éléments CoroutineScope, Job et Dispatcher
  • Différence entre "Deferred" et "Await"

8. En savoir plus