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 в контексте распределённых систем, но наиболее актуален сегодня для мобильных приложений из-за их асинхронной природы.

Основная причина: стандартные отладочные инструменты изменяют среду выполнения. Брейкпоинт останавливает поток на несколько миллисекунд, логгирование добавляет synchronous 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 переменных, которые не видны другим потокам
  • Uninitialized variables — код, полагающийся на значения по умолчанию полей класса
  • GCD/dispatch queues — в iOS неопределённый порядок выполнения блоков в concurrent queues
  • Буферизированный 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 снова.

Cyclic logging — логгирование в кольцевой буфер в памяти (не на диск). Когда баг происходит, буфер сохраняется в файл. Поскольку запись в память занимает наносекунды (вместо миллисекунд для 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 на уровне архитектуры

Изоляция состояния — minimisе 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 и minimisе shared mutable state — архитектурная профилактика целого класса ошибок
  • StrictMode в debug-сборке превращает потенциальный Heisenbug в детерминированный Bohrbug, видимый сразу

Мы разработаем мобильное приложение под ключ

IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

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