Introduction à Kotlin Coroutines

1. Vue d'ensemble

Dans cet article, nous examinerons les coroutines du langage Kotlin. En termes simples, les coroutines nous permettent de créer des programmes asynchrones de manière très fluide , et ils sont basés sur le concept de programmation de style continuation .

Le langage Kotlin nous donne des constructions de base mais peut accéder à des coroutines plus utiles avec la bibliothèque kotlinx-coroutines-core . Nous examinerons cette bibliothèque une fois que nous aurons compris les éléments de base du langage Kotlin.

2. Création d'une Coroutine avec BuildSequence

Créons une première coroutine en utilisant la fonction buildSequence .

Et implémentons un générateur de séquence de Fibonacci en utilisant cette fonction:

val fibonacciSeq = buildSequence { var a = 0 var b = 1 yield(1) while (true) { yield(a + b) val tmp = a + b a = b b = tmp } }

La signature d'une fonction yield est:

public abstract suspend fun yield(value: T)

Le mot clé suspend signifie que cette fonction peut être bloquante. Une telle fonction peut suspendre une coroutine buildSequence .

Les fonctions de suspension peuvent être créées en tant que fonctions Kotlin standard, mais nous devons être conscients que nous ne pouvons les appeler qu'à partir d'une coroutine. Sinon, nous obtiendrons une erreur du compilateur.

Si nous avons suspendu l'appel dans la buildSequence, cet appel sera transformé en état dédié dans la machine à états. Une coroutine peut être passée et affectée à une variable comme toute autre fonction.

Dans la coroutine fibonacciSeq , nous avons deux points de suspension. Premièrement, lorsque nous appelons yield (1) et ensuite lorsque nous appelons yield (a + b).

Si cette fonction yield entraîne un appel bloquant, le thread actuel ne s'y bloquera pas. Il pourra exécuter un autre code. Une fois que la fonction suspendue a terminé son exécution, le thread peut reprendre l'exécution de la coroutine fibonacciSeq .

Nous pouvons tester notre code en prenant quelques éléments de la séquence de Fibonacci:

val res = fibonacciSeq .take(5) .toList() assertEquals(res, listOf(1, 1, 2, 3, 5))

3. Ajout de la dépendance Maven pour kotlinx-coroutines

Regardons la bibliothèque kotlinx-coroutines qui a des constructions utiles construites au-dessus des coroutines de base.

Ajoutons la dépendance à la bibliothèque kotlinx-coroutines-core . Notez que nous devons également ajouter le référentiel jcenter :

 org.jetbrains.kotlinx kotlinx-coroutines-core 0.16    central //jcenter.bintray.com  

4. Programmation asynchrone Utilisation du lancement () C oroutine

La bibliothèque kotlinx-coroutines ajoute de nombreuses constructions utiles qui nous permettent de créer des programmes asynchrones. Disons que nous avons une fonction de calcul coûteuse qui ajoute une chaîne à la liste d'entrée:

suspend fun expensiveComputation(res: MutableList) { delay(1000L) res.add("word!") }

Nous pouvons utiliser une coroutine de lancement qui exécutera cette fonction de suspension de manière non bloquante - nous devons lui passer un pool de threads comme argument.

La fonction de lancement renvoie une instance de Job sur laquelle nous pouvons appeler une méthode join () pour attendre les résultats:

