Coroutines, threads et Mutex en Kotlin : comprendre l’exécution réelle
Les coroutines Kotlin sont souvent présentées comme une manière “simple” de faire de l’asynchrone, mais on se perd vite si on ne distingue pas clairement :
- Coroutine : une unité de travail suspendable (logique d’exécution).
- Thread : une ressource OS/JVM qui exécute du code (capacité CPU réelle).
Une coroutine n’est pas un thread. Une coroutine s’exécute sur un thread, et peut changer de thread au cours de sa vie (selon dispatcher / suspension).
Cet article explique :
- comment les coroutines utilisent les threads (dispatchers, scheduling),
- la différence critique entre suspension et blocage,
- comment protéger un état partagé avec
Mutex, - les pièges réels (deadlocks, starvation, locks “classiques” vs coroutines, annulation).
1) Le modèle mental : “coroutine = tâche”, “thread = worker”
Imagine un atelier :
- Les threads sont des ouvriers (il y en a un nombre limité).
- Les coroutines sont des tâches sur une liste (il peut y en avoir des milliers).
Les ouvriers prennent une tâche, travaillent dessus, puis la reposent si elle doit attendre quelque chose (réseau, délai, autre ressource). Pendant cette attente, l’ouvrier peut prendre une autre tâche.
C’est l’idée clé : attendre sans immobiliser un thread.
2) Dispatchers : qui décide sur quels threads ça tourne ?
Le dispatcher d’une coroutine choisit :
- dans quel pool de threads exécuter,
- comment planifier la reprise après suspension.
Les principaux dispatchers :
Dispatchers.Main: thread UI (Android)Dispatchers.IO: pool pour opérations bloquantes (IO, réseau, disque)Dispatchers.Default: pool pour calcul CPUDispatchers.Unconfined: spécial, à éviter en applicatif
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) {
println("Default thread = ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) {
println("IO thread = ${Thread.currentThread().name}")
}
}
Remarque : “IO” et “Default” utilisent tous deux des pools, mais l’intention n’est pas la même :
- Default : limiter pour éviter la saturation CPU.
- IO : permettre plus de threads car beaucoup de temps est passé à attendre (blocage).
3) Suspension vs blocage : la différence la plus importante
3.1 Suspension (bonne pratique coroutines)
Quand une coroutine appelle une fonction suspend comme delay,
elle se suspend :
- le thread est libéré,
- la coroutine reprendra plus tard,
- sans bloquer de ressource OS.
suspend fun exampleSuspend() {
println("Before delay: ${Thread.currentThread().name}")
delay(100) // suspend, ne bloque pas le thread
println("After delay: ${Thread.currentThread().name}")
}
3.2 Blocage (danger si mal placé)
Si tu fais un appel bloquant (ex : Thread.sleep, IO bloquant, lock JVM),
tu immobilises un thread :
fun exampleBlock() {
Thread.sleep(100) // bloque le thread
}
Dans une application, bloquer des threads du pool (ou pire : le Main thread) peut provoquer :
- latences,
- saturation du pool (plus aucune coroutine ne progresse),
- ANR sur Android si Main est bloqué.
4) Comment une coroutine “interagit” avec un thread
Une coroutine a un continuation (un état) qui permet de reprendre l’exécution après un point de suspension.
Concrètement :
- la coroutine démarre sur un thread selon le dispatcher,
- si elle suspend, elle rend la main,
- quand l’événement attendu arrive, le dispatcher planifie sa reprise sur un thread du pool.
Elle peut donc reprendre sur un thread différent (sauf si tu imposes une contrainte de thread via dispatcher).
5) État partagé : pourquoi il faut synchroniser
Dès que plusieurs coroutines peuvent toucher la même donnée (map, liste, compteur, state), tu risques des incohérences.
Exemple (problématique) :
class Counter {
var value = 0
suspend fun inc() {
value++ // pas atomique : risque de perdre des increments
}
}
Même si “ça marche souvent”, sous charge tu peux obtenir un résultat faux.
6) Mutex coroutines : protéger sans bloquer les threads
kotlinx.coroutines.sync.Mutex est conçu pour les coroutines.
Il fournit une exclusion mutuelle “coroutine-friendly” :
- si le mutex est libre : la coroutine entre,
- s’il est pris : la coroutine suspend (au lieu de bloquer un thread).
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SafeCounter {
private val mutex = Mutex()
private var value = 0
suspend fun inc() = mutex.withLock {
value++
}
suspend fun get(): Int = mutex.withLock { value }
}
Ici, si 1000 coroutines appellent inc() :
- elles attendent leur tour,
- mais le système ne “crame” pas 1000 threads.
7) Mutex vs locks JVM : pourquoi un lock classique est souvent un mauvais choix en coroutines
Les locks JVM (synchronized, ReentrantLock) bloquent un thread.
Si tu as beaucoup de coroutines et que tu bloques les threads du pool,
tu peux réduire fortement la capacité de ton app à progresser.
Piège typique :
val lock = java.util.concurrent.locks.ReentrantLock()
suspend fun bad() {
lock.lock()
try {
delay(100) // très mauvais : on bloque un thread pendant une attente "suspendable"
} finally {
lock.unlock()
}
}
Ici, tu immobilises un thread inutilement pendant 100ms. Si cela arrive souvent, tu peux saturer le pool.
Version correcte :
val mutex = Mutex()
suspend fun good() {
mutex.withLock {
// pas de delay long ici, juste une mutation rapide
}
delay(100) // attente en dehors du lock
}
8) Règle d’or : ne garde pas un Mutex pendant une opération longue
Un Mutex protège une section critique. Si tu le gardes pendant un réseau/IO/delay, tu transformes ton application en “une seule tâche à la fois”.
Anti-pattern :
mutex.withLock {
val data = api.fetch() // long
cache.update(data) // mutation
}
Meilleure approche :
val data = api.fetch() // long, hors lock
mutex.withLock {
cache.update(data) // court, protégé
}
9) Mutex et annulation : ce qui se passe si une coroutine est annulée
En coroutines, l’annulation est un mécanisme normal. Si une coroutine est annulée pendant qu’elle attend un mutex, elle peut arrêter d’attendre.
Avec withLock, le pattern est sûr :
- si la coroutine obtient le lock, le bloc est exécuté,
- à la fin, le lock est relâché automatiquement, même en cas d’exception.
suspend fun safeUpdate() {
mutex.withLock {
// si une exception arrive ici, le mutex sera relâché
state.value++
}
}
Bon réflexe : utiliser withLock plutôt que lock()/unlock() manuels.
10) Thread confinement : alternative au Mutex
Parfois, le meilleur moyen d’éviter la synchronisation est de ne pas partager l’état :
- tu forces toutes les mutations sur un seul contexte (un seul “worker”),
- les autres coroutines envoient des demandes.
Exemples de techniques :
- un dispatcher à thread unique (rarement nécessaire, mais utile dans certains services),
- un actor-like pattern (channel + loop),
- un state reducer MVI sur Main (UI state),
Exemple simple (single-thread context) :
import kotlinx.coroutines.*
import java.util.concurrent.Executors
val single = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
suspend fun confinedExample() = withContext(single) {
// toutes les mutations ici sont sérialisées naturellement
}
Attention : cette approche peut devenir un goulot d’étranglement si trop de travail.
11) Exemple réaliste : cache mémoire protégé par Mutex + IO hors lock
class UserRepository(
private val api: Api
) {
private val mutex = Mutex()
private val cache = mutableMapOf<String, User>()
suspend fun getUser(id: String): User {
// 1) check cache (court)
mutex.withLock {
cache[id]?.let { return it }
}
// 2) fetch réseau (long, hors lock)
val user = api.fetchUser(id)
// 3) update cache (court)
mutex.withLock {
cache[id] = user
}
return user
}
}
Ce pattern évite :
- de bloquer un thread pendant le réseau,
- de sérialiser inutilement toutes les requêtes,
- de corrompre le cache.
12) Pièges classiques (et comment les éviter)
12.1 Deadlock par ordre de verrous
Si tu as plusieurs mutex/locks, garde un ordre d’acquisition constant.
12.2 Mélanger Mutex et locks JVM sans stratégie
Mélanger un ReentrantLock (bloquant thread) avec un Mutex (suspendable)
peut créer des scénarios difficiles à raisonner.
Si possible : unifier (coroutines => Mutex / thread-based => locks JVM).
12.3 Bloquer dans Dispatchers.Default
Si tu fais de l’IO bloquant, utilise Dispatchers.IO :
withContext(Dispatchers.IO) {
// appels bloquants (fichier, DB driver bloquant, etc.)
}
12.4 Oublier que “suspend” n’implique pas “thread-safe”
Une fonction suspend n’est pas automatiquement safe.
Si elle touche un état partagé, tu dois quand même synchroniser.
Conclusion
Les coroutines ne remplacent pas les threads : elles s’exécutent sur des threads, mais permettent d’attendre sans les bloquer.
Pour un état partagé entre coroutines, Mutex est souvent la bonne primitive car :
- il suspend au lieu de bloquer,
- il s’intègre naturellement avec
suspend, - il aide à garder un code scalable sous charge.
Les règles pratiques :
- Mutex pour protéger une mutation courte,
- IO/réseau hors lock,
- éviter les locks JVM dans du code coroutine-first,
- penser “suspension vs blocage” en permanence.