Heisenbug: wat is het, waarom ontstaat het en methodes om het te vangen

Auteur: IT Sectr Gepubliceerd: 2026-07-29 Leestijd: 10 min

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

  • Race condition — de hoofdoorzaak van Heisenbug: verandering van timing tijdens debuggen maskeert het probleem
  • Bohrbug — voorspelbare bug, gemakkelijk te reproduceren in tegenstelling tot Heisenbug
  • Mandelbug — bug met complexe oorzaak-gevolg relatie, gevoelig voor begincondities
  • ThreadSanitizer — tool voor detectie van dataraces zonder invloed op timing
  • Deterministische tests — de enige betrouwbare manier om Heisenbug te reproduceren

Wat is Heisenbug in mobiele ontwikkeling?

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.

Voorbeeld van Heisenbug

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, Mandelbug, Heisenbug: classificatie van bugs

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.

TypeReproduceerbaarheidReactie op debuggenVoorbeeld
Bohrbug100%Verandert nietNPE bij lege lijst
MandelbugChaotischVerandert nietCrash op Android 12, Samsung, bij lage batterij
HeisenbugAlleen zonder debuggenVerdwijntRace condition die verdwijnt met logs
SchrödinbugVerschijnt niet in codeVerschijnt bij ernaar kijkenBug zichtbaar in code maar treedt nooit op

Belangrijkste oorzaken van Heisenbug

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.

kotlin
// 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.

  • ThreadLocal — onjuist gebruik van thread-local variabelen die niet zichtbaar zijn voor andere threads
  • Niet-geïnitialiseerde variabelen — code die vertrouwt op standaardwaarden van klassevelden
  • GCD/dispatch queues — in iOS ongedefinieerde volgorde van blokuitvoering in concurrent queues
  • Gebufferde I/O — gegevens worden niet naar schijf geschreven tot de buffer vol is

Strategieën om ongrijpbare bugs te vangen

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.

kotlin
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.

Preventie van Heisenbug op architectuurniveau

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.

kotlin
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

Waarom is Heisenbug zo moeilijk te vinden?

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.

Waarin verschilt Heisenbug van Mandelbug?

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().

Hoe test je Heisenbug in CI/CD?

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.

Helpen Flow/Coroutines om Heisenbug te voorkomen?

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.

Wat te doen als Heisenbug alleen in productie verschijnt?

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

  • Heisenbug — bug die verdwijnt bij poging tot debuggen; hoofdoorzaak — verandering van timing door ontwikkeltools
  • Race condition — de hoofdoorzaak van Heisenbug in mobiele apps, vooral in asynchrone code
  • Bohrbug (100% reproduceerbaar) en Mandelbug (chaotisch) — andere bugtypes, niet verwarren met Heisenbug
  • ThreadSanitizer — de beste tool voor detectie van dataraces, zonder invloed op execution timing
  • Cyclisch loggen in geheugen in plaats van schijf — manier om gegevens te verzamelen zonder Heisenbug te maskeren
  • Unidirectional Data Flow en minimalisatie van gedeelde veranderlijke toestand — architectuurpreventie van een hele klasse fouten
  • StrictMode in debug build verandert een potentiële Heisenbug in een deterministische Bohrbug, direct zichtbaar

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.

Bespreek het project

Lees ook