@Test fun givenAsyncCoroutine_whenStartIt_thenShouldExecuteItInTheAsyncWay() { // given val res = mutableListOf() // when runBlocking { val promise = launch(CommonPool) { expensiveComputation(res) } res.add("Hello,") promise.join() } // then assertEquals(res, listOf("Hello,", "word!")) }

Pour pouvoir tester notre code, nous passons toute la logique dans la coroutine runBlocking - qui est un appel bloquant. Par conséquent, notre assertEquals () peut être exécuté de manière synchrone après le code à l'intérieur de la méthode runBlocking () .

Notez que dans cet exemple, bien que la méthode launch () soit déclenchée en premier, il s'agit d'un calcul retardé. Le fil principal continuera en ajoutant la chaîne «Hello» à la liste de résultats.

Après le délai d'une seconde introduit dans la fonction costerComputation () , le "mot!" La chaîne sera ajoutée au résultat.

5. Les coroutines sont très légères

Imaginons une situation dans laquelle nous voulons effectuer 100 000 opérations de manière asynchrone. La création d'un nombre aussi élevé de threads sera très coûteuse et produira éventuellement une OutOfMemoryException.

Heureusement, lors de l'utilisation des coroutines, ce n'est pas un cas. Nous pouvons exécuter autant d'opérations de blocage que nous le souhaitons. Sous le capot, ces opérations seront gérées par un nombre fixe de threads sans création excessive de threads:

@Test fun givenHugeAmountOfCoroutines_whenStartIt_thenShouldExecuteItWithoutOutOfMemory() { runBlocking { // given val counter = AtomicInteger(0) val numberOfCoroutines = 100_000 // when val jobs = List(numberOfCoroutines) { launch(CommonPool) { delay(1000L) counter.incrementAndGet() } } jobs.forEach { it.join() } // then assertEquals(counter.get(), numberOfCoroutines) } }

Notez que nous exécutons 100 000 coroutines et que chaque exécution ajoute un retard substantiel. Néanmoins, il n'est pas nécessaire de créer trop de threads car ces opérations sont exécutées de manière asynchrone en utilisant le thread du CommonPool.

6. Annulation et délais d'expiration

Parfois, après avoir déclenché un calcul asynchrone de longue durée, nous voulons l'annuler car nous ne sommes plus intéressés par le résultat.

Lorsque nous démarrons notre action asynchrone avec la coroutine launch () , nous pouvons examiner l' indicateur isActive . Cet indicateur est défini sur false chaque fois que le thread principal appelle la méthode cancel () sur l'instance du Job:

@Test fun givenCancellableJob_whenRequestForCancel_thenShouldQuit() { runBlocking { // given val job = launch(CommonPool) { while (isActive) { println("is working") } } delay(1300L) // when job.cancel() // then cancel successfully } }

C'est une manière très élégante et simple d'utiliser le mécanisme d'annulation . Dans l'action asynchrone, nous devons uniquement vérifier si l' indicateur isActive est égal à false et annuler notre traitement.

Lorsque nous demandons un traitement et que nous ne savons pas combien de temps ce calcul prendra, il est conseillé de définir le délai d'expiration pour une telle action. Si le traitement ne se termine pas dans le délai imparti, nous obtiendrons une exception et nous pourrons y réagir de manière appropriée.

Par exemple, nous pouvons réessayer l'action:

@Test(expected = CancellationException::class) fun givenAsyncAction_whenDeclareTimeout_thenShouldFinishWhenTimedOut() { runBlocking { withTimeout(1300L) { repeat(1000) { i -> println("Some expensive computation $i ...") delay(500L) } } } }

Si nous ne définissons pas de délai d'expiration, il est possible que notre thread soit bloqué pour toujours car ce calcul se bloquera. Nous ne pouvons pas gérer ce cas dans notre code si le délai n'est pas défini.

7. Exécution simultanée d'actions asynchrones

Let's say that we need to start two asynchronous actions concurrently and wait for their results afterward. If our processing takes one second and we need to execute that processing twice, the runtime of synchronous blocking execution will be two seconds.

It would be better if we could run both those actions in separate threads and wait for those results in the main thread.

We can leverage the async() coroutine to achieve this by starting processing in two separate threads concurrently:

@Test fun givenHaveTwoExpensiveAction_whenExecuteThemAsync_thenTheyShouldRunConcurrently() { runBlocking { val delay = 1000L val time = measureTimeMillis { // given val one = async(CommonPool) { someExpensiveComputation(delay) } val two = async(CommonPool) { someExpensiveComputation(delay) } // when runBlocking { one.await() two.await() } } // then assertTrue(time < delay * 2) } }

After we submit the two expensive computations, we suspend the coroutine by executing the runBlocking() call. Once results one and two are available, the coroutine will resume, and the results are returned. Executing two tasks in this way should take around one second.

We can pass CoroutineStart.LAZY as the second argument to the async() method, but this will mean the asynchronous computation will not be started until requested. Because we are requesting computation in the runBlocking coroutine, it means the call to two.await() will be made only once the one.await() has finished:

@Test fun givenTwoExpensiveAction_whenExecuteThemLazy_thenTheyShouldNotConcurrently() { runBlocking { val delay = 1000L val time = measureTimeMillis { // given val one = async(CommonPool, CoroutineStart.LAZY) { someExpensiveComputation(delay) } val two = async(CommonPool, CoroutineStart.LAZY) { someExpensiveComputation(delay) } // when runBlocking { one.await() two.await() } } // then assertTrue(time > delay * 2) } }

The laziness of the execution in this particular example causes our code to run synchronously. That happens because when we call await(), the main thread is blocked and only after task one finishes task two will be triggered.

We need to be aware of performing asynchronous actions in a lazy way as they may run in a blocking way.

8. Conclusion

In this article, we looked at basics of Kotlin coroutines.

We saw that buildSequence is the main building block of every coroutine. We described how the flow of execution in this Continuation-passing programming style looks.

Finally, we looked at the kotlinx-coroutines library that ships a lot of very useful constructs for creating asynchronous programs.

L'implémentation de tous ces exemples et extraits de code se trouve dans le projet GitHub.