Heisenbug — デバッグしようとすると消えてしまうバグ。この用語はハイゼンベルクの不確定性原理に由来します:観測がシステムの動作に影響を与える。モバイル開発において、Heisenbugは最も難しい問題の一つです。なぜなら標準的なデバッグ手法(ログ、ブレークポイント、追加コード)がプログラムの状態を変更し、バグを隠してしまうからです。原因と、捉えどころのないエラーに対処する方法を解説します。
重要ポイント
Heisenbug — 本番環境や通常動作では発生するが、デバッグ環境で再現しようとすると消えてしまうエラーの一種。この用語は1980年代にプログラマーのJim Grayによって分散システムの文脈で作られましたが、非同期性のため今日ではモバイルアプリケーションに最も関連性が高いです。
主な理由:標準的なデバッグツールが実行環境を変更する。ブレークポイントはスレッドを数ミリ秒停止させ、ログ記録は同期I/Oを追加し、追加チェックは操作の順序を変更する。マルチスレッド環境では、マイクロ秒の遅延でもスレッドの実行順序が変わり、データ競合を隠してしまう可能性があります。
Microsoft Research(2022年)によると、マルチスレッドモバイルアプリケーションの全バグの約15~25%がHeisenbugに分類されます。同時に、1つのHeisenbugの発見と修正にかかる時間は、直接再現できないため、通常のバグよりも平均して5~10倍長くなります。
リストを高速でスワイプすると本番環境でアプリがクラッシュするが、デバッガーに接続したりログを追加したりすると — 完全に動作する。原因:UIスレッド(RecyclerViewの更新)とバックグラウンドスレッド(アダプタデータの更新)間のデータ競合。ログが遅延を追加し、スレッドをランダムに同期させる。
Bohrbug — 予測可能で安定して再現できるバグ。ボーアの原子モデルにちなんで命名:原子のように、観測するたびに同じ動作をする。例:データ読み込み前にボタンをクリックするとNullPointerException。標準的な単体テストで対応。
Mandelbug — 複雑でカオスな因果関係を持つバグ(マンデルブロ集合にちなんで命名)。特定の条件の組み合わせでのみ発生:OSバージョン、デバイスモデル、ネットワーク状態。デバッグ中に消えない点でHeisenbugと異なる — 問題は再現の困難さであり、ツールによる動作変更ではない。
Heisenbug — まさにデバッグツールが原因で消えるバグ。ログを追加すると — バグが消える。ブレークポイントを設定すると — バグが発生しない。すべてを取り除くと — バグが戻る。主な原因:デバッグ中のタイミング変更。
| タイプ | 再現性 | デバッグへの反応 | 例 |
|---|---|---|---|
| Bohrbug | 100% | 変化なし | 空リストでのNPE |
| Mandelbug | カオス | 変化なし | Android 12、Samsung、バッテリー低下でクラッシュ |
| Heisenbug | デバッグなしのみ | 消える | ログで消える競合状態 |
| Schrödinbug | コードで顕在化しない | 見ると現れる | コード上は見えるが決して発生しないバグ |
Race condition — Heisenbugの最大の原因。2つのスレッドが同期なしに共有データにアクセスする。デバッガーが遅延を導入し、スレッドが自然に同期される。デバッガーがない場合、実行順序は予測不能。
タイミング依存エラー — 特定の実行速度でのみ発生するバグ。例:次の操作開始前に終了しなければならないアニメーション。デバッガーではアニメーションが遅くなり、アニメーション終了後に操作が開始される。本番環境では — その逆。
// 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)は最適化のため命令を並べ替えることがある。デバッグビルドでは最適化が無効になり、コードは「書かれた通り」に実行される。リリースビルドではコンパイラが操作順序を変更し、コードの隠れた前提を明らかにする可能性がある。
ThreadSanitizer(TSan)— C/C++およびKotlin/Nativeにおけるデータ競合検出のためのGoogleのツール。ビルドに組み込まれ、同期なしの共有メモリアクセスを検出する。ログと異なり、TSanはI/Oではなく計装コードを通じて動作するため、タイミングに影響を与えない。
決定論的テスト — 実際の非同期性を制御された非同期性に置き換える。実行順序を完全に制御するには、TestDispatcher(Kotlin)、RxJava Plugins、またはGCDテストキュー(iOS)を使用する。具体的なシナリオを指定:スレッドAが実行、次にB、次に再びA。
循環ログ — メモリ内のリングバッファへのログ記録(ディスクではなく)。バグが発生したとき、バッファはファイルに保存される。メモリへの書き込みはナノ秒(ディスクI/Oのミリ秒ではなく)しかかからないため、このようなログはタイミングに影響を与えず、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) } }
}
}
本番環境でのログ記録 — バグがローカルで再現できない場合、本番環境でデータを収集する。Firebase Crashlyticsログ、Sentry Breadcrumbs、またはカスタム循環ロガーを使用する。重要:ログ記録は非同期で、パフォーマンスへの影響を最小限に抑える必要がある。
状態の分離 — 共有可変状態を最小限に抑える。各コンポーネントは、他のコンポーネントから直接書き込みできない、独自の分離された状態を持つ必要がある。Unidirectional Data Flow(UDF)を使用する — 状態は一方向に流れる:イベント → リデューサー → 状態 → UI。
関数型アプローチ — 副作用のない純粋関数はテストとデバッグが容易。副作用(ネットワーク、DB、ファイル)は厳密に定義された層(リポジトリ、データソース)に分離する。関数型コードでのスレッドエラーはほぼ不可能。
ストリクトモード — デバッグビルドでAndroid StrictModeを有効にする。スレッドポリシー違反(メインスレッドでのネットワーク、メインスレッドでのディスクI/O)を検出し、例外をスローする。これにより、潜在的なHeisenbugが即座に可視化される決定論的Bohrbugに変わる。
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
非同期性に焦点を当てたコードレビュー — プロセスの必須部分。各プルリクエストは、共有可変状態、スレッドセーフでないコレクション、同期の欠如についてチェックされる必要がある。特定のパターン(synchronizedなしでのMutableListへのアクセスなど)を自動的に禁止するには、lintルールを使用する。
よくある質問
標準的な手法 — ブレークポイント、ログ、print — が実行環境を大きく変え、バグが顕在化しなくなるからです。デバッガーはすべてのスレッドを数十ミリ秒停止させます。この間に、バグを引き起こしていた競合状態は自然に解消されます。実行タイミングに影響を与えないツールが必要です。
Mandelbugは条件の複雑さのために再現が困難ですが、デバッグツールはその顕在化に影響しません。Heisenbugはまさにデバッグツールが原因で消えます。Mandelbugの例:Android 11、3 GB RAM、バッテリーレベル15%未満のデバイスでのみクラッシュ。Heisenbugの例:Log.d()を追加すると消える競合状態。
flaky test detectionを使用する — 時々失敗し、時々成功するテスト。Androidでは、Android Test Orchestratorを使用してテストを分離する。デバッグテストにStrictModeを追加する。ThreadSanitizerでビルドを計装する。テストの>5%の実行でflakyな場合 — 潜在的なHeisenbugと見なしてマージ前に調査する。
部分的に。KotlinのFlowと構造化された並行性は、共有可変状態の量を減らし、スレッド管理を簡素化する。しかし、コルーチンはスレッド安全性を保証しない:2つのコルーチンが状態を共有する場合、競合状態は依然として可能。共有状態を保護するにはMutexを、コルーチン間でデータを渡すにはChannelを使用する。
エラー時に自動フラッシュされるメモリ内の循環ログバッファを使用する。カスタムbreadcrumbsを使用してCrashlyticsやSentry経由で詳細な監視を追加する。Androidでは、ANR検出を有効にしてトレースを確認する。バグが競合状態の場合、本番環境に近い負荷でのデバッグビルドのThreadSanitizerが問題を明らかにする可能性がある。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。