Les coroutines Kotlin en production : dispatchers, annulation et fuites

Les coroutines sont la façon normale d’écrire du code concurrent en Kotlin. Ktor est construit dessus. Spring les accepte dans ses contrôleurs. Elles sont stables depuis 2018, et bien documentées.

Pourtant, elles cassent en production. Pas à cause d’un bug dans la bibliothèque. À cause de quatre malentendus, toujours les mêmes. Un appel bloquant posé au mauvais endroit. Un catch un peu trop large. Un runBlocking de trop. Et un thread dump qui ne montre rien, au moment où on en a le plus besoin.

Cet article fait le tour de ces quatre points. Tout a été vérifié avec kotlinx-coroutines 1.11.0 et un JDK 25, sur une machine à 10 processeurs. Les sorties sont réelles.

Une coroutine n’est pas un thread

C’est la phrase que tout le monde connaît, et dont peu de gens tirent les conséquences.

Une coroutine est un objet sur la heap. Le compilateur transforme chaque fonction suspend en une machine à états. Quand la coroutine attend, sur un delay, un appel réseau ou une requête vers une base non bloquante, elle ne bloque aucun thread. Elle range son état dans un objet, la continuation, et rend le thread. Quand le résultat arrive, un thread la reprend là où elle s’était arrêtée. Pas forcément le même.

On peut mesurer ce que ça coûte. Le programme suivant lance 100 000 coroutines qui attendent, puis compare la mémoire avant et après :

fun memoire(): Long {
    System.gc(); Thread.sleep(200)
    val r = Runtime.getRuntime()
    return r.totalMemory() - r.freeMemory()
}

fun main() = runBlocking {
    val avant = memoire()
    val jobs = List(100_000) { launch { delay(Long.MAX_VALUE) } }
    delay(500)
    val delta = memoire() - avant
    println("100 000 coroutines suspendues : ${delta / 1024 / 1024} Mo, soit ${delta / 100_000} octets chacune")
    println("threads JVM : " + Thread.getAllStackTraces().size)
    jobs.forEach { it.cancel() }
}
100 000 coroutines suspendues : 24 Mo, soit 254 octets chacune
threads JVM : 6

Quelques centaines d’octets par coroutine. Six threads en tout, ceux de la JVM. Un thread plateforme, lui, réserve de l’ordre du mégaoctet pour sa stack, comme le rappelle l’article sur les virtual threads.

C’est là tout l’intérêt. Et c’est là aussi tout le piège. Une coroutine ne coûte rien tant qu’elle est suspendue. Dès qu’elle bloque, elle coûte un thread. Et des threads, il n’y en a pas beaucoup.

Combien de threads, exactement

Les coroutines tournent sur des dispatchers. Un dispatcher, c’est un pool de threads et une règle pour y placer le travail. Il y en a deux à connaître côté serveur.

Dispatchers.Default est fait pour le calcul. Dispatchers.IO est fait pour les appels bloquants. Le programme suivant lance 200 tâches bloquantes sur chacun, et compte les threads distincts qui ont servi :

suspend fun compter(d: CoroutineDispatcher): Int = coroutineScope {
    val noms = ConcurrentHashMap.newKeySet<String>()
    repeat(200) { launch(d) { noms += Thread.currentThread().name; Thread.sleep(500) } }
    noms.size
}
cpus=10
Default: 10 threads
IO: 64 threads
IO.limitedParallelism(100): 100 threads

Default a autant de threads que de processeurs, avec un minimum de deux. IO en a 64, ou le nombre de processeurs si celui-ci est plus grand. Ce plafond se règle avec la propriété système kotlinx.coroutines.io.parallelism. Et une vue limitedParallelism sur IO peut aller au-delà de 64, comme le montre la dernière ligne. C’est le cas depuis la 1.6, et c’est propre à IO : la même vue sur Default reste bornée par le nombre de processeurs.

Deux détails qui comptent en production.

Le premier : les deux dispatchers partagent les mêmes threads. Regardez les noms :

[DefaultDispatcher-worker-45, DefaultDispatcher-worker-75, DefaultDispatcher-worker-84, ...]

Il n’y a pas de thread IO-worker. Un même pool sert les deux, et chaque dispatcher applique sa propre limite dessus. Dans un thread dump, le nom du thread ne dit donc pas pour qui il travaille. La stack, si : une tâche passée par IO porte un frame kotlinx.coroutines.internal.LimitedDispatcher$Worker.run deux lignes sous votre dernier frame, juste après DispatchedTask.run. Une tâche Default n’en a pas. Une vue limitedParallelism, sur l’un ou l’autre, en porte un aussi : ce frame veut dire « passé par une limite », et IO en est une.

Le second : ce nombre de processeurs, en conteneur, c’est la JVM qui le lit dans le cgroup. Un pod limité à un CPU a un Dispatchers.Default à deux threads. Tout ce que dit Régler la JVM en conteneur s’applique donc directement à vos coroutines.

Bloquer sur Default, le premier piège

Voici le piège le plus fréquent, et le plus facile à mesurer. Cent tâches qui attendent une seconde chacune, trois façons de le faire :

val t1 = measureTimeMillis { coroutineScope { repeat(100) { launch(Dispatchers.Default) { Thread.sleep(1000) } } } }
val t2 = measureTimeMillis { coroutineScope { repeat(100) { launch(Dispatchers.Default) { delay(1000) } } } }
val t3 = measureTimeMillis { coroutineScope { repeat(100) { launch(Dispatchers.IO) { Thread.sleep(1000) } } } }
100 x Thread.sleep(1000) sur Default : 10036 ms
100 x delay(1000) sur Default : 1014 ms
100 x Thread.sleep(1000) sur IO : 2015 ms

Même attente d’une seconde, et un facteur dix entre la première ligne et la deuxième.

Thread.sleep bloque son thread. Sur Default, il n’y en a que dix. Les cent tâches passent donc dix par dix. delay suspend la coroutine, le thread est libre aussitôt, et les cent tâches attendent toutes en même temps. Sur IO, Thread.sleep bloque toujours, mais avec 64 threads le mal est moindre.

Thread.sleep est un exemple. En vrai, ce sera JDBC. Un appel JDBC bloque son thread, toujours : il n’existe pas de JDBC non bloquant. Ce sera aussi un vieux client HTTP, une lecture de fichier, un Thread.sleep caché dans une bibliothèque de retry. Tout ça, sur Default, mange vos dix threads. L’application ne plante pas. Elle devient lente, le CPU dort, et le profiler ne montre rien parce que rien ne calcule.

La règle est simple. Ce qui bloque va sur IO, explicitement :

suspend fun chargerClient(id: Long): Client = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject("select ... where id = ?", mapper, id)
}

Et la règle vaut double dans un framework. Sur Spring WebFlux, un handler suspend s’exécute sur les threads Netty, à peu près aussi nombreux que les cœurs, quatre au minimum. Un appel JDBC posé là bloque un thread qui sert toutes les requêtes du serveur. Ktor, avec le moteur Netty, exécute aussi ses handlers dans des coroutines, sur un pool d’appel d’un thread par processeur. Même règle : le bloquant passe par withContext(Dispatchers.IO).

runBlocking dans une coroutine

runBlocking fait ce que son nom dit. Il bloque le thread courant jusqu’à ce que sa coroutine se termine. C’est parfait dans main, dans un test, ou pour appeler du code suspend depuis une API qui ne l’est pas.

Dans une coroutine, c’est une bombe. Le programme suivant tourne sur un dispatcher à un seul thread. La coroutine externe fait un runBlocking, qui veut à son tour exécuter du travail sur ce même dispatcher :

val un = Dispatchers.Default.limitedParallelism(1)
runBlocking(un) {
    println("dans la coroutine, thread=" + Thread.currentThread().name)
    val r = runBlocking { withContext(un) { "ok" } }
    println("résultat=$r")
}
dans la coroutine, thread=DefaultDispatcher-worker-1
[tué au bout de 8 s]

