Heisenbug: що це, чому виникає та методи вилову

Автор: IT Sectr Опубліковано: 2026-07-29 Час читання: 10 хв

Heisenbug — баг, який зникає під час спроби його налагодити. Термін походить від принципу невизначеності Гейзенберга: спостереження впливає на поведінку системи. У мобільній розробці Heisenbug — одна з найскладніших проблем, тому що стандартні методи налагодження (логи, брейкпоінти, додатковий код) змінюють стан програми й приховують баг. Розберемо причини виникнення та методи боротьби з невловимими помилками.

Головне

  • Race condition — головна причина Heisenbug: зміна timing під час налагодження маскує проблему
  • Bohrbug — передбачуваний баг, легко відтворюється на відміну від Heisenbug
  • Mandelbug — баг зі складним причинно-наслідковим зв’язком, чутливий до початкових умов
  • ThreadSanitizer — інструмент для виявлення гонок даних, що не впливає на timing
  • Детерміновані тести — єдиний надійний спосіб відтворення Heisenbug

Що таке Heisenbug у мобільній розробці?

Heisenbug — клас помилок, які проявляються в production або під час нормальної роботи, але зникають при спробі відтворити їх у середовищі налагодження. Термін був введений у 1980-х роках програмістом Jim Gray у контексті розподілених систем, але найбільш актуальний сьогодні для мобільних застосунків через їхню асинхронну природу.

Основна причина: стандартні інструменти налагодження змінюють середовище виконання. Брейкпоінт зупиняє потік на кілька мілісекунд, логування додає синхронний I/O, додаткові перевірки змінюють порядок операцій. У багатопотоковому середовищі навіть мікросекундна затримка може змінити порядок виконання потоків і приховати гонку даних.

За даними Microsoft Research (2022), близько 15–25% всіх багів у багатопотокових мобільних застосунках класифікуються як Heisenbug. При цьому час на пошук і виправлення одного Heisenbug у середньому в 5–10 разів більший, ніж на звичайний баг, через неможливість прямого відтворення.

Приклад Heisenbug

Застосунок вилітає в production при швидкому свайпі по списку, але при підключенні до налагоджувача або додаванні логів — працює ідеально. Причина: гонка даних між UI-потоком (оновлення RecyclerView) і фоновим потоком (оновлення даних адаптера). Логи додають затримку, яка синхронізує потоки випадковим чином.

Bohrbug, Mandelbug, Heisenbug: класифікація багів

Bohrbug — передбачуваний, стабільно відтворюваний баг. Названий за аналогією з атомною моделлю Бора: як атом, баг веде себе однаково при кожному спостереженні. Приклад: NullPointerException при натисканні на кнопку до завантаження даних. Лікується стандартним unit-тестуванням.

Mandelbug — баг зі складним, хаотичним причинно-наслідковим зв’язком (названий за аналогією з множиною Мандельброта). Проявляється тільки за певної комбінації умов: версія ОС, модель пристрою, стан мережі. Відрізняється від Heisenbug тим, що не зникає при налагодженні — проблема в складності відтворення, а не в зміні поведінки від інструментів.

Heisenbug — баг, який зникає саме через інструменти налагодження. Якщо додати лог — баг зникає. Якщо поставити брейкпоінт — баг не проявляється. Якщо прибрати все — баг повертається. Основна причина: змінений timing при налагодженні.

ТипВідтворюваністьРеакція на налагодженняПриклад
Bohrbug100%Не змінюєтьсяNPE при порожньому списку
MandelbugХаотичнаНе змінюєтьсяКраш на Android 12, Samsung, при низькому заряді
HeisenbugТільки без налагодженняЗникаєRace condition, що зникає з логами
SchrödinbugНе проявляється в кодіЗ’являється при поглядіБаг видно в коді, але ніколи не спрацьовує

Основні причини виникнення Heisenbug

Race condition — номер один серед причин Heisenbug. Два потоки звертаються до спільних даних без синхронізації. Налагоджувач вносить затримку, через яку потоки встигають синхронізуватися природним чином. Без налагоджувача порядок виконання непередбачуваний.

Timing-залежні помилки — баги, що проявляються тільки за певної швидкості виконання. Наприклад, анімація, яка повинна завершитися до початку наступної операції. У налагоджувачі анімація йде повільніше, і операція встигає початися після завершення анімації. У production — навпаки.

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

Оптимізація компілятора — компілятор (JIT, ART, Kotlin/Native) може перевпорядкувати інструкції для оптимізації. У налагоджувальній збірці (debug build) оптимізації вимкнені, і код виконується «як написано». У release build компілятор змінює порядок операцій, що може виявити приховані припущення в коді.

  • ThreadLocal — неправильне використання thread-local змінних, які не видно іншим потокам
  • Неініціалізовані змінні — код, що покладається на значення за замовчуванням полів класу
  • GCD/dispatch черги — у iOS невизначений порядок виконання блоків у конкурентних чергах
  • Буферизований I/O — дані не записуються на диск, доки буфер не заповнений

Стратегії вилову невловимих багів

ThreadSanitizer (TSan) — інструмент Google для виявлення гонок даних у C/C++ і Kotlin/Native. Вбудовується в збірку та детектує будь-який доступ до спільної пам’яті без синхронізації. На відміну від логів, TSan не впливає на timing, тому що працює через instrumented code, а не через I/O.

Детерміновані тести — замініть реальну асинхронність на контрольовану. Використовуйте TestDispatcher (Kotlin), RxJava Plugins або GCD test queues (iOS) для повного контролю над порядком виконання. Задавайте конкретні сценарії: потік A виконується, потім B, потім A знову.

Циклічне логування — логування в кільцевий буфер у пам’яті (не на диск). Коли баг відбувається, буфер зберігається у файл. Оскільки запис у пам’ять займає наносекунди (замість мілісекунд для disk I/O), такий лог не впливає на timing і не маскує 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) } }
    }
}

Логування в production — якщо баг не відтворюється локально, збирайте дані в production. Використовуйте Firebase Crashlytics logs, Sentry Breadcrumbs або кастомний циклічний логер. Важливо: логування має бути асинхронним і мінімально впливати на продуктивність.

Профілактика Heisenbug на рівні архітектури

Ізоляція стану — мінімізуйте shared mutable state. Кожен компонент повинен мати свій ізольований стан, недоступний для прямого запису з інших компонентів. Використовуйте Unidirectional Data Flow (UDF) — стан тече в одному напрямку: Event → Reducer → State → UI.

Функційний підхід — чисті функції без side effects легше тестувати та налагоджувати. Side effects (мережа, БД, файли) ізолюйте в суворо визначених шарах (repository, data source). Потокові помилки у функційному коді практично неможливі.

Strict mode — увімкніть Android StrictMode у debug-збірці. Він детектує порушення threading policy (мережа на main thread, disk I/O на main thread) і кидає виняток. Це перетворює потенційний Heisenbug на детермінований Bohrbug, який видно одразу.

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

Code review з фокусом на асинхронність — обов’язкова частина процесу. Кожен пул-реквест має перевірятися на наявність shared mutable state, не потокобезпечних колекцій, відсутність синхронізації. Використовуйте lint-правила для автоматичної заборони певних патернів (наприклад, доступ до MutableList без synchronized).

Часті запитання

Чому Heisenbug так складно знайти?

Тому що стандартні методи — брейкпоінти, логи, print — змінюють середовище виконання настільки, що баг перестає проявлятися. Налагоджувач зупиняє всі потоки на десятки мілісекунд. За цей час race condition, яка викликала баг, природним чином вирішується. Потрібні інструменти, що не впливають на execution timing.

Чим Heisenbug відрізняється від Mandelbug?

Mandelbug важко відтворити через складність умов, але інструменти налагодження не впливають на його прояв. Heisenbug же зникає саме від інструментів налагодження. Приклад Mandelbug: краш тільки на пристроях з Android 11, 3 GB RAM і при рівні заряду нижче 15%. Приклад Heisenbug: race condition, що зникає при додаванні Log.d().

Як тестувати Heisenbug у CI/CD?

Запускайте flaky test detection — тести, які іноді падають, іноді проходять. В Android використовуйте Android Test Orchestrator для ізоляції тестів. Додайте StrictMode у debug-тести. Інструментуйте збірку з ThreadSanitizer. Якщо тест flaky >5% прогінок — вважайте його потенційним Heisenbug і розбирайтеся до мержу.

Чи допомагає Flow/Coroutines уникнути Heisenbug?

Частково. Flow і structured concurrency у Kotlin зменшують кількість shared mutable state і спрощують управління потоками. Але корутини не гарантують потокобезпеку: якщо дві корутини shared state, race condition все ще можлива. Використовуйте Mutex для захисту спільного стану або Channel для передачі даних між корутинами.

Що робити, якщо Heisenbug проявляється тільки в production?

Використовуйте циклічний буфер логів у пам’яті з автоматичним скиданням при помилці. Додайте детальний моніторинг через Crashlytics або Sentry з кастомними breadcrumbs. Для Android увімкніть ANR detection і дивіться traces. Якщо баг — race condition, ThreadSanitizer у debug build поряд з production-like навантаженням може виявити проблему.

Підсумки

  • Heisenbug — баг, що зникає при спробі налагодження; головна причина — зміна timing інструментами розробника
  • Race condition — основна причина Heisenbug у мобільних застосунках, особливо в асинхронному коді
  • Bohrbug (100% відтворюваний) і Mandelbug (хаотичний) — інші типи багів, не плутати з Heisenbug
  • ThreadSanitizer — найкращий інструмент для виявлення гонок даних, що не впливає на timing виконання
  • Циклічне логування в пам’яті замість дискового — спосіб збору даних без маскування Heisenbug
  • Unidirectional Data Flow і мінімізація shared mutable state — архітектурна профілактика цілого класу помилок
  • StrictMode у debug-збірці перетворює потенційний Heisenbug на детермінований Bohrbug, видимий одразу

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також