Deadlock(相互ブロック)とは、2つ以上のスレッドが他の参加者によって保持されているリソースの解放を無限に待機する状態です。Oracle Java Tutorials (2024)によると、Deadlockは各スレッドが別のスレッドに必要なロックを保持している循環待機で発生します。特別な検出ツールがない場合、Deadlockは可視エラーなしにアプリケーションの実行を完全に停止させます。
重要ポイント
Deadlockはマルチスレッドプログラミングにおいて、2つ以上のスレッドが互いに永久にブロックし合う状況です。各スレッドは別のスレッドに必要なリソースを保持し、不足しているリソースを取得するのを待つ間、それを解放しません。結果として、どのスレッドも実行を継続できません。
モバイル開発において、Deadlockは例外やクラッシュを引き起こさないため、特に重要です。アプリケーションはユーザーの操作に応答しなくなり(ANR — Application Not Responding)、唯一の解決策はプロセスを強制終了することです。Google(Android Performance Patterns、2023)によると、Google Play ConsoleのANRレポートの約15%はバックグラウンドスレッドでの相互ブロックに関連しています。
Deadlockと他の並行性問題の主な違いは、外部介入なしでは不可逆であることです。オペレーティングシステムのスケジューラはロックを強制的に取り消せないため、スレッドは自分からリソースを解放しません。これにより、スレッドはアクティブだが有用な作業をしていないLivelockとDeadlockが区別されます。
1971年、Edward G. CoffmanはDeadlockの発生に必要な4つの必須条件を定式化しました。少なくとも1つが欠けている場合、相互ブロックは不可能です。これらの条件はCoffmanの条件として知られ、すべてのDeadlock防止アルゴリズムの基礎を形成しています。
リソースは一度に1つのスレッドのみが取得できます。リソースが複数のスレッドによる同時読み取りを許可する場合(例:読み取りモードのReadWriteLock)、Deadlockは発生しません。この条件はMutexとロックの性質そのものに由来します。
スレッドは既に取得したリソースを保持し、同時に別のリソースの取得を待機します。スレッドが次のリソースを要求する前に現在のリソースを解放できる場合(二相ロッキングによる)、Hold and Wait条件は破られます。Androidでは、スレッドがデータベースロックを保持し、SharedPreferencesロックを取得しようとするときに現れることがよくあります。
オペレーティングシステムはスレッドからロックを強制的に奪うことはできません。リソースはスレッド自身が解放したときにのみ解放されます。一部のシステム(例:SQLite WALモード)では、個々の操作レベルで強制横取りが実装され、Deadlockのリスクを低減します。
スレッドの閉じたチェーンが存在し、各スレッドがチェーン内の次のスレッドによって保持されているリソースを待機します。例えば、スレッドAがリソース1を保持しリソース2を待機し、スレッドBがリソース2を保持しリソース1を待機します。これは開発者がアーキテクチャ的に排除できる唯一の条件です — ロック階層を通じて。すべてのスレッドが厳密に定義されたグローバル順序でリソースを取得する場合、循環は物理的に不可能です。
実際には、Androidアプリケーションでは、異なるレベルのロックの暗黙的な交差によりDeadlockが最も頻繁に発生します:データベースロック(Room)、SharedPreferencesロック、インメモリコレクションロック。これらの各ロックは異なるコンポーネントによって管理され、取得順序の集中プロトコルがないと、開発者は意図せず循環を作り出します。
相互ブロックの古典的な例を見てみましょう — 2つのスレッドが異なる順序でロックを取得する場合です。最初のスレッドがリソースAをロックしBを取得しようとし、2番目のスレッドがBをロックしAを取得しようとすると、Deadlockが発生します。
class DeadlockExample {
private val lockA = Any()
private val lockB = Any()
fun operationA() {
synchronized(lockA) {
Thread.sleep(50) // 作業シミュレーション
synchronized(lockB) {
println("operationA完了")
}
}
}
fun operationB() {
synchronized(lockB) { // 逆順序
Thread.sleep(50)
synchronized(lockA) {
println("operationB完了")
}
}
}
}
fun main() {
val ex = DeadlockExample()
Thread { ex.operationA() }.start()
Thread { ex.operationB() }.start()
// アプリケーションは永久にハングします — Deadlock!
}
この例では、operationAがlockAを取得し、operationBがlockBを取得します。次にそれぞれが2番目のロックを取得しようとし — 両方が無限に待機します。プログラムはエラーメッセージなしでハングします。修正する唯一の方法は、すべてのメソッドで同じロック取得順序を保証することです。
これら3つの並行性問題はよく混同されますが、そのメカニズムと結果は根本的に異なります。Deadlock — 完全停止、Starvation — リソースの無限待機、Livelock — アクティブなアイドル状態。違いを理解することは、正しい解決戦略を選択するために極めて重要です。
| 特性 | Deadlock | Starvation | Livelock |
|---|---|---|---|
| スレッドの状態 | ブロック済み(BLOCKED) | 準備完了(RUNNABLE) | アクティブ(RUNNABLE) |
| 作業の実行 | なし | なし | あり、ただし無駄 |
| 原因 | 循環待機 | 不公平なスケジューリング | 誤った競合処理 |
| 検出 | Thread Dump、タイムアウト | 進捗監視 | 再試行カウンター |
Starvation(枯渇)は、スケジューラが他のスレッドを優先して低優先度スレッドの実行を常に延期するときに発生します。Deadlockとは異なり、スレッドはブロックされていません — 実行準備はできていますがCPU時間を得られません。Androidでは、UIスレッドとServiceスレッドが常にアクティブな場合、低優先度のバックグラウンドスレッドが決して実行されないのが典型的なシナリオです。
Livelock(活性ロック)は、スレッドがブロックされていないものの、互いのアクションに無限に反応し有用な作業を行わない状況です。古典的な例え — 廊下で2人がすれ違おうとして、両方が同じ方向に避けようとします。Deadlockとは異なり、LivelockのスレッドはCPUを消費し、デバイスのバッテリーを消耗させます。
Thread DumpはJVMおよびAndroid Runtimeで相互ブロックを検出する主要ツールです。ダンプ中、JVMはモニター間の依存関係グラフを自動的に分析し、Deadlockサイクルをマークします。Android Studioでは、Android ProfilerまたはADB Shellからのkill -3 PIDコマンドを通じてスレッドダンプを取得できます。
実行時の自動Deadlock検出はWatchdogタイマーを通じて実装されます。スレッドが指定されたタイムアウト内に操作を完了しない場合、watchdogがダンプを開始し、クラッシュレポーティングシステム(Firebase Crashlytics、Sentry)にレポートを送信します。Sentry(Issue Resolution Report、2024)によると、watchdogの設定によりDeadlock診断時間が数週間から数時間に短縮されます。
開発段階では、JetBrains ThreadSafe静的解析ツールおよびLock Checkerモジュールを備えたChecker Frameworkが効果的です。これらのツールはソースコードレベルでロック取得順序を分析し、潜在的な循環について警告します。さらに、Test-Driven Deadlock Detection — 数百のスレッドで異なるロック順序で操作を実行するストレステストが推奨されます。
特に注目すべきはCooperative Deadlock Detection — スレッドがグローバルレジストリを通じて取得したロックに関する情報を交換する方法です。スレッドが潜在的な循環を検出すると、すべてのリソースを解放して操作を再試行します。このアプローチは分散システム(Apache ZooKeeper、Google Chubby)で使用され、Jetpack Syncなどのライブラリを通じて徐々にモバイル開発に採用されています。
最も信頼できる方法は、アプリケーション全体でロック取得のグローバルな順序を確立することです。すべてのスレッドが常に小さい番号のロックを最初に取得し、次に大きい番号のロックを取得する場合、循環待機(Circular Wait条件)は不可能です。大規模プロジェクトでは、順序は文書化され、コードレビューを通じて検証されます。
TryLockはスレッドを無期限にブロックせず、指定された時間内にロックが取得できない場合にfalseを返すロック方法です。JavaではReentrantLock.tryLock(timeout、TimeUnit)を介して、Kotlin Coroutinesではタイムアウト付きのMutex.withLockを介して実装されます。失敗した場合、スレッドは取得したすべてのリソースを解放し、後で再試行します。
銀行家のアルゴリズムはEdsger Dijkstraによって提案されたDeadlock防止の理論的方法です。リソース割り当てを銀行取引としてモデル化します:システムは、不安全な状態(deadlock)につながる可能性がある場合、リソースを割り当てません。実際には、スレッドの最大必要量を事前に知ることの難しさから、モバイル開発ではアルゴリズムが使用されることは稀ですが、その原理はSQLiteデータベースやファイルシステムで使用されています。
よくある質問
いいえ、相互ブロックには少なくとも2つのスレッドが必要です。シングルスレッドコードでは、すべての操作が順次実行されるため、循環待機は不可能です。ただし、ファイルロックやプロセス間セマフォを使用する場合、プロセス間でDeadlockが発生する可能性があります。
コルーチンでは、Deadlockはサスペンド関数のレベルで発生し、OSスレッドをブロックしないため、気づきにくくなっています。kotlinx.coroutinesのMutexはサスペンディングロックであり、スレッドをブロックしませんが、コルーチンは実行されません。検出には、kotlinx-coroutines-debugモジュールのDebugProbesを使用してください。
SQLite Deadlockは、2つのデータベース接続が異なる順序でトランザクションを実行しようとするときに発生します。SQLiteはそのような状況を検出し、SQLITE_BUSYまたはSQLITE_LOCKEDエラーコードを返します。Androidでは、単一のデータベースインスタンスと@Transactionによるトランザクションを持つRoomを使用することが推奨され、接続間のDeadlockを排除します。
Android Runtimeには、ANR(Application Not Responding)が発生したときに実行される組み込みのDeadlock検出器があります。システムはアプリケーションの全スレッドのThread Dumpを分析し、相互ブロックをマークします。結果は/data/anr/traces.txtおよびGoogle Play ConsoleのANR Reportsセクションで利用できます。
まず、アプリケーションのすべてのスレッドのThread Dumpを取得します。各スレッドがどのロックを保持し、どのロックを取得しようとしているかを分析します。制限時間を超えた場合に自動ダンプするWatchdogタイマーを実装します。修正後、再発を防ぐためにCIパイプラインにThreadSafety lintルールを追加します。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。