La deuxième ligne ne vient jamais. Le runBlocking interne tient le seul thread du dispatcher. Le withContext(un) attend qu’un thread de ce dispatcher se libère. Personne n’avance. C’est un deadlock.

Un seul thread, c’est le cas extrême. Mais dix threads, c’est le Dispatchers.Default d’une machine ordinaire. Il suffit de dix requêtes qui font le même détour en même temps, et le serveur s’arrête. Ça n’arrive jamais en test. Ça arrive un lundi matin, sous charge.

runBlocking reste donc aux frontières du programme : main, les tests, et l’adaptateur vers une API bloquante que vous ne contrôlez pas. Jamais dans une fonction suspend, jamais dans un handler.

L’annulation se fait en coopération

Une coroutine annulée ne s’arrête pas toute seule. L’annulation est coopérative : la coroutine doit passer par un point de suspension, delay, yield, un appel réseau, pour s’apercevoir qu’elle est annulée. Sans ça, elle continue.

La démonstration tient en trois lignes :

val job = launch(Dispatchers.Default) { while (true) { n++ } }
delay(100); job.cancel()
withTimeoutOrNull(1000) { job.join() }
while(true): join après cancel = toujours en vie après 1 s
while(isActive): terminée

La boucle while (true) survit à son cancel(). Elle n’a aucun point de suspension, donc aucune occasion de s’arrêter. Le worker est perdu jusqu’à la fin du processus. La version while (isActive) s’arrête proprement. ensureActive() ou yield() dans la boucle font le même travail.

Ce cas est connu. Le suivant l’est beaucoup moins, et il est plus vicieux.

Le catch qui avale l’annulation

L’annulation voyage sous la forme d’une exception, CancellationException. Chaque point de suspension de la bibliothèque, delay, yield, withContext, les channels, la lance quand le job est annulé. Le problème, c’est que CancellationException hérite de Exception. Un catch (e: Exception) l’attrape donc, comme toutes les autres. Un catch (e: RuntimeException) aussi, puisqu’elle en hérite.

Voici une boucle de polling comme on en écrit partout. Elle attend, elle travaille, et elle se protège contre les erreurs :

val job = launch(Dispatchers.Default) {
    while (true) {
        try {
            delay(50)
            // travail
        } catch (e: Exception) {
            // on logue, et on continue
        }
        tours.incrementAndGet()
    }
}
delay(120); job.cancel()
catch(Exception): tours avant cancel=2, 100 ms après cancel=178461, isCompleted=false

Deux tours avant l’annulation. 178 461 tours dans les 100 ms qui suivent. Et le job n’est toujours pas terminé.

Ce qui se passe : une fois le job annulé, delay lance CancellationException immédiatement, à chaque appel. Le catch l’attrape, la boucle recommence, delay relance. On a transformé une attente de 50 ms en boucle serrée. Le worker tourne à 100 % de CPU, pour rien, jusqu’à la fin du processus. Et le runBlocking qui entoure tout ça, comme il attend ses enfants, ne rend jamais la main.

C’est la fuite de coroutine la plus courante que je connaisse, et elle passe toutes les revues de code. Le remède tient en une ligne au début du catch :

} catch (e: Exception) {
    if (e is CancellationException) throw e
    // logue, et continue
}

Ou coroutineContext.ensureActive() au même endroit, qui relance l’annulation si le job est annulé. Ou encore un catch (e: CancellationException) { throw e } placé avant le catch général.

Le nettoyage dans finally

Dernier point sur l’annulation. Une coroutine annulée exécute bien ses blocs finally. Mais dedans, le job est déjà annulé. Tout point de suspension y relance donc CancellationException :

delay dans finally: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled

Si votre nettoyage doit suspendre, fermer une connexion avec un client non bloquant, envoyer un dernier message, il faut le dire explicitement :

} finally {
    withContext(NonCancellable) {
        connexion.close()
    }
}

