Heisenbug — un bogue qui disparaît lorsqu’on essaie de le dëoguer. Le terme vient du principe d’incertitude d’Heisenberg : l’observation affecte le comportement du système. Dans le développement mobile, Heisenbug est l’un des problèmes les plus difficiles car les méthodes standard de débogage (logs, points d’arrêt, code supplémentaire) modifient l’état du programme et cachent le bogue. Explorons les causes et les méthodes pour faire face aux erreurs insaisissables.
Points Clés
Heisenbug — une classe d’erreurs qui se manifestent en production ou pendant le fonctionnement normal, mais disparaissent lorsqu’on essaie de les reproduire dans un environnement de débogage. Le terme a été inventé dans les années 1980 par le programmeur Jim Gray dans le contexte des systèmes distribués, mais il est le plus pertinent aujourd’hui pour les applications mobiles en raison de leur nature asynchrone.
La raison principale : les outils de débogage standard modifient l’environnement d’exécution. Un point d’arrêt suspend le thread pendant plusieurs millisecondes, la journalisation ajoute des E/S synchrones, des vérifications supplémentaires changent l’ordre des opérations. Dans un environnement multithreadé, même un retard de microseconde peut modifier l’ordre d’exécution des threads et cacher une course de données.
Selon Microsoft Research (2022), environ 15 à 25 % de tous les bogues dans les applications mobiles multithreadées sont classés comme Heisenbug. Dans le même temps, le temps pour trouver et corriger un Heisenbug est en moyenne 5 à 10 fois plus long qu’un bogue normal, en raison de l’impossibilité de reproduction directe.
Une application plante en production lors d’un balayage rapide d’une liste, mais une fois connectée au débogueur ou avec des logs ajoutés — elle fonctionne parfaitement. Cause : une course de données entre le thread UI (mise à jour de RecyclerView) et le thread d’arrière-plan (mise à jour des données de l’adaptateur). Les logs ajoutent un retard qui synchronise les threads aléatoirement.
Bohrbug — un bogue prévisible, stablement reproductible. Nommé par analogie avec le modèle atomique de Bohr : comme un atome, le bogue se comporte de la même façon à chaque observation. Exemple : NullPointerException en cliquant sur un bouton avant le chargement des données. Traité par des tests unitaires standard.
Mandelbug — un bogue avec une relation causale complexe et chaotique (nommé par analogie avec l’ensemble de Mandelbrot). Se manifeste seulement sous une certaine combinaison de conditions : version de l’OS, modèle de l’appareil, état du réseau. Il diffère de Heisenbug en ce qu’il ne disparaît pas pendant le débogage — le problème est la difficulté de reproduction, pas un changement de comportement dû aux outils.
Heisenbug — un bogue qui disparaît précisément à cause des outils de débogage. Si vous ajoutez un log — le bogue disparaît. Si vous placez un point d’arrêt — le bogue ne se manifeste pas. Si vous retirez tout — le bogue revient. La cause principale : un timing modifié pendant le débogage.
| Type | Reproductibilité | Réaction au débogage | Exemple |
|---|---|---|---|
| Bohrbug | 100 % | Inchangée | NPE sur liste vide |
| Mandelbug | Chaotique | Inchangée | Plantage sur Android 12, Samsung, batterie faible |
| Heisenbug | Seulement sans débogage | Disparaît | Course de données disparaissant avec les logs |
| Schrödinbug | Ne se manifeste pas dans le code | Apparaît en regardant | Bogue visible dans le code mais ne se déclenche jamais |
Race condition — la cause numéro un de Heisenbug. Deux threads accèdent à des données partagées sans synchronisation. Le débogueur introduit un retard, ce qui fait que les threads se synchronisent naturellement. Sans le débogueur, l’ordre d’exécution est imprévisible.
Erreurs dépendantes du timing — des bogues qui se manifestent seulement à une certaine vitesse d’exécution. Par exemple, une animation qui doit se terminer avant le début de l’opération suivante. Dans le débogueur, l’animation est plus lente et l’opération a le temps de commencer après la fin de l’animation. En production — l’inverse.
// Example race condition — typical Heisenbug
class ListViewModel : ViewModel() {
private var items = mutableListOf<String>()
fun loadFromNetwork() {
viewModelScope.launch(Dispatchers.IO) {
val result = api.fetchItems()
items.addAll(result) // ❌ Not thread-safe
}
}
fun getItems(): List<String> = items.toList()
// Race condition: getItems read may overlap with loadFromNetwork write
}
Optimisation du compilateur — le compilateur (JIT, ART, Kotlin/Native) peut réordonner les instructions pour l’optimisation. Dans une compilation de débogage (debug build), les optimisations sont désactivées et le code s’exécute « tel qu’écrit ». Dans une compilation de release, le compilateur modifie l’ordre des opérations, ce qui peut révéler des hypothèses cachées dans le code.
ThreadSanitizer (TSan) — un outil de Google pour détecter les courses de données en C/C++ et Kotlin/Native. Il est intégré dans la compilation et détecte tout accès à la mémoire partagée sans synchronisation. Contrairement aux logs, TSan n’affecte pas le timing car il fonctionne via du code instrumenté, pas via des E/S.
Tests déterministes — remplacez l’asynchronisme réel par un asynchronisme contrôlé. Utilisez TestDispatcher (Kotlin), RxJava Plugins ou des files de test GCD (iOS) pour un contrôle total sur l’ordre d’exécution. Spécifiez des scénarios concrets : le thread A s’exécute, puis B, puis à nouveau A.
Journalisation cyclique — enregistrement dans un tampon circulaire en mémoire (pas sur disque). Lorsque le bogue se produit, le tampon est sauvegardé dans un fichier. Comme l’écriture en mémoire prend des nanosecondes (au lieu de millisecondes pour les E/S disque), cette journalisation n’affecte pas le timing et ne masque pas le Heisenbug.
class CyclicBuffer(val capacity: Int = 1000) {
private val buffer = ArrayDeque<String>(capacity)
private val lock = Any()
fun log(message: String) {
synchronized(lock) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.addLast(message)
}
}
fun flush() {
synchronized(lock) { buffer.forEach { fileWriter.write(it) } }
}
}
Journalisation en production — si le bogue n’est pas reproductible localement, collectez des données en production. Utilisez les logs Firebase Crashlytics, Sentry Breadcrumbs ou un journaliste cyclique personnalisé. Important : la journalisation doit être asynchrone et avoir un impact minimal sur les performances.
Isolation d’état — minimisez l’état mutable partagé. Chaque composant doit avoir son propre état isolé, inaccessible en écriture directe depuis d’autres composants. Utilisez Unidirectional Data Flow (UDF) — l’état circule dans une direction : Événement → Réducteur → État → UI.
Approche fonctionnelle — les fonctions pures sans effets secondaires sont plus faciles à tester et déboguer. Isolez les effets secondaires (réseau, BD, fichiers) dans des couches strictement définies (référentiel, source de données). Les erreurs de concurrence dans le code fonctionnel sont pratiquement impossibles.
Mode strict — activez Android StrictMode dans la compilation de débogage. Il détecte les violations de la politique de threading (réseau sur le thread principal, E/S disque sur le thread principal) et lance une exception. Cela transforme un Heisenbug potentiel en un Bohrbug déterministe immédiatement visible.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
Revue de code axée sur l’asynchronisme — une partie obligatoire du processus. Chaque pull request doit vérifier la présence d’état mutable partagé, de collections non thread-safe et d’absence de synchronisation. Utilisez des règles lint pour interdire automatiquement certains motifs (par exemple, accéder à MutableList sans synchronized).
Questions Fréquentes
Parce que les méthodes standard — points d’arrêt, logs, print — modifient tellement l’environnement d’exécution que le bogue cesse de se manifester. Le débogueur suspend tous les threads pendant des dizaines de millisecondes. Pendant ce temps, la course de données qui causait le bogue se résout naturellement. Des outils qui n’affectent pas le timing d’exécution sont nécessaires.
Mandelbug est difficile à reproduire en raison de la complexité des conditions, mais les outils de débogage n’affectent pas sa manifestation. Heisenbug disparaît précisément à cause des outils de débogage. Exemple de Mandelbug : un plantage seulement sur les appareils avec Android 11, 3 Go de RAM et un niveau de batterie inférieur à 15 %. Exemple de Heisenbug : une course de données qui disparaît lors de l’ajout de Log.d().
Utilisez la détection de tests flaky — des tests qui échouent parfois, réussissent parfois. Sous Android, utilisez Android Test Orchestrator pour isoler les tests. Ajoutez StrictMode aux tests de débogage. Instrumentez la compilation avec ThreadSanitizer. Si un test est flaky >5 % des exécutions — considérez-le comme un Heisenbug potentiel et enquêtez avant la fusion.
Partiellement. Flow et la concurrence structurée en Kotlin réduisent la quantité d’état mutable partagé et simplifient la gestion des threads. Mais les coroutines ne garantissent pas la sécurité des threads : si deux coroutines partagent un état, une course de données est toujours possible. Utilisez Mutex pour protéger l’état partagé ou Channel pour passer des données entre coroutines.
Utilisez un tampon de journal cyclique en mémoire avec vidage automatique en cas d’erreur. Ajoutez une surveillance détaillée via Crashlytics ou Sentry avec des breadcrumbs personnalisés. Pour Android, activez la détection ANR et consultez les traces. Si le bogue est une course de données, ThreadSanitizer dans une compilation de débogage avec une charge similaire à la production peut révéler le problème.
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