Coroutines dans Kotlin : launch, async et scope

1. Aperçu

Les coroutines sont la façon dont Kotlin gère le code asynchrone sans callback hell ni blocage de thread. Cet article explique comment launch, async, suspend fun et CoroutineScope fonctionnent, et quand utiliser lequel. À la fin, vous saurez écrire du code concurrent propre, contrôler le cycle de vie des coroutines et le tester avec JUnit 5.

2. Dépendances nécessaires

Les coroutines n’ont pas de support natif dans la stdlib Kotlin, vous devez ajouter kotlinx-coroutines-core.

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.1</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-test</artifactId>
    <version>1.8.1</version>
    <scope>test</scope>
</dependency>

Si vous utilisez Spring Boot, le module spring-boot-starter-webflux ou spring-boot-starter inclut déjà les extensions Kotlin pour supporter suspend fun dans les contrôleurs.

3. suspend fun : l’unité fondamentale des coroutines

suspend est un mot-clé qui marque une fonction pouvant être suspendue sans bloquer le thread.

suspend fun fetchOrder(orderId: Long): Order {
    delay(200) // simule un appel I/O, ne bloque pas le thread réel
    return Order(orderId, "PENDING")
}

data class Order(val id: Long, val status: String)

delay() est une fonction suspend intégrée, différente de Thread.sleep() car elle ne retient pas le thread pendant l’attente. suspend fun ne peut être appelée que depuis une autre suspend fun ou depuis un coroutine builder (launch, async, runBlocking).

Remarque : appeler suspend fun directement depuis une fonction fun ordinaire (non-suspend) provoquera une erreur de compilation. C’est le mécanisme de Kotlin qui vous force à déclarer explicitement le contexte asynchrone.

4. launch : coroutine fire-and-forget

launch crée une nouvelle coroutine, retourne un Job, et ne ramène pas le résultat du calcul — utilisez-la quand vous ne faites que « lancer et oublier ».

fun main() = runBlocking {
    val job: Job = launch {
        println("Début du traitement sur le thread: ${Thread.currentThread().name}")
        val order = fetchOrder(1L)
        println("Statut de la commande: ${order.status}")
    }
    println("launch a retourné, la coroutine s'exécute en parallèle")
    job.join() // attendre que la coroutine se termine
}
launch a retourné, la coroutine s'exécute en parallèle
Début du traitement sur le thread: main
Statut de la commande: PENDING

runBlocking crée un scope pour exécuter des coroutines dans main (à utiliser uniquement pour démo/test, pas en code production réel). launch ne bloque pas le thread courant — elle planifie la coroutine et retourne immédiatement. job.join() est le point où vous attendez que cette coroutine se termine.

5. async : coroutine avec résultat de retour

async ressemble à launch mais retourne Deferred<T> — une « promise » sur laquelle vous appelez .await() pour récupérer le résultat.

suspend fun fetchCustomer(customerId: Long): String {
    delay(150)
    return "Customer-$customerId"
}

suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
    val orderDeferred = async { fetchOrder(orderId) }
    val customerDeferred = async { fetchCustomer(customerId) }

    val order = orderDeferred.await()
    val customer = customerDeferred.await()
    "Commande ${order.id} (${order.status}) du $customer"
}

Les deux appels async s’exécutent en parallèle (concurrent), non séquentiellement. Le temps total d’attente est ici ~200ms (max de 200ms et 150ms) et non 350ms. await() est le seul point où suspend pour récupérer le résultat — si vous appelez await() immédiatement après chaque async au lieu de les séparer, le code s’exécutera séquentiellement et perdra l’avantage du parallélisme.

// Mauvais : s'exécute séquentiellement, perd l'intérêt d'async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()

Remarque : le code ci-dessus n’est pas une erreur de syntaxe mais casse l’objectif d’utiliser async — chaque await() bloque immédiatement après la création, les deux coroutines ne s’exécutent donc jamais simultanément.

6. CoroutineScope : qui gère le cycle de vie des coroutines

Toute coroutine doit appartenir à un CoroutineScope. Le scope détermine : combien de temps la coroutine vit, quand elle est annulée, comment les exceptions se propagent.

class OrderService {
    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    fun processOrderAsync(orderId: Long) {
        serviceScope.launch {
            val order = fetchOrder(orderId)
            println("Commande ${order.id} traitée")
        }
    }

    fun shutdown() {
        serviceScope.cancel() // annule toutes les coroutines enfants en cours dans ce scope
    }
}

serviceScope est conservé comme champ de la classe — c’est comment vous évitez que les coroutines « orphelines » ne s’exécutent indéfiniment après que l’objet ne soit plus utilisé. SupervisorJob() fait qu’une coroutine enfant échouée n’annule pas les autres enfants (contrairement à Job() ordinaire, où un échec annulerait tous les frères).

6.1. coroutineScope vs CoroutineScope

Ne confondez pas coroutineScope { ... } (fonction suspend, minuscule) avec CoroutineScope(...) (constructeur créant un nouveau scope, majuscule).

suspend fun loadDashboard(): String = coroutineScope {
    // coroutineScope attend que TOUTES les coroutines enfants terminent
    // puis retourne, et utilise le contexte de la coroutine parent
    val a = async { fetchOrder(1L) }
    val b = async { fetchCustomer(2L) }
    "${a.await()} - ${b.await()}"
}

coroutineScope est un suspend function builder qui groupe plusieurs coroutines enfants, garantissant la concurrence structurée : la fonction retourne uniquement quand tous les async/launch dedans sont terminés (ou que l’un d’eux échoue et que tous sont annulés). Elle ne crée pas un nouveau scope indépendant mais hérite du contexte courant — très différent de CoroutineScope(...) à la section 6 qui crée un scope vivant indépendamment, pas automatiquement annulé quand la fonction se termine.

7. Dispatcher : sur quel thread exécuter la coroutine

Dispatcher détermine où la coroutine s’exécute — similaire à choisir un Executor en Java.

suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
    // Dispatchers.IO pour les I/O bloquants (lire fichier, appels JDBC...)
    File(path).readText()
}

suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
    // Dispatchers.Default pour les tâches CPU-intensive
    data.sumOf { it.toLong() * it }
}

withContext commute la coroutine courante pour s’exécuter sur un dispatcher différent puis commute automatiquement au dispatcher précédent une fois terminé — très utile quand vous devez appeler du code bloquant (JDBC, I/O fichier) sans occuper le thread principal de Dispatchers.Default ou Dispatchers.Main (sur Android).

Dispatcher À utiliser pour
Dispatchers.Default Calculs CPU-intensive (parsing, tri, compute)
Dispatchers.IO I/O bloquant (fichier, JDBC, réseau bloquant)
Dispatchers.Main Mise à jour UI (Android/JavaFX uniquement)
Dispatchers.Unconfined Rarement utilisé, s’exécute sur le thread d’appel initial

8. Gestion des exceptions dans les coroutines

Les exceptions dans launch et async se propagent différemment — c’est la source de bugs la plus courante.

fun main() = runBlocking {
    // launch : l'exception est levée immédiatement, peut crash le scope si non capturée
    val job = launch {
        try {
            throw IllegalStateException("Commande inexistante")
        } catch (e: IllegalStateException) {
            println("Exception capturée dans launch: ${e.message}")
        }
    }
    job.join()

    // async : l'exception est retenue dans Deferred, levée seulement lors d'await()
    val deferred = async {
        throw IllegalStateException("Client inexistant")
    }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("Exception capturée dans async: ${e.message}")
    }
}
Exception capturée dans launch: Commande inexistante
Exception capturée dans async: Client inexistant

Avec async, si vous oubliez d’appeler .await(), l’exception sera silencieusement « avalée » jusqu’à ce que Deferred soit garbage-collecté ou que vous appeliez await() quelque part — très facile de causer des bugs difficiles à tracer en production.

9. Tester les coroutines

Utilisez kotlinx-coroutines-test avec runTest pour tester les suspend functions sans attendre les delay réels.

class OrderServiceTest {

    @Test
    fun `fetchOrder retourne le statut PENDING`() = runTest {
        val order = fetchOrder(1L)

        assertThat(order.id).isEqualTo(1L)
        assertThat(order.status).isEqualTo("PENDING")
    }

    @Test
    fun `loadOrderDetail execute deux coroutines en parallele`() = runTest {
        val result = loadOrderDetail(orderId = 1L, customerId = 2L)

        assertThat(result).contains("Commande 1")
        assertThat(result).contains("Customer-2")
    }
}

runTest utilise automatiquement un temps virtuel — delay(200) dans fetchOrder ne fait pas traîner le test 200ms réels, il saute directement au point de reprise logique. C’est pourquoi vous ne devez pas tester les coroutines avec runBlocking dans les tests unitaires : il attend le temps réel, ralentissant votre suite de tests au fil du temps.

10. Erreurs courantes

  • Utiliser GlobalScope : les coroutines vivent pendant toute la durée de l’application, ne sont pas annulées selon aucun cycle de vie — fuite mémoire. Créez toujours un scope lié à une classe/composant spécifique comme à la section 6.
  • Appeler runBlocking dans le code production : il bloque le thread courant réellement, détruisant le but des coroutines. À utiliser uniquement dans main(), les tests, ou où il faut absolument adapter du code bloquant avec du code suspend.
  • Oublier await() sur async : l’exception est avalée, la coroutine s’exécute sans que personne n’attende le résultat — gaspillage et difficile à tracer.
  • Confondre Dispatchers.IO avec le travail CPU-intensive : Dispatchers.IO a un pool de threads bien plus grand (optimisé pour I/O), l’utiliser pour du calcul lourd dégradé l’efficacité du scheduling.

Remarque : si votre service n’a pas besoin de vraie concurrence (juste appeler séquentiellement quelques I/O), les coroutines peuvent être du sur-engineering. Parfois une simple fonction suspend fun appelant séquentiellement sans async/launch est plus claire et facile à maintenir.

11. Conclusion

suspend fun est l’unité fondamentale, launch s’utilise quand on n’a pas besoin de résultat retourné, async s’utilise pour exécuter en parallèle et récupérer le résultat via await(). CoroutineScope gère le cycle de vie — liez toujours le scope à un composant spécifique, évitez GlobalScope, et préférez coroutineScope/SupervisorJob pour contrôler la concurrence structurée. Maîtriser ces trois concepts suffit à écrire la plupart du code asynchrone en Kotlin de manière sûre et facile à tester.

kotlincoroutinesconcurrencyandroidspring-boot