Où vont les exceptions

Les coroutines sont structurées : un launch a un parent, et ce parent attend ses enfants. Cette structure décide aussi de ce que devient une exception. Il y a quatre cas, et il faut les connaître tous les quatre.

Dans un coroutineScope, une exception dans un enfant annule les frères, puis remonte au parent, qui la relance. Ça vaut aussi pour un async que personne n’a attendu :

coroutineScope {
    async { delay(50); error("boum async") }
    launch { delay(1000); println("jamais affiché") }
}
coroutineScope a relancé: boum async

Le launch est annulé, le coroutineScope relance. C’est le comportement le plus sûr. Rien ne se perd.

Dans un supervisorScope, ou sous un SupervisorJob, un enfant qui échoue n’entraîne pas les autres. Le supervisorScope se termine normalement. L’exception d’un launch va au CoroutineExceptionHandler du contexte, ou, s’il n’y en a pas, au handler d’exceptions non attrapées du thread qui exécutait la coroutine, qui affiche la stack et passe à autre chose :

handler a vu: java.lang.IllegalStateException: boum supervisor
le frère survit

Un async dans un superviseur, en revanche, garde son exception pour lui. Elle sera livrée à l’appel de await(). Si personne n’appelle await(), personne ne la verra jamais :

async non awaité dans supervisor: isCancelled=true, rien d'affiché

Pas de log, pas de handler. Le Deferred est annulé en silence. Un async dont on n’attend pas le résultat est un launch qui a oublié de le dire, et qui perd ses erreurs en plus.

withTimeout, enfin, est le cas que presque personne ne connaît. Il lance TimeoutCancellationException. Et cette exception est une CancellationException. Regardez ce que ça donne dans un launch :

val job = scope.launch {
    withTimeout(100) { delay(1000) }
    println("jamais affiché")
}
withTimeout dans launch: isCancelled=true, parent actif=true

Le launch est annulé. Le parent ne l’est pas, et ce n’est pas l’effet du SupervisorJob : sous un Job ordinaire, le parent et les frères survivent aussi. Et le CoroutineExceptionHandler n’a rien vu : pour la bibliothèque, une annulation est un événement normal, pas une erreur. Votre timeout a expiré, le travail n’a pas été fait, et rien ne l’a signalé. withTimeoutOrNull renvoie un null qu’on est obligé de traiter. C’est souvent le meilleur choix. Sinon, attrapez TimeoutCancellationException au bord du launch et loguez-la vous-même.

Le scope qui ne meurt jamais

Une coroutine vit dans un scope. Un scope vit tant qu’on ne l’annule pas. Un scope qu’on n’annule jamais, c’est une fuite.

Le cas classique :

class Synchronisation {
    private val scope = CoroutineScope(Dispatchers.Default)

    fun demarrer() {
        scope.launch { while (isActive) { synchroniser(); delay(10_000) } }
    }
}

Rien ne ferme ce scope. Si Synchronisation est recréée, à chaque test, à chaque rechargement, à chaque requête, l’ancienne boucle continue. Deux instances, deux boucles. Cent instances, cent boucles. La mémoire monte lentement, le CPU aussi, et le heap dump montrera des StandaloneCoroutine accrochées à des objets que plus personne n’utilise.

GlobalScope est le même problème, en pire, puisqu’on ne peut même pas l’annuler. Il est marqué @DelicateCoroutinesApi pour ça.

La règle : un scope a un propriétaire, et le propriétaire le ferme. Avec un SupervisorJob, pour qu’un échec n’emporte pas les autres tâches :

class Synchronisation : AutoCloseable {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    fun demarrer() { scope.launch { /* ... */ } }

    override fun close() = scope.cancel()
}

Dans Spring, close() s’appelle depuis un @PreDestroy. Dans Ktor, depuis un hook d’arrêt de l’application. Ce qui compte, c’est qu’il soit appelé.

Dans un thread dump, elles n’existent pas

Voilà le point qui rend tout le reste difficile à diagnostiquer.

Prenons 10 000 coroutines nommées, suspendues sur un delay, et un thread dump pris pendant qu’elles attendent :

val jobs = List(10_000) { i ->
    launch(Dispatchers.Default + CoroutineName("commande-$i")) { delay(Long.MAX_VALUE) }
}
threads dans le dump : 30, dont workers : 10
occurrences de 'commande' dans le dump : 0

Trente threads. Dix workers. Et zéro trace des 10 000 coroutines. Les workers, eux, sont tous dans le même état :

"DefaultDispatcher-worker-1" #25 daemon prio=5 ... waiting on condition
   java.lang.Thread.State: TIMED_WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@25.0.1/Native Method)
	at java.util.concurrent.locks.LockSupport.parkNanos(java.base@25.0.1/LockSupport.java:408)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.park(CoroutineScheduler.kt:833)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.tryPark(CoroutineScheduler.kt:781)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:751)

C’est logique. Une coroutine suspendue n’est sur aucun thread. Elle est sur la heap. Un thread dump photographie des threads, et ne voit donc que les coroutines en train de tourner. Celles qui attendent, c’est-à-dire presque toutes, c’est-à-dire celles que vous cherchez, sont invisibles. Tout le réflexe décrit dans Lire un thread dump avec jstack s’arrête à la porte des coroutines. C’est exactement la même limite que pour les virtual threads, et pour la même raison.

Deux outils comblent le trou.

Le mode debug

Avec -Dkotlinx.coroutines.debug, la bibliothèque ajoute le nom de la coroutine au nom du thread, pendant qu’elle tourne :

thread name in coroutine: DefaultDispatcher-worker-2 @commande-0#2

Ça ne montre toujours que les coroutines montées. Mais au moins, quand un worker est occupé, on sait par quoi. Encore faut-il nommer les coroutines, avec CoroutineName. Sans ça, le dump dira @coroutine#2, ce qui n’aide personne.

Le coroutine dump

Le vrai outil, c’est kotlinx-coroutines-debug. Il s’accroche aux coroutines à leur création et sait lister les suspendues, avec leur stack. Le plus sûr est de l’installer en agent au démarrage :

java -javaagent:kotlinx-coroutines-debug-1.11.0.jar -Dkotlinx.coroutines.debug ...

DebugProbes.install() fait la même chose depuis le code. Il s’attache à sa propre JVM via Byte Buddy et JNA, que Gradle tire avec la dépendance. Si vous posez juste les jars sur un classpath, sans JNA, vous aurez un No compatible attachment provider is available. Et depuis JDK 21, cet attachement dynamique affiche un avertissement, en attendant d’être interdit par défaut. L’agent évite tout ça. Le nom de la coroutine dans le dump, lui, vient de -Dkotlinx.coroutines.debug : sans le flag, l’agent liste des coroutines anonymes.

Un appel à DebugProbes.dumpCoroutines() sort alors ceci, pour une coroutine suspendue trois fonctions plus bas :

Coroutine "commande-42#2":StandaloneCoroutine{Active}@215be6bb, state: SUSPENDED
	at E9Kt.appelerApi(E9.kt:11)
	at E9Kt.chargerClient(E9.kt:10)
	at E9Kt.traiterCommande(E9.kt:9)
	at E9Kt$main$1$1.invokeSuspend(E9.kt:4)

Le nom, l’état, et la pile de fonctions suspend. Là où le thread dump ne montrait rien, on voit la coroutine bloquée dans appelerApi, appelée par chargerClient. C’est ce qu’il faut pour trouver qui attend quoi.

Un détail qui surprend : une fonction suspend dont le dernier acte est d’appeler une autre fonction suspend n’apparaît pas dans cette pile. Le compilateur l’optimise comme un tail call, sans continuation propre. Si une fonction manque dans le dump, c’est probablement ça.

Ces probes ont un coût. Elles suivent chaque coroutine créée. Réservez-les à un poste de travail, ou à un environnement de test sous charge, le temps de comprendre.

Limiter la concurrence

Le pool de threads limitait la concurrence sans le dire. Avec un launch par requête, la limite saute. Et comme dans l’article sur les virtual threads, c’est le pool de connexions, derrière, qui prend le mur.

Pour brider un appel vers une ressource, la bibliothèque a son propre Semaphore. Pas celui de java.util.concurrent, qui bloquerait le thread. Celui de kotlinx.coroutines.sync, qui suspend :

private val limite = Semaphore(20)

suspend fun appeler(r: Requete): Reponse = limite.withPermit {
    client.send(r)
}

Vingt appels en vol au maximum. Les coroutines en trop attendent, sans occuper de thread.

limitedParallelism(n) fait un travail voisin, mais sur le dispatcher : au plus n coroutines en train de tourner. Ça borne le CPU, pas le nombre d’appels en attente d’une réponse. Pour une ressource distante, c’est le sémaphore qu’il vous faut.

Et n’ajoutez rien devant un pool de connexions. HikariCP limite déjà. Réglez sa taille et son timeout d’acquisition, comme le décrit l’article sur les virtual threads.

Coroutines et virtual threads

Depuis Java 21, la question revient à chaque migration : les virtual threads ne rendent-ils pas les coroutines inutiles ?

Un point d’abord, souvent mal compris. Sur un JDK 25, Dispatchers.IO utilise toujours des threads plateforme. Soixante-quatre, comme mesuré plus haut. La bibliothèque n’a pas de dispatcher intégré pour les virtual threads, et ne bascule jamais dessus toute seule.

Mais on peut en fabriquer un, en une ligne :

val vt = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

Et la différence se voit tout de suite, sur mille appels bloquants d’une seconde :

1000 x Thread.sleep(1000) sur virtual threads : 1018 ms, 1000 threads distincts
1000 x Thread.sleep(1000) sur IO : 16060 ms

Une seconde contre seize. Sur IO, mille appels bloquants passent 64 par 64. Sur des virtual threads, ils passent tous ensemble, chacun sur son thread, et le blocage ne coûte plus rien. Pour du code bloquant que vous ne pouvez pas rendre suspend, JDBC en tête, c’est un vrai gain, immédiat, sans toucher au code appelant.

Les deux ne se remplacent pas. Un virtual thread supprime le coût d’un thread bloqué. Une coroutine apporte en plus la structure : le scope, l’annulation qui se propage, les exceptions qui remontent au parent, Flow. On peut avoir les deux. Le dispatcher ci-dessus donne des coroutines structurées, exécutées sur des virtual threads. Gardez Default pour le calcul, et remplacez IO par ce dispatcher là où vous bloquez.

Une réserve : les pièges des virtual threads s’appliquent alors, le pinning en tête. L’article dédié en fait le tour.

En résumé

Default a un thread par processeur, deux au minimum, IO en a 64, et ils partagent le même pool. En conteneur, ce nombre vient du cgroup.

Un appel bloquant sur Default coûte un thread sur dix. JDBC, les vieux clients HTTP, les fichiers : tout ça va sur IO, via withContext.

runBlocking reste aux frontières du programme. Dans une coroutine, c’est un deadlock qui attend son lundi matin.

L’annulation est coopérative. Une boucle sans point de suspension ne s’arrête pas. Et catch (e: Exception) avale CancellationException : relancez-la, sinon la coroutine tourne en boucle serrée jusqu’à la fin du processus.

withTimeout dans un launch échoue en silence. Un async non attendu sous un superviseur aussi.

Un scope a un propriétaire, qui l’annule. GlobalScope n’en a pas.

jstack ne voit pas les coroutines suspendues. Nommez-les, activez -Dkotlinx.coroutines.debug, et gardez kotlinx-coroutines-debug sous la main pour un vrai coroutine dump.

Enfin, Dispatchers.IO ne profite pas des virtual threads. Un asCoroutineDispatcher() sur un executor virtuel, et vos appels bloquants cessent de faire la queue.