axiom-hang-diagnostics について
Swiftアプリ開発者向けのハング診断スキルです。メインスレッドの処理過多とブロックを見分け、Instruments、Xcode Organizer、MetricKitで原因を調べ、無応答を防ぐ方法を説明します。
主な機能
- 01ハング、ヒッチ、遅延の区別
- 02Time Profilerによるメインスレッド分析
- 03System Traceによるブロック調査
- 04OrganizerとMetricKitの診断情報の確認
- 05一般的な原因と非同期修正例
活用シーン
- 01ユーザー報告の画面フリーズ調査
- 02本番環境のハングレポート分析
- 03アプリ遷移中のウォッチドッグ終了の診断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-hang-diagnosticsインストール後、Claude Code で/axiom-hang-diagnosticsを入力して読み込まれたか確認します。
詳細
ハングの診断
アプリのハングを体系的に診断し、解決します。ハングとは、メインスレッドが1秒を超えてブロックされ、アプリがユーザー入力に応答しなくなる状態です。
注意すべき兆候 — 次の場合はこのスキルを確認してください
| 症状 | このスキルの対象 |
|---|---|
| 使用中にアプリが一時的にフリーズする | はい — ハングの可能性があります |
| UIがタッチに反応しない | はい — メインスレッドがブロックされています |
| 「App not responding」というシステムダイアログが表示される | はい — 深刻なハングです |
| Xcode Organizerにハング診断が表示される | はい — 実機でのハングレポートです |
| MetricKit MXHangDiagnosticを受信した | はい — 集計されたハングデータです |
| アニメーションがカクついたり、途中で止まったりする | 場合によります — ハングではなく、ヒッチの可能性があります |
| アプリの動作が遅く感じるが、応答はある | いいえ — パフォーマンスの問題であり、ハングではありません |
ハングとは
ハングとは、メインのランループが1秒を超えてイベントを処理できない状態です。ユーザーがタップしても何も起きません。
User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANG
重要な違い:メインスレッドは、すべてのユーザー入力を処理します。メインスレッドがビジーまたはブロックされると、UI全体がフリーズします。
ハング、ヒッチ、遅延の違い
| 問題 | 継続時間 | ユーザーエクスペリエンス | ツール |
|---|---|---|---|
| ハング | 1秒超 | アプリがフリーズし、応答しない | Time Profiler、System Trace |
| ヒッチ | 1~3フレーム(16~50ms) | アニメーションがカクつく | Animation Hitches instrument |
| 遅延 | 100~500ms | 遅く感じるが、応答はある | Time Profiler |
このスキルではハングを扱います。 ヒッチについては axiom-swiftui-performance をご覧ください。一般的な遅延については axiom-performance-profiling をご覧ください。
ハングの2つの原因
ハングの根本原因は、次の2つのいずれかです。
1. メインスレッドがビジー
メインスレッドがイベントを処理せず、何らかの作業をしています。
サブカテゴリ:
| 種類 | 例 | 修正方法 |
|---|---|---|
| 先回りした作業 | ユーザーが要求していないデータを事前計算する | 遅延初期化し、必要になったときに計算する |
| 不要な作業 | 関連する通知だけでなく、すべての通知を処理する | 通知をフィルタリングし、対象を絞ったオブザーバーを使う |
| 最適でないAPI | 非同期APIがあるのにブロッキングAPIを使う | 非同期APIに切り替える |
2. メインスレッドがブロックされている
メインスレッドが、別の処理を待っています。
サブカテゴリ:
| 種類 | 例 | 修正方法 |
|---|---|---|
| 同期IPC | システムサービスを同期的に呼び出す | 非同期API版を使う |
| ファイルI/O | メインスレッドで Data(contentsOf:) を実行する | バックグラウンドキューに移す |
| ネットワーク | 同期的なURLリクエスト | URLSessionの非同期APIを使う |
| ロック競合 | バックグラウンドスレッドが保持するロックを待つ | クリティカルセクションを短縮し、アクターを使う |
| セマフォ/dispatch_sync | バックグラウンド処理をブロッキングして待つ | 非同期の完了処理を使う構成に変更する |
意思決定ツリー — ハングの診断
START: App hangs reported
│
├─→ Do you have hang diagnostics from Organizer or MetricKit?
│ │
│ ├─→ YES: Examine stack trace
│ │ │
│ │ ├─→ Stack shows your code running
│ │ │ → BUSY: Main thread doing work
│ │ │ → Profile with Time Profiler
│ │ │
│ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync)
│ │ → BLOCKED: Main thread waiting
│ │ → Profile with System Trace
│ │
│ └─→ NO: Can you reproduce?
│ │
│ ├─→ YES: Profile with Time Profiler first
│ │ │
│ │ ├─→ High CPU on main thread
│ │ │ → BUSY: Optimize the work
│ │ │
│ │ └─→ Low CPU, thread blocked
│ │ → Use System Trace to find what's blocking
│ │
│ └─→ NO: Enable MetricKit in app
│ → Wait for field reports
│ → Check Organizer > Hangs
ツールの選択
| シナリオ | 主なツール | 理由 |
|---|---|---|
| ローカルで再現できる | Time Profiler | メインスレッドが何をしているか正確に確認できます |
| スレッドのブロックが疑われる | System Trace | スレッドの状態やロック競合を確認できます |
| 実機でのレポートのみ | Xcode Organizer | 集計されたハング診断を確認できます |
| アプリ内のデータが必要 | MetricKit | コールスタックを含むMXHangDiagnosticを取得できます |
| 正確なタイミングが必要 | System Trace | ナノ秒レベルのスレッド解析ができます |
ハングを調べるためのTime Profilerワークフロー
- Instrumentsを起動する → Time Profilerテンプレートを選択する
- ハング中に記録する → フリーズを再現する
- 記録を停止する → タイムラインでハングしていた期間を見つける
- ハング領域を選択する → フリーズしていた時間範囲をドラッグして選択する
- コールツリーを調べる → メインスレッドの処理を確認する
確認する項目:
- メインスレッドで「Self Time」が長い関数
- 予想外に深いコールスタック
- メインスレッドで実行すべきでないシステムコール
ハングがブロックされた場合のシステムトレースワークフロー
- Instruments を起動 → System Trace テンプレートを選択します
- ハング中に記録 → スレッドの状態をキャプチャします
- メインスレッドを見つける → メインスレッドに絞り込みます
- 赤色/オレンジ色を確認 → ブロック状態を確認します
- ブロックの原因を調べる → ロック、セマフォ、IPC
スレッドの状態:
- 実行中(青):コードを実行中です
- プリエンプト(オレンジ):実行可能ですが、スケジュールされていません
- ブロック中(赤):リソースを待機中です
よくあるハングのパターンと修正方法
パターン 1:同期ファイル I/O
修正前(ハングします):
// Main thread blocks on file read
func loadUserData() {
let data = try! Data(contentsOf: largeFileURL) // BLOCKS
processData(data)
}
修正後(非同期):
func loadUserData() {
Task.detached {
let data = try Data(contentsOf: largeFileURL)
await MainActor.run {
self.processData(data)
}
}
}
パターン 2:フィルタリングされていない通知オブザーバー
修正前(すべてを処理します):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: nil // Receives ALL contexts
)
修正後(フィルタリング済み):
NotificationCenter.default.addObserver(
self,
selector: #selector(handleChange),
name: .NSManagedObjectContextObjectsDidChange,
object: relevantContext // Only this context
)
パターン 3:コストの高いフォーマッターの作成
修正前(毎回作成します):
func formatDate(_ date: Date) -> String {
let formatter = DateFormatter() // EXPENSIVE
formatter.dateStyle = .medium
return formatter.string(from: date)
}
修正後(キャッシュ済み):
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
func formatDate(_ date: Date) -> String {
Self.dateFormatter.string(from: date)
}
パターン 4:メインスレッドへの dispatch_sync
修正前(デッドロックの危険性):
// From background thread
DispatchQueue.main.sync { // BLOCKS if main is blocked
updateUI()
}
修正後(非同期):
DispatchQueue.main.async {
self.updateUI()
}
パターン 5:非同期処理の結果をセマフォで待機
修正前(メインスレッドをブロックします):
func fetchDataSync() -> Data {
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
URLSession.shared.dataTask(with: url) { data, _, _ in
result = data
semaphore.signal()
}.resume()
semaphore.wait() // BLOCKS MAIN THREAD
return result!
}
修正後(async/await):
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
パターン 6:ロックの競合
修正前(共有ロック):
class DataManager {
private let lock = NSLock()
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
lock.lock() // Main thread waits for background
defer { lock.unlock() }
return cache[key]
}
}
修正後(アクター):
actor DataManager {
private var cache: [String: Data] = [:]
func getData(for key: String) -> Data? {
cache[key] // Actor serializes access safely
}
}
パターン 7:アプリ起動時のハング(Watchdog)
修正前(処理が多すぎます):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
loadAllUserData() // Expensive
setupAnalytics() // Network calls
precomputeLayouts() // CPU intensive
return true
}
修正後(後回しにする):
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Only essential setup
setupMinimalUI()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Defer non-essential work
Task {
await loadUserDataInBackground()
}
}
パターン 8:メインスレッドでの画像処理
修正前(UI をブロックします):
func processImage(_ image: UIImage) {
let filtered = applyExpensiveFilter(image) // BLOCKS
imageView.image = filtered
}
修正後(バックグラウンド処理):
func processImage(_ image: UIImage) {
imageView.image = placeholder
Task.detached(priority: .userInitiated) {
let filtered = applyExpensiveFilter(image)
await MainActor.run {
self.imageView.image = filtered
}
}
}
Xcode Organizer によるハング診断
Window > Organizer > Select App > Hangs
Organizer には、診断情報の共有に同意したユーザーから集約されたハングデータが表示されます。
レポートの読み方:
- ハング率:デバイス 1 台あたり 1 日に発生するハング数
- コールスタック:ハングが発生した場所
- デバイス/OS の内訳:影響を受けた構成
コールスタックの解釈:
- 先頭に自分のコードがある場合:メインスレッドがアプリの処理でビジー状態です
- 先頭にシステム API がある場合:メインスレッドでブロッキング API を呼び出しています
- pthread_mutex/semaphore:ロックの競合、または明示的な待機です
MetricKit によるハング診断
アプリでハング診断を受け取るには、MetricKit を導入します。
import MetricKit
class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
if let hangDiagnostics = payload.hangDiagnostics {
for diagnostic in hangDiagnostics {
analyzeHang(diagnostic)
}
}
}
}
private func analyzeHang(_ diagnostic: MXHangDiagnostic) {
// Duration of the hang
let duration = diagnostic.hangDuration
// Call stack tree (needs symbolication)
let callStack = diagnostic.callStackTree
// Send to your analytics
uploadHangDiagnostic(duration: duration, callStack: callStack)
}
}
主な MXHangDiagnostic プロパティ:
hangDuration:ハングが継続した時間callStackTree:フレームを含む MXCallStackTreesignatureIdentifier:類似したハングをグループ化するための識別子
Watchdog による終了
Watchdog は、重要な遷移中にハングするアプリを強制終了します。
| 遷移 | 制限時間 | 結果 |
|---|---|---|
| アプリの起動 | 約 20 秒 | アプリが強制終了され、クラッシュが記録されます |
| バックグラウンドへの遷移 | 約 5 秒 | アプリが強制終了されます |
| フォアグラウンドへの遷移 | 約 10 秒 | アプリが強制終了されます |
Watchdog が無効になる環境:
- シミュレーター
- デバッガーが接続されている場合
- 開発ビルド(場合によります)
Watchdog による強制終了はクラッシュとして記録されます。例外タイプは EXC_CRASH (SIGKILL)、終了理由は Namespace RUNNINGBOARD, Code 3735883980(16 進数の 0xDEAD10CC — アプリが一時停止中にファイルロックまたは SQLite データベースロックを保持していたことを示します)です。
プレッシャーシナリオ
シナリオ 1:マネージャーが「ローディングスピナーを追加するだけでいい」と言う
状況:データの読み込み中にアプリがハングします。マネージャーは「修正」するためにスピナーを追加するよう提案します。
これでは解決しない理由:スピナーを追加してもハングは防げません。UI はフリーズしたままで、スピナーもアニメーションせず、アプリは応答しない状態が続きます。
適切な対応:「ハング中はメインスレッドがブロックされるため、スピナーはアニメーションしません。スピナーが実際に回転し、アプリが応答し続けるように、この処理をメインスレッド以外に移す必要があります。」
シナリオ 2:「テストでは問題なく動作します」
状況:QA ではハングを再現できません。ログには、本番環境で発生していることが示されています。
分析:
- 実環境のデバイスではデータサイズが異なります
- ネットワーク環境は変動します(接続が遅いほど同期に時間がかかります)
- バックグラウンドアプリがメモリ/CPU を消費します
- デバッグビルドでは Watchdog が無効です
対応:
- 実環境の診断情報を取得するために MetricKit を追加します
- 本番環境と同等のサイズのデータセットでテストします
- デバッガーを接続せずにテストします
- Organizer でハングレポートを確認します
シナリオ 3:「ずっとこのやり方でやってきた」
状況:レガシーコードがメインスレッドで同期 API を呼び出しています。リファクタリングは「リスクが高すぎる」とされています。
重要な理由:以前は問題なく動作していたとしても、次のような変化が起きている可能性があります。
- データが大きくなっている
- OS のアップデートによってタイミングが変わっている
- 新しいデバイスの特性が異なる
- アプリの動作が速くなるほど、ユーザーが問題に気付きやすくなる
アプローチ:
- メトリクスを追加し、現在のハング率を測定する
- フィーチャーフラグを使って段階的にリファクタリングする
- A/B テストで改善を示す
- 修正しない場合のリスクを文書化する
避けるべきアンチパターン
| アンチパターン | 問題点 | 代わりに |
|---|---|---|
バックグラウンドからの DispatchQueue.main.sync | デッドロックする可能性があり、常にブロックします | .async を使う |
| 非同期処理を同期処理に変換するためのセマフォ | 呼び出し元のスレッドをブロックします | 完了ハンドラー/await を使い、非同期処理のままにする |
| メインスレッドでのファイル I/O | レイテンシーが予測できません | バックグラウンドキューを使う |
| フィルターされていない通知オブザーバー | 関係のないイベントを処理します | オブジェクト/名前でフィルターする |
| ループ内でのフォーマッターの作成 | 初期化にコストがかかります | キャッシュして再利用する |
| 同期ネットワークリクエスト | ネットワークのレイテンシーでブロックします | URLSession の非同期 API を使う |
ハング防止チェックリスト
リリース前に、次の項目を確認してください。
- メインスレッドで
Data(contentsOf:)やファイル読み込みを行っていない - バックグラウンドスレッドから
DispatchQueue.main.syncを呼び出していない - メインスレッドで semaphore.wait() を呼び出していない
- フォーマッター(DateFormatter、NumberFormatter)がキャッシュされている
- 通知オブザーバーが適切にフィルターされている
- 起動時の処理が最小限になっている(必須でない処理は後回しにする)
- 画像処理がメインスレッド以外で行われている
- データベースクエリがメインスレッドで実行されていない
- フィールド診断に MetricKit が採用されている
リソース
WWDC:2021-10258、2022-10082
ドキュメント:/xcode/analyzing-responsiveness-issues-in-your-shipping-app、/metrickit/mxhangdiagnostic
スキル:axiom-metrickit-ref、axiom-performance-profiling、axiom-swift-concurrency、axiom-lldb(フリーズ発生時点での対話的なスレッド検査)