Heisenbug — баг, который исчезает при попытке его отладить. Термин происходит от принципа неопределённости Гейзенберга: наблюдение влияет на поведение системы. В мобильной разработке Heisenbug — одна из самых сложных проблем, потому что стандартные методы отладки (логи, брейкпоинты, дополнительный код) меняют состояние программы и скрывают баг. Разберём причины возникновения и методы борьбы с неуловимыми ошибками.
Главное
Heisenbug — класс ошибок, которые проявляются в production или при нормальной работе, но исчезают при попытке их воспроизвести в отладочной среде. Термин был введён в 1980-х годах программистом Jim Gray в контексте распределённых систем, но наиболее актуален сегодня для мобильных приложений из-за их асинхронной природы.
Основная причина: стандартные отладочные инструменты изменяют среду выполнения. Брейкпоинт останавливает поток на несколько миллисекунд, логгирование добавляет synchronous I/O, дополнительные проверки меняют порядок операций. В многопоточной среде даже микросекундная задержка может изменить порядок выполнения потоков и скрыть гонку данных.
По данным Microsoft Research (2022), около 15-25% всех багов в многопоточных мобильных приложениях классифицируются как Heisenbug. При этом время на поиск и исправление одного Heisenbug в среднем в 5-10 раз больше, чем на обычный баг, из-за невозможности прямого воспроизведения.
Приложение вылетает в production при быстром свайпе по списку, но при подключении к отладчику или добавлении логов — работает идеально. Причина: гонка данных между UI-потоком (обновление RecyclerView) и фоновым потоком (обновление данных адаптера). Логи добавляют задержку, которая синхронизирует потоки случайным образом.
Bohrbug — предсказуемый, стабильно воспроизводимый баг. Назван по аналогии с атомной моделью Бора: как атом, баг ведёт себя одинаково при каждом наблюдении. Пример: NullPointerException при нажатии на кнопку до загрузки данных. Лечится стандартным unit-тестированием.
Mandelbug — баг со сложной, хаотической причинно-следственной связью (назван по аналогии с множеством Мандельброта). Проявляется только при определённой комбинации условий: версия ОС, модель устройства, состояние сети, фаза луны. Отличается от Heisenbug тем, что не исчезает при отладке — проблема в сложности воспроизведения, а не в изменении поведения от инструментов.
Heisenbug — баг, который исчезает именно из-за инструментов отладки. Если добавить лог — баг пропадает. Если поставить брейкпоинт — баг не проявляется. Если убрать всё — баг возвращается. Основная причина: изменённый timing при отладке.
| Тип | Воспроизводимость | Реакция на отладку | Пример |
|---|---|---|---|
| Bohrbug | 100% | Не меняется | NPE при пустом списке |
| Mandelbug | Хаотическая | Не меняется | Краш на Android 12, Samsung, при низком заряде |
| Heisenbug | Только без отладки | Исчезает | Race condition, исчезающая с логами |
| Schrödinbug | Не проявляется в коде | Появляется при взгляде | Баг виден в коде, но никогда не срабатывает |
Race condition — номер один среди причин Heisenbug. Два потока обращаются к общим данным без синхронизации. Отладчик вносит задержку, из-за которой потоки успевают синхронизироваться естественным образом. Без отладчика порядок выполнения непредсказуем.
Timing-зависимые ошибки — баги, проявляющиеся только при определённой скорости выполнения. Например, анимация, которая должна завершиться до начала следующей операции. В отладчике анимация идёт медленнее, и операция успевает начаться после завершения анимации. В production — наоборот.
// 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 компилятор меняет порядок операций, что может выявить скрытые предположения в коде.
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.
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 или кастомный циклический логгер. Важно: логгирование должно быть асинхронным и минимально влиять на производительность.
Изоляция состояния — 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, который виден сразу.
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).
Часто задаваемые вопросы
Потому что стандартные методы — брейкпоинты, логи, print — изменяют среду выполнения настолько, что баг перестаёт проявляться. Отладчик останавливает все потоки на десятки миллисекунд. За это время race condition, которая вызывала баг, естественным образом разрешается. Нужны инструменты, не влияющие на execution timing.
Mandelbug трудно воспроизвести из-за сложности условий, но инструменты отладки не влияют на его проявление. Heisenbug же исчезает именно от инструментов отладки. Пример Mandelbug: краш только на устройствах с Android 11, 3 GB RAM и при уровне заряда ниже 15%. Пример Heisenbug: race condition, пропадающая при добавлении Log.d().
Запускайте flaky test detection — тесты, которые иногда падают, иногда проходят. В Android используйте Android Test Orchestrator для изоляции тестов. Добавьте StrictMode в debug-тесты. Инструментируйте сборку с ThreadSanitizer. Если тест flaky >5% прогонов — считайте его потенциальным Heisenbug и разбирайтесь до мержа.
Частично. Flow и structured concurrency в Kotlin уменьшают количество shared mutable state и упрощают управление потоками. Но корутины не гарантируют потокобезопасность: если два корутина shared state, race condition всё ещё возможна. Используйте Mutex для защиты общего состояния или Channel для передачи данных между корутинами.
Используйте циклический буфер логов в памяти с автоматическим сбросом при ошибке. Добавьте детальный мониторинг через Crashlytics или Sentry с кастомными breadcrumbs. Для Android включите ANR detection и смотрите traces. Если баг — race condition, ThreadSanitizer в debug build рядом с production-like нагрузкой может выявить проблему.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также