Heisenbug: co to, dlaczego powstaje i metody wyłapywania

Autor: IT Sectr Opublikowano: 2026-07-29 Czas czytania: 10 min

Heisenbug — błąd, który znika podczas próby debugowania. Termin pochodzi od zasady nieoznaczoności Heisenberga: obserwacja wpływa na zachowanie systemu. W programowaniu mobilnym Heisenbug to jeden z najtrudniejszych problemów, ponieważ standardowe metody debugowania (logi, breakpointy, dodatkowy kod) zmieniają stan programu i ukrywają błąd. Przeanalizujemy przyczyny występowania i metody walki z nieuchwytnymi błędami.

Najważniejsze

  • Race condition — główna przyczyna Heisenbug: zmiana timingowania podczas debugowania maskuje problem
  • Bohrbug — przewidywalny błąd, łatwo odtwarzalny w przeciwieństwie do Heisenbug
  • Mandelbug — błąd o złożonym związku przyczynowo-skutkowym, wrażliwy na warunki początkowe
  • ThreadSanitizer — narzędzie do wykrywania wyścigów danych, nie wpływające na timing
  • Testy deterministyczne — jedyny niezawodny sposób odtworzenia Heisenbug

Czym jest Heisenbug w programowaniu mobilnym?

Heisenbug — klasa błędów, które pojawiają się w środowisku produkcyjnym lub podczas normalnej pracy, ale znikają przy próbie ich odtworzenia w środowisku debugowania. Termin został wprowadzony w latach 80. XX wieku przez programistę Jima Graya w kontekście systemów rozproszonych, ale jest najbardziej aktualny dziś dla aplikacji mobilnych ze względu na ich asynchroniczną naturę.

Główna przyczyna: standardowe narzędzia debugowania zmieniają środowisko wykonania. Breakpoint zatrzymuje wątek na kilka milisekund, logowanie dodaje synchroniczne I/O, dodatkowe sprawdzenia zmieniają kolejność operacji. W środowisku wielowątkowym nawet mikrosekundowe opóźnienie może zmienić kolejność wykonywania wątków i ukryć wyścig danych.

Według danych Microsoft Research (2022), około 15-25% wszystkich błędów w wielowątkowych aplikacjach mobilnych klasyfikowanych jest jako Heisenbug. Czas na znalezienie i naprawę jednego Heisenbug jest średnio 5-10 razy dłuższy niż w przypadku zwykłego błędu, z powodu niemożności bezpośredniego odtworzenia.

Przykład Heisenbug

Aplikacja wysypuje się w środowisku produkcyjnym przy szybkim przesunięciu listy, ale po podłączeniu debuggera lub dodaniu logów działa idealnie. Przyczyna: wyścig danych między wątkiem UI (aktualizacja RecyclerView) a wątkiem tła (aktualizacja danych adaptera). Logi dodają opóźnienie, które synchronizuje wątki w sposób przypadkowy.

Bohrbug, Mandelbug, Heisenbug: klasyfikacja błędów

Bohrbug — przewidywalny, stabilnie odtwarzalny błąd. Nazwany analogicznie do atomowego modelu Bohra: jak atom, błąd zachowuje się tak samo przy każdej obserwacji. Przykład: NullPointerException przy kliknięciu przycisku przed załadowaniem danych. Leczy się standardowym testowaniem jednostkowym.

Mandelbug — błąd o złożonym, chaotycznym związku przyczynowo-skutkowym (nazwany analogicznie do zbioru Mandelbrota). Ujawnia się tylko przy określonej kombinacji warunków: wersja systemu operacyjnego, model urządzenia, stan sieci, faza księżyca. Różni się od Heisenbug tym, że nie znika podczas debugowania — problem tkwi w trudności odtworzenia, a nie w zmianie zachowania pod wpływem narzędzi.

Heisenbug — błąd, który znika właśnie z powodu narzędzi debugowania. Jeśli dodasz log — błąd znika. Jeśli postawisz breakpoint — błąd się nie pojawia. Jeśli usuniesz wszystko — błąd wraca. Główna przyczyna: zmieniony timing podczas debugowania.

TypOdtwarzalnośćReakcja na debugowaniePrzykład
Bohrbug100%Nie zmienia sięNPE przy pustej liście
MandelbugChaotycznaNie zmienia sięCrash na Android 12, Samsung, przy niskim poziomie baterii
HeisenbugTylko bez debugowaniaZnikaRace condition, znikająca z logami
SchrödinbugNie ujawnia się w kodziePojawia się przy spojrzeniuBłąd widoczny w kodzie, ale nigdy nie występuje

Główne przyczyny występowania Heisenbug

Race condition — numer jeden wśród przyczyn Heisenbug. Dwa wątki uzyskują dostęp do wspólnych danych bez synchronizacji. Debugger wprowadza opóźnienie, przez które wątki zdążą zsynchronizować się naturalnie. Bez debuggera kolejność wykonania jest nieprzewidywalna.

Błędy zależne od timingowania — błędy ujawniające się tylko przy określonej prędkości wykonania. Na przykład animacja, która powinna zakończyć się przed rozpoczęciem następnej operacji. W debuggerze animacja działa wolniej i operacja zdąża rozpocząć się po zakończeniu animacji. W środowisku produkcyjnym — odwrotnie.

kotlin
// Przykład race condition — typowy Heisenbug
class ListViewModel : ViewModel() {
    private var items = mutableListOf<String>()

    fun loadFromNetwork() {
        viewModelScope.launch(Dispatchers.IO) {
            val result = api.fetchItems()
            items.addAll(result) // ❌ Nie jest bezpieczny wątkowo
        }
    }

    fun getItems(): List<String> = items.toList()
    // Race condition: odczyt getItems może nakładać się na zapis loadFromNetwork
}

Optymalizacja kompilatora — kompilator (JIT, ART, Kotlin/Native) może przestawić instrukcje w celu optymalizacji. W kompilacji debugowania (debug build) optymalizacje są wyłączone i kod wykonuje się „tak jak napisany“. W release build kompilator zmienia kolejność operacji, co może ujawnić ukryte założenia w kodzie.

  • ThreadLocal — nieprawidłowe użycie zmiennych thread-local, które nie są widoczne dla innych wątków
  • Niezainicjalizowane zmienne — kod polegający na domyślnych wartościach pól klasy
  • Kolejki GCD/dispatch — w iOS nieokreślona kolejność wykonywania bloków w kolejkach concurrent
  • Buforowane I/O — dane nie są zapisywane na dysk, dopóki bufor nie zostanie wypełniony

Strategie wyłapywania nieuchwytnych błędów

ThreadSanitizer (TSan) — narzędzie Google do wykrywania wyścigów danych w C/C++ i Kotlin/Native. Wbudowuje się w kompilację i wykrywa każdy dostęp do współdzielonej pamięci bez synchronizacji. W przeciwieństwie do logów, TSan nie wpływa na timing, ponieważ działa przez instrumented code, a nie przez I/O.

Testy deterministyczne — zastąp rzeczywistą asynchroniczność kontrolowaną. Używaj TestDispatcher (Kotlin), RxJava Plugins lub GCD test queues (iOS) dla pełnej kontroli nad kolejnością wykonywania. Określ konkretne scenariusze: wątek A wykonuje się, potem B, potem A ponownie.

Logowanie cykliczne — logowanie do bufora cyklicznego w pamięci (nie na dysk). Gdy wystąpi błąd, bufor jest zapisywany do pliku. Ponieważ zapis do pamięci zajmuje nanosekundy (zamiast milisekund dla I/O dyskowego), taki log nie wpływa na timing i nie maskuje Heisenbug.

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) } }
    }
}

Logowanie w środowisku produkcyjnym — jeśli błąd nie odtwarza się lokalnie, zbieraj dane w produkcji. Używaj Firebase Crashlytics logs, Sentry Breadcrumbs lub własnego cyklicznego logera. Ważne: logowanie powinno być asynchroniczne i maksymalnie ograniczać wpływ na wydajność.

Profilaktyka Heisenbug na poziomie architektury

Izolacja stanu — minimalizuj współdzielony mutowalny stan. Każdy komponent powinien mieć swój izolowany stan, niedostępny do bezpośredniego zapisu z innych komponentów. Używaj Unidirectional Data Flow (UDF) — stan płynie w jednym kierunku: Event → Reducer → State → UI.

Podejście funkcyjne — czyste funkcje bez efektów ubocznych są łatwiejsze do testowania i debugowania. Efekty uboczne (sieć, baza danych, pliki) izoluj w ściśle określonych warstwach (repository, data source). Błędy związane z wątkami w kodzie funkcyjnym są praktycznie niemożliwe.

