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
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.
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 — 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.
| Typ | Odtwarzalność | Reakcja na debugowanie | Przykład |
|---|---|---|---|
| Bohrbug | 100% | Nie zmienia się | NPE przy pustej liście |
| Mandelbug | Chaotyczna | Nie zmienia się | Crash na Android 12, Samsung, przy niskim poziomie baterii |
| Heisenbug | Tylko bez debugowania | Znika | Race condition, znikająca z logami |
| Schrödinbug | Nie ujawnia się w kodzie | Pojawia się przy spojrzeniu | Błąd widoczny w kodzie, ale nigdy nie występuje |
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.
// 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.
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.
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ść.
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.
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
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.
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().
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.
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.
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
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.
Przeczytaj również