Heisenbug — een bug die verdwijnt wanneer je hem probeert te debuggen. De term komt van het onzekerheidsprincipe van Heisenberg: observatie beïnvloedt het gedrag van het systeem. In mobiele ontwikkeling is Heisenbug een van de moeilijkste problemen omdat standaard debugmethodes (logs, breakpoints, extra code) de programmastatus veranderen en de bug verbergen. We bespreken de oorzaken en methodes om ongrijpbare fouten te bestrijden.
Belangrijkste punten
Heisenbug — een klasse fouten die verschijnen in de productieomgeving of bij normaal gebruik, maar verdwijnen bij poging tot reproductie in een debugomgeving. De term werd in de jaren 80 geïntroduceerd door programmeur Jim Gray in de context van gedistribueerde systemen, maar is vandaag het meest relevant voor mobiele apps vanwege hun asynchrone aard.
Hoofdoorzaak: standaard debugtools veranderen de uitvoeringsomgeving. Breakpoint stopt de thread voor enkele milliseconden, loggen voegt synchrone I/O toe, extra controles veranderen de volgorde van bewerkingen. In een multi-threaded omgeving kan zelfs een microseconde vertraging de uitvoervolgorde van threads veranderen en een datarace verbergen.
Volgens Microsoft Research (2022) wordt ongeveer 15-25% van alle bugs in multi-threaded mobiele apps geclassificeerd als Heisenbug. De tijd om een Heisenbug te vinden en te repareren is gemiddeld 5-10 keer langer dan voor een gewone bug, vanwege de onmogelijkheid van directe reproductie.
De app crasht in productie bij snel vegen door een lijst, maar wanneer de debugger wordt aangesloten of logs worden toegevoegd, werkt hij perfect. Oorzaak: een datarace tussen de UI-thread (RecyclerView updaten) en de achtergrondthread (adaptergegevens updaten). Logs voegen een vertraging toe die de threads willekeurig synchroniseert.
Bohrbug — een voorspelbare, stabiel reproduceerbare bug. Genoemd naar analogie met het atoommodel van Bohr: zoals een atoom, gedraagt de bug zich bij elke observatie hetzelfde. Voorbeeld: NullPointerException bij klikken op een knop voordat gegevens zijn geladen. Wordt behandeld met standaard unittesten.
Mandelbug — een bug met een complexe, chaotische oorzaak-gevolg relatie (genoemd naar analogie met de Mandelbrot-verzameling). Verschijnt alleen bij een specifieke combinatie van omstandigheden: OS-versie, apparaatmodel, netwerkstatus, maanfase. Verschilt van Heisenbug doordat hij niet verdwijnt bij debuggen — het probleem zit in de moeilijkheid van reproductie, niet in gedragsverandering door tools.
Heisenbug — een bug die juist door debugtools verdwijnt. Als je een log toevoegt — verdwijnt de bug. Als je een breakpoint plaatst — verschijnt de bug niet. Als je alles verwijdert — komt de bug terug. Hoofdoorzaak: veranderde timing tijdens debuggen.
| Type | Reproduceerbaarheid | Reactie op debuggen | Voorbeeld |
|---|---|---|---|
| Bohrbug | 100% | Verandert niet | NPE bij lege lijst |
| Mandelbug | Chaotisch | Verandert niet | Crash op Android 12, Samsung, bij lage batterij |
| Heisenbug | Alleen zonder debuggen | Verdwijnt | Race condition die verdwijnt met logs |
| Schrödinbug | Verschijnt niet in code | Verschijnt bij ernaar kijken | Bug zichtbaar in code maar treedt nooit op |
Race condition — nummer een onder de oorzaken van Heisenbug. Twee threads krijgen toegang tot gedeelde gegevens zonder synchronisatie. De debugger introduceert een vertraging waardoor de threads de kans krijgen om op natuurlijke wijze te synchroniseren. Zonder debugger is de uitvoervolgorde onvoorspelbaar.
Timing-afhankelijke fouten — bugs die alleen bij een bepaalde uitvoersnelheid verschijnen. Bijvoorbeeld een animatie die moet zijn voltooid voordat de volgende bewerking begint. In de debugger gaat de animatie langzamer en kan de bewerking na de animatie beginnen. In productie — andersom.
// Voorbeeld van race condition — typische Heisenbug
class ListViewModel : ViewModel() {
private var items = mutableListOf<String>()
fun loadFromNetwork() {
viewModelScope.launch(Dispatchers.IO) {
val result = api.fetchItems()
items.addAll(result) // ❌ Niet thread-safe
}
}
fun getItems(): List<String> = items.toList()
// Race condition: getItems lezen kan overlappen met loadFromNetwork schrijven
}
Compileroptimalisatie — de compiler (JIT, ART, Kotlin/Native) kan instructies herordenenen voor optimalisatie. In debug build zijn optimalisaties uitgeschakeld en wordt code uitgevoerd „n zoals geschreven“. In release build verandert de compiler de volgorde van bewerkingen, wat verborgen aannames in de code kan onthullen.
ThreadSanitizer (TSan) — Google's tool voor detectie van dataraces in C/C++ en Kotlin/Native. Wordt in de build geïntegreerd en detecteert elke toegang tot gedeeld geheugen zonder synchronisatie. In tegenstelling tot logs beïnvloedt TSan de timing niet, omdat het via instrumented code werkt, niet via I/O.
Deterministische tests — vervang echte asynchronie door gecontroleerde. Gebruik TestDispatcher (Kotlin), RxJava Plugins of GCD test queues (iOS) voor volledige controle over de uitvoervolgorde. Stel specifieke scenario's in: thread A wordt uitgevoerd, dan B, dan A opnieuw.
Cyclisch loggen — loggen naar een cyclische buffer in het geheugen (niet naar schijf). Wanneer de bug optreedt, wordt de buffer opgeslagen in een bestand. Omdat schrijven naar geheugen nanoseconden duurt (in plaats van milliseconden voor schijf-I/O), beïnvloedt zo'n log de timing niet en maskeert hij Heisenbug niet.
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) } }
}
}
Loggen in productie — als de bug zich lokaal niet laat reproduceren, verzamel dan gegevens in productie. Gebruik Firebase Crashlytics logs, Sentry Breadcrumbs of een aangepaste cyclische logger. Belangrijk: loggen moet asynchroon zijn en minimale invloed hebben op prestaties.
Isolatie van toestand — minimaliseer gedeelde veranderlijke toestand. Elk component moet zijn eigen geïsoleerde toestand hebben, ontoegankelijk voor direct schrijven vanuit andere componenten. Gebruik Unidirectional Data Flow (UDF) — toestand stroomt in één richting: Event → Reducer → State → UI.
Functionele benadering — pure functies zonder bijwerkingen zijn gemakkelijker te testen en debuggen. Isoleer bijwerkingen (netwerk, database, bestanden) in strikt gedefinieerde lagen (repository, data source). Thread-gerelateerde fouten in functionele code zijn praktisch onmogelijk.
Strict mode — schakel Android StrictMode in debug build in. Het detecteert schendingen van het threadingbeleid (netwerk op main thread, schijf-I/O op main thread) en gooit een uitzondering. Dit verandert een potentiële Heisenbug in een deterministische Bohrbug die direct zichtbaar is.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
Code review met focus op asynchronie — verplicht onderdeel van het proces. Elke pull request moet worden gecontroleerd op gedeelde veranderlijke toestand, niet-thread-safe collecties, ontbrekende synchronisatie. Gebruik lint-regels voor automatische verbod op bepaalde patronen (bijv. toegang tot MutableList zonder synchronized).
Veelgestelde vragen
Omdat standaardmethodes — breakpoints, logs, print — de uitvoeringsomgeving zo sterk veranderen dat de bug stopt met verschijnen. De debugger stopt alle threads voor tientallen milliseconden. In die tijd lost de datarace die de bug veroorzaakte zich op natuurlijke wijze op. Tools die de execution timing niet beïnvloeden, zijn nodig.
Mandelbug is moeilijk te reproduceren vanwege de complexiteit van de omstandigheden, maar debugtools beïnvloeden het verschijnen ervan niet. Heisenbug verdwijnt juist door debugtools. Voorbeeld van Mandelbug: crash alleen op apparaten met Android 11, 3 GB RAM en bij batterijniveau onder 15%. Voorbeeld van Heisenbug: datarace die verdwijnt bij toevoegen van Log.d().
Voer flaky test detection uit — tests die soms falen, soms slagen. Gebruik in Android Android Test Orchestrator voor testisolatie. Voeg StrictMode toe aan debugtests. Instrumenteer de build met ThreadSanitizer. Als een test in >5% van de runs flaky is — beschouw hem dan als potentiële Heisenbug en onderzoek voor merge.
Gedeeltelijk. Flow en structured concurrency in Kotlin verminderen de hoeveelheid gedeelde veranderlijke toestand en vereenvoudigen threadbeheer. Maar coroutines garanderen geen threadveiligheid: als twee coroutines gedeelde toestand hebben, is een datarace nog steeds mogelijk. Gebruik Mutex voor het beschermen van gedeelde toestand of Channel voor gegevensoverdracht tussen coroutines.
Gebruik een cyclische logbuffer in het geheugen met automatische dumping bij fouten. Voeg gedetailleerde monitoring toe via Crashlytics of Sentry met aangepaste breadcrumbs. Schakel voor Android ANR detection in en bekijk traces. Als de bug een datarace is, kan ThreadSanitizer in debug build met productie-achtige belasting het probleem onthullen.
Samenvatting
We ontwikkelen een mobiele applicatie turnkey
IT Sectr creëert sinds 2017 iOS- en Android-applicaties voor startups en bedrijven. We adviseren u en stellen de beste oplossing voor.
Lees ook