Starvation(スレッド枯渇)とは、スレッドが実行の準備ができているにもかかわらず、作業を続行するために必要なリソースにアクセスできない状況です。Baeldung(Java Thread Starvation, 2024)によると、枯渇は不公平なスケジューリングによって発生し、低優先度のスレッドが高優先度のスレッドに優先して常に延期されます。Deadlockとは異なり、Starvationはスレッドをブロックしません — RUNNABLE状態のままですが、CPU時間を取得できません。
重要なポイント
Starvation(スレッド枯渇)とは、マルチスレッドプログラミングにおける問題で、スレッドがタスクを完了するために必要なリソースにアクセスできない状態を指します。リソースは他のスレッドによって永久にロックされているわけではありません。スレッドはRUNNABLE状態ですが、スケジューラまたは同期メカニズムが体系的に他のスレッドを優先してその実行を延期します。
モバイル開発では、Starvationは不均等なタスク実行として現れます。一部の操作は即座に実行されますが、他の操作は壊滅的な遅延に悩まされます。例えば、バックグラウンドのデータ同期スレッドは、UIスレッドとアニメーションハンドラが常に先行する場合、データベースにアクセスできない可能性があります。Android Developer Blog(Performance Matters、2023)によると、Androidでのフレーム落ち(jank)の約12%は、レンダリングに依存するバックグラウンドタスクのStarvationが原因です。
StarvationとDeadlockの主な違いは可逆性です。システム負荷が低下するか優先度が再配分されると、枯渇したスレッドはリソースを取得して作業を完了できます。ただし、持続的な高負荷下では、Starvationは無期限に続き、アプリケーションがフリーズしたような印象を与える可能性があります。
JavaおよびKotlinのsynchronizedは、不公平なメカニズムの典型的な例です。高競合下では、JVMは同じアクティブなスレッドに継続的にロックを付与し、他のスレッドは常に競争に敗れます。これはJVMのバグではなく、設計上のトレードオフです。不公平なロックは、アクセスの公平性を犠牲にして高いスループットを提供します。4~8スレッドのモバイルアプリケーションでは、この問題は特に重要です。
異なるスレッド優先度を設定すると、低優先度スレッドのStarvationにつながる可能性があります。Android Runtimeでは、LinuxのCFS(Completely Fair Scheduler)がCPU時間を優先度に比例して分配し、高優先度スレッドが常にアクティブな場合、低優先度スレッドはCPU時間を取得できない可能性があります。GoogleはAndroidでのスレッド優先度変更を強く推奨していません — システムが自動的に管理します。
スレッドが長時間ロックを保持する場合(synchronizedブロック内で重い計算、ネットワークリクエスト、ファイル操作を実行)、そのロックを待機する他のスレッドが枯渇します。これはAndroidで特に危険です。UIスレッドでの長時間の操作はANRを引き起こし、クリティカルセクションを最適化せずにバックグラウンドスレッドに移動するだけでは、Starvation問題を作業スレッドに移すことになります。
不公平なスケジューリングにより、1つのスレッドがロックを頻繁に取得する例を考えてみましょう。Starvationは、高優先度スレッドの無限ループによって示され、低優先度スレッドが共有リソースにアクセスするのを防ぎます。
class SharedResource {
private val lock = Any()
fun criticalSection(id: String) {
synchronized(lock) {
println("$idがアクセスを取得")
Thread.sleep(10) // 作業のシミュレーション
}
}
}
fun main() {
val resource = SharedResource()
// 高優先度スレッド — 常にアクティブ
val highPriority = Thread {
while (true) {
resource.criticalSection("High")
}
}
// 低優先度スレッド — アクセスできない可能性あり
val lowPriority = Thread {
while (true) {
resource.criticalSection("Low")
}
}
highPriority.start()
lowPriority.start()
// 「Low」はメッセージを出力できない可能性あり — Starvation!
}
この例では、highPriorityスレッドが常にロックを取得し、10 msだけ解放します。synchronizedの不公平な性質により、JVMスケジューラは解放したばかりの同じスレッドに再びロックを付与する可能性が高く、低優先度スレッドが枯渇します。解決策は、公平なFIFO待機順序を保証するfairフラグ付きのReentrantLock(true)を使用することです。
公平なロックを使用した修正バージョンは、リソースアクセスの公平な分配を保証します。
class FairSharedResource {
private val fairLock = ReentrantLock(true) // fair = true
fun criticalSection(id: String) {
fairLock.lock()
try {
println("$idがアクセスを取得(fair)")
Thread.sleep(10)
} finally {
fairLock.unlock()
}
}
}
3つの古典的なマルチスレッド問題 — Starvation、Deadlock、Livelock — はしばしば一緒にグループ化されますが、そのメカニズムと解決策は異なります。Starvation — スレッドは準備完了だがリソースを取得できない。Deadlock — スレッドは循環待機によってブロックされている。Livelock — スレッドはアクティブだが進行しない。
| パラメータ | Starvation | Deadlock | Livelock |
|---|---|---|---|
| スレッド状態 | RUNNABLE | BLOCKED | RUNNABLE |
| 進行 | なし | なし | なし(アクティブだが) |
| CPU使用率 | 低い | 最小 | 高い(最大100%) |
| 原因 | 不公平なスケジューリング | 循環待機 | 競合に対する同じ反応 |
| 主な修正 | Fair Lock、クリティカルセクションの短縮 | ロック階層 | 再試行制限、exponential backoff |
StarvationはDeadlockよりも重要度が低いと考えられています。なぜなら致命的ではないからです — 負荷が低下すれば、枯渇したスレッドは最終的に実行されます。ただし、メモリとCPUが限られている実際のAndroid使用では、Starvationが数分間続き、許容できないUXを生み出す可能性があります。
Thread Dumpを短い間隔で繰り返し取得することが、枯渇を検出する基本的な方法です。スレッドが一貫してRUNNABLE状態にあるが、複数のダンプにわたってコールスタックが変化しない場合 — これはStarvationの古典的な兆候です。Android Studioでは、時間経過に伴うスレッド状態の記録にAndroid Profilerを使用します。
タスクの実行時間監視による自動検出が可能です。予測可能な実行時間(例:50 ms)のタスクが5秒以上かかる場合 — Starvationの可能性が高いです。モバイルアプリケーションでは、Firebase Performance Monitoringを使用してクリティカルセクションのカスタムトレースを設定し、しきい値を超えたときに通知を受け取ることができます。
synchronizedブロックによるStarvationを診断するには、Java Flight Recorder(JFR)(OpenJDK APIを介してAndroidで利用可能)またはAsync Profilerを使用します。これらのツールは、どのモニターの待機時間が最も長いか、どのスレッドが各モニターを競合しているかを示します。JFRデータは、組み込みプロファイラーを介してIntelliJ IDEA Ultimateと統合されます。
ReentrantLock(true)は、スレッドがFIFO順序でロックを取得することを保証します。synchronizedとは異なり、公平なロックは解放したばかりのスレッドがすぐに再取得することを許可しません。これによりStarvationが完全に排除されますが、キューを維持するオーバーヘッドのため、全体的なスループットが10~20%低下します。
ロックフリーデータ構造(ConcurrentHashMap、AtomicReference、LongAdder)は、定義上Starvationを排除します。1つのスレッドが保持できるロックがないためです。すべての操作はCPUのCAS命令を使用し、有限ステップ内で少なくとも1つのスレッドの進行を保証します。モバイル開発では、タスクキューにConcurrentLinkedQueueを推奨します。
ロック保持時間の最小化は、Starvationのリスクを減らす普遍的な方法です。重い操作(ネットワーク、ディスクI/O、複雑な計算)はsynchronizedブロックの外に移動します。読み取り側が稀な書き込み側によって枯渇しないようにするシナリオでは、ReadWriteLockを使用します。Kotlin Coroutinesライブラリは、OSスレッドをブロックしないサスペンド機構を備えたMutexを提供します。
Condition.await()とsignal()は注意して使用する必要があります。Conditionで待機しているスレッドは他のスレッドと一緒に起動され(spurious wakeup)、すべてがロックを競合します。await後すぐに待機に戻るスレッドがいる一方で他のスレッドがロックを取得できた場合、枯渇したスレッドは無期限に起きたり眠ったりする可能性があります。再確認を保証するために、常にifではなくwhileループで条件をチェックしてください。
よくある質問
Priority Inversion(優先度逆転)とは、低優先度スレッドが高優先度スレッドに必要なロックを保持している状況です。その結果、高優先度スレッドが低優先度スレッドを待機し、優先度が逆転します。Starvationはより広範な問題で、スレッドが優先度に関係なく、不公平なスケジューリングや長いクリティカルセクションのためにリソースを取得できません。
いいえ、Starvationはマルチスレッドの問題です。シングルスレッドコードにはリソース競合やスレッドスケジューリングがありません。ただし、非同期シングルスレッドコード(例:JavaScriptイベントループ)では、1つのマイクロタスクがsetTimeout(遅延0)を介して他のタスクの実行を無期限に延期する場合、Starvationが発生する可能性があります。
JMM(Java Memory Model)はスレッド間の変更の可視性ルールを定義しますが、公平なスケジューリングは保証しません。synchronizedはJMMに従って逐次一貫性(基本的な正確性)を保証しますが、Starvationは防止しません。公平性にはJMMで指定されていない追加のメカニズムが必要です。
UIスレッド(Main Thread)は最高優先度を持つため、古典的な意味では枯渇しません。ただし、UIスレッドが枯渇したバックグラウンドスレッドからの結果を待つ場合、Starvationが発生します。典型的なシナリオ:AsyncTaskやコルーチンがデータをロードするが、他のスレッドとの競合のためデータベースにアクセスできず、UIが待機中にフリーズします。
コルーチンでStarvationを防ぐには、Dispatchers.IOでlimitedParallelismを使用してスレッドの枯渇を回避します。同期にはkotlinx.coroutines.syncのMutexを使用します — スレッドをブロックせずにコルーチンを一時停止し、枯渇リスクを低減します。コルーチンでのrunBlockingは避けてください。プールスレッドを占有し、他のコルーチンのStarvationを引き起こす可能性があります。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。