Strict mode — włącz Android StrictMode w kompilacji debugowania. Wykrywa naruszenia polityki wątkowania (sieć na głównym wątku, I/O dyskowe na głównym wątku) i rzuca wyjątek. To zamienia potencjalny Heisenbug w deterministyczny Bohrbug, widoczny od razu.

kotlin
class DebugApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .penaltyLog()
                    .build()
            )
        }
    }
}

Code review z naciskiem na asynchroniczność — obowiązkowa część procesu. Każdy pull request powinien być sprawdzany pod kątem współdzielonego mutowalnego stanu, kolekcji niezabezpieczonych wątkowo, braku synchronizacji. Używaj reguł lint do automatycznego zakazu określonych wzorców (np. dostęp do MutableList bez synchronized).

Często zadawane pytania

Dlaczego Heisenbug jest tak trudny do znalezienia?

Ponieważ standardowe metody — breakpointy, logi, print — zmieniają środowisko wykonania na tyle, że błąd przestaje się ujawniać. Debugger zatrzymuje wszystkie wątki na dziesiątki milisekund. W tym czasie race condition, która powodowała błąd, naturalnie się rozwiązuje. Potrzebne są narzędzia, które nie wpływają na timing wykonania.

Czym Heisenbug różni się od Mandelbug?

Mandelbug trudno odtworzyć z powodu złożoności warunków, ale narzędzia debugowania nie wpływają na jego ujawnianie się. Heisenbug znika właśnie od narzędzi debugowania. Przykład Mandelbug: crash tylko na urządzeniach z Android 11, 3 GB RAM i przy poziomie naładowania poniżej 15%. Przykład Heisenbug: race condition, która znika po dodaniu Log.d().

Jak testować Heisenbug w CI/CD?

Uruchamiaj flaky test detection — testy, które czasem padają, czasem przechodzą. W Android używaj Android Test Orchestrator do izolacji testów. Dodaj StrictMode w testach debugowania. Instrumentuj kompilację z ThreadSanitizer. Jeśli test jest flaky w >5% przebiegów — traktuj go jako potencjalny Heisenbug i analizuj przed mergem.

Czy Flow/Coroutines pomagają uniknąć Heisenbug?

Częściowo. Flow i structured concurrency w Kotlin zmniejszają ilość współdzielonego mutowalnego stanu i upraszczają zarządzanie wątkami. Ale korutyny nie gwarantują bezpieczeństwa wątkowego: jeśli dwie korutyny mają współdzielony stan, race condition jest nadal możliwa. Używaj Mutex do ochrony wspólnego stanu lub Channel do przesyłania danych między korutynami.

Co robić, jeśli Heisenbug występuje tylko w środowisku produkcyjnym?

Używaj cyklicznego bufora logów w pamięci z automatycznym zrzutem przy błędzie. Dodaj szczegółowy monitoring przez Crashlytics lub Sentry z niestandardowymi breadcrumbs. Dla Androida włącz ANR detection i sprawdzaj trace. Jeśli błąd to race condition, ThreadSanitizer w debug build przy zbliżonym do produkcyjnego obciążeniu może ujawnić problem.

Podsumowanie

  • Heisenbug — błąd, który znika podczas próby debugowania; główna przyczyna — zmiana timingowania przez narzędzia programisty
  • Race condition — główna przyczyna Heisenbug w aplikacjach mobilnych, szczególnie w kodzie asynchronicznym
  • Bohrbug (100% odtwarzalny) i Mandelbug (chaotyczny) — inne typy błędów, nie mylić z Heisenbug
  • ThreadSanitizer — najlepsze narzędzie do wykrywania wyścigów danych, nie wpływające na timing wykonania
  • Logowanie cykliczne w pamięci zamiast dyskowego — sposób zbierania danych bez maskowania Heisenbug
  • Unidirectional Data Flow i minimalizacja współdzielonego mutowalnego stanu — profilaktyka architektoniczna całej klasy błędów
  • StrictMode w kompilacji debugowania zamienia potencjalny Heisenbug w deterministyczny Bohrbug, widoczny od razu

Opracujemy aplikację mobilną pod klucz

IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.

Omów projekt

Przeczytaj również