Deadlock (blocage mutuel) est un état dans lequel deux threads ou plus attendent indéfiniment la libération de ressources détenues par d'autres participants. Selon Oracle Java Tutorials (2024), Deadlock survient lors d'une attente circulaire où chaque thread détient un verrou nécessaire à un autre thread. Sans outils de détection spéciaux, Deadlock arrête complètement l'exécution de l'application sans erreurs visibles.
Points clés
Deadlock est une situation en programmation multithread où deux threads ou plus se bloquent mutuellement de façon permanente. Chaque thread détient une ressource nécessaire à un autre thread et ne la libère pas en attendant d'acquérir la ressource manquante. Par conséquent, aucun des threads ne peut poursuivre son exécution.
Dans le développement mobile, Deadlock est particulièrement critique car il ne provoque ni exceptions ni plantages. L'application cesse simplement de répondre aux actions de l'utilisateur (ANR — Application Not Responding), et la seule issue est de forcer la fin du processus. Selon Google (Android Performance Patterns, 2023), environ 15 % des rapports ANR dans Google Play Console sont liés à des blocages mutuels dans les threads d'arrière-plan.
La principale différence entre Deadlock et les autres problèmes de concurrence est son irréversibilité sans intervention extérieure. Les threads ne libéreront pas les ressources d'eux-mêmes car l'ordonnanceur du système d'exploitation ne peut pas révoquer un verrou de force. Cela distingue Deadlock de Livelock, où les threads sont actifs mais n'effectuent pas de travail utile.
En 1971, Edward G. Coffman a formulé quatre conditions obligatoires nécessaires à l'apparition de Deadlock. Si au moins l'une d'elles est absente, le blocage mutuel est impossible. Ces conditions sont connues sous le nom de conditions de Coffman et constituent la base de tous les algorithmes de prévention de Deadlock.
Une ressource ne peut être acquise que par un seul thread à la fois. Si une ressource permet la lecture simultanée par plusieurs threads (par exemple, ReadWriteLock en mode lecture), Deadlock ne se produit pas. Cette condition découle de la nature même du Mutex et des verrous.
Un thread détient une ressource déjà acquise et attend simultanément d'acquérir une autre ressource. Si un thread peut libérer la ressource actuelle avant de demander la suivante (via un verrouillage en deux phases), la condition Hold and Wait est rompue. Sous Android, cela se manifeste souvent lorsqu'un thread détient un verrou de base de données et tente d'acquérir un verrou SharedPreferences.
Le système d'exploitation ne peut pas retirer de force un verrou à un thread. La ressource n'est libérée que lorsque le thread lui-même la libère. Dans certains systèmes (par exemple, SQLite en mode WAL), la préemption forcée est implémentée au niveau des opérations individuelles, ce qui réduit le risque de Deadlock.
Il existe une chaîne fermée de threads, chacun attendant une ressource détenue par le suivant dans la chaîne. Par exemple, le thread A détient la ressource 1 et attend la ressource 2, le thread B détient la ressource 2 et attend la ressource 1. C'est la seule condition qu'un développeur peut éliminer architecturalement — via une hiérarchie de verrous. Si tous les threads acquièrent les ressources dans un ordre global strictement défini, un cycle est physiquement impossible.
En pratique, dans les applications Android, Deadlock survient le plus souvent en raison de l'intersection implicite de verrous de différents niveaux : verrou de base de données (Room), verrou SharedPreferences et verrou de collection en mémoire. Chacun de ces verrous est géré par différents composants, et sans protocole centralisé d'ordre d'acquisition, les développeurs créent involontairement des cycles.
Considérons un exemple classique de blocage mutuel — deux threads acquièrent des verrous dans un ordre différent. Si le premier thread verrouille la ressource A et tente d'acquérir B, et le second verrouille B et tente d'acquérir A, Deadlock se produit.
class DeadlockExample {
private val lockA = Any()
private val lockB = Any()
fun operationA() {
synchronized(lockA) {
Thread.sleep(50) // simulation de travail
synchronized(lockB) {
println("operationA terminée")
}
}
}
fun operationB() {
synchronized(lockB) { // ordre inverse
Thread.sleep(50)
synchronized(lockA) {
println("operationB terminée")
}
}
}
}
fun main() {
val ex = DeadlockExample()
Thread { ex.operationA() }.start()
Thread { ex.operationB() }.start()
// L'application se bloquera pour toujours — Deadlock !
}
Dans cet exemple, operationA acquiert lockA, et operationB acquiert lockB. Ensuite, chacun tente d'acquérir le second verrou — et les deux attendent indéfiniment. Le programme se bloque sans message d'erreur. La seule façon de corriger est de garantir le même ordre d'acquisition des verrous dans toutes les méthodes.
Ces trois problèmes de concurrence sont souvent confondus, mais leurs mécanismes et conséquences sont fondamentalement différents. Deadlock — arrêt complet, Starvation — attente infinie d'une ressource, Livelock — inactivité active. Comprendre les différences est crucial pour choisir la bonne stratégie de résolution.
| Caractéristique | Deadlock | Starvation | Livelock |
|---|---|---|---|
| État des threads | Bloqués (BLOCKED) | Prêts (RUNNABLE) | Actifs (RUNNABLE) |
| Travail effectué | Non | Non | Oui, mais inutile |
| Cause | Attente circulaire | Ordonnancement injuste | Gestion incorrecte des conflits |
| Détection | Thread Dump, timeouts | Suivi de progression | Compteur de tentatives |
Starvation (famine) se produit lorsque l'ordonnanceur retarde constamment l'exécution d'un thread de faible priorité au profit d'autres. Contrairement à Deadlock, le thread n'est pas bloqué — il est prêt à s'exécuter mais n'obtient pas de temps CPU. Sous Android, un scénario typique est un thread d'arrière-plan de faible priorité qui ne s'exécute jamais si le thread UI et les threads Service sont constamment actifs.
Livelock (verrou actif) est une situation où les threads ne sont pas bloqués mais réagissent infiniment aux actions des uns et des autres sans effectuer de travail utile. L'analogie classique — deux personnes se rencontrent dans un couloir et essaient toutes deux de céder le passage, se déplaçant dans la même direction. Contrairement à Deadlock, les threads en Livelock consomment du CPU, vidant la batterie de l'appareil.
Thread Dump est l'outil principal pour détecter les blocages mutuels dans JVM et Android Runtime. Lors d'un dump, la JVM analyse automatiquement le graphe de dépendances entre les moniteurs et marque les cycles de Deadlock. Dans Android Studio, les dumps de threads peuvent être obtenus via Android Profiler ou la commande kill -3 PID depuis ADB Shell.
La détection automatique de Deadlock à l'exécution est implémentée via des temporisateurs Watchdog. Si un thread ne termine pas une opération dans un délai imparti, le watchdog initie un dump et envoie un rapport au système de Crash Reporting (Firebase Crashlytics, Sentry). Selon Sentry (Issue Resolution Report, 2024), la configuration d'un watchdog réduit le temps de diagnostic de Deadlock de semaines à quelques heures.
Pendant le développement, l'analyseur statique ThreadSafe de JetBrains et Checker Framework avec le module Lock Checker sont efficaces. Ces outils analysent l'ordre d'acquisition des verrous au niveau du code source et avertissent des cycles potentiels. De plus, la détection de Deadlock pilotée par les tests (Test-Driven Deadlock Detection) est recommandée — des tests de stress qui exécutent des opérations avec différents ordres de verrouillage dans des centaines de threads.
Une attention particulière mérite la Cooperative Deadlock Detection — une méthode où les threads échangent des informations sur les verrous acquis via un registre global. Si un thread détecte un cycle potentiel, il libère toutes les ressources et réessaie l'opération. Cette approche est utilisée dans les systèmes distribués (Apache ZooKeeper, Google Chubby) et est progressivement adoptée dans le développement mobile via des bibliothèques comme Jetpack Sync.
La méthode la plus fiable consiste à établir un ordre global d'acquisition des verrous dans toute l'application. Si tous les threads acquièrent toujours d'abord le verrou avec le numéro le plus petit, puis celui avec le numéro le plus grand, l'attente circulaire (condition Circular Wait) est impossible. Dans les grands projets, l'ordre est documenté et vérifié par la revue de code.
TryLock est une méthode de verrouillage qui ne bloque pas un thread indéfiniment mais renvoie false si le verrou n'est pas acquis dans un délai spécifié. En Java, cela est implémenté via ReentrantLock.tryLock(timeout, TimeUnit), en Kotlin Coroutines — via Mutex.withLock avec un délai d'attente. En cas d'échec, le thread libère toutes les ressources acquises et réessaie plus tard.
Algorithme du banquier est une méthode théorique de prévention de Deadlock proposée par Edsger Dijkstra. Il modélise l'allocation des ressources comme des transactions bancaires : le système n'alloue pas une ressource si cela pourrait conduire à un état dangereux (deadlock). En pratique, l'algorithme est rarement utilisé dans le développement mobile en raison de la difficulté de connaître à l'avance les besoins maximaux des threads, mais ses principes sont utilisés dans les bases de données SQLite et les systèmes de fichiers.
Questions fréquentes
Non, le blocage mutuel nécessite au moins deux threads. Dans le code mono-thread, toutes les opérations sont exécutées séquentiellement, donc l'attente circulaire est impossible. Cependant, Deadlock peut se produire entre processus lors de l'utilisation de verrous de fichiers ou de sémaphores interprocessus.
Dans les coroutines, Deadlock se produit au niveau des fonctions suspend et ne bloque pas le thread OS, ce qui le rend moins perceptible. Mutex de kotlinx.coroutines est un verrou suspendu (suspending) — il ne bloque pas le thread, mais la coroutine ne s'exécute pas. Pour la détection, utilisez DebugProbes du module kotlinx-coroutines-debug.
Deadlock dans SQLite se produit lorsque deux connexions à la base de données tentent d'exécuter des transactions dans des ordres différents. SQLite détecte ces situations et renvoie le code d'erreur SQLITE_BUSY ou SQLITE_LOCKED. Sous Android, il est recommandé d'utiliser Room avec une seule instance de base de données et des transactions via @Transaction, ce qui élimine le Deadlock inter-connexions.
Android Runtime dispose d'un détecteur de Deadlock intégré qui s'exécute lors de la génération d'un ANR (Application Not Responding). Le système analyse le Thread Dump de tous les threads de l'application et marque les blocages mutuels. Le résultat est disponible dans /data/anr/traces.txt et dans Google Play Console dans la section ANR Reports.
Commencez par obtenir un Thread Dump de tous les threads de l'application. Analysez quels verrous chaque thread détient et lesquels il tente d'acquérir. Implémentez un temporisateur Watchdog avec dump automatique en cas de dépassement du délai. Après correction, ajoutez la règle lint ThreadSafety à votre pipeline CI pour prévenir les récidives.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi