1. 現場を凍りつかせる「try-catchを貫通するクラッシュ」

Android開発やKotlinによるサーバーサイド開発の現場で、非同期並行処理を書くとき、誰もが一度は次のようなコードを書いてレビューに提出するか、あるいはそのまま本番にデプロイして致命的なクラッシュを引き起こします。

// 💀 危険: try-catchで囲んでいるのにアプリがクラッシュする典型例
fun loadUserProfile() {
    viewModelScope.launch {
        try {
            val deferredUser = async {
                apiClient.fetchUser() // ここで HTTP 500 や IOException が発生!
            }
            val user = deferredUser.await()
            renderUser(user)
        } catch (e: Exception) {
            // 例外をキャッチして安全にエラーUIを表示するはず……?
            Log.e("UserVM", "ユーザー取得失敗: ${e.message}")
            showErrorToast()
        }
    }
}

コードを一見すると、deferredUser.await() を含むすべての処理が try { ... } catch (e: Exception) の内側にきれいに収まっています。Javaの CompletableFuture.get() や JavaScriptの try { await promise } catch の感覚からすれば、「例外が発生しても catch 節でキャッチされ、トーストが表示されて終わり」と確信したくなるはずです。

しかし、実際に端末上でテストを実行し、APIがエラーを返した瞬間に起きるのは驚くべき挙動です。

Logcatには確かに「ユーザー取得失敗」というログが刻まれている。しかしその直後、まるで何事もなかったかのように AndroidRuntime: FATAL EXCEPTION が発火し、プロセス全体が無慈悲に強制終了(SIGABRT)する。

「例外をキャッチしているのに、なぜアプリが落ちるのか?」「Kotlinのコルーチンは try-catch を無視するバグでもあるのか?」――この不可解極まりない挙動こそが、Kotlin Coroutinesが採用している 構造化並行性(Structured Concurrency)の最大の地雷 であり、多くのエンジニアが挫折する境界線です。

2. 構造化並行性の物理法則: 例外は「値」ではなく「破壊シグナル」

この現象の根本原因を理解するには、コルーチンにおける例外の正体を知る必要があります。手続き型言語における例外は「コールスタックを巻き戻す制御フロー」ですが、コルーチンにおける例外は 「親子関係を結ぶ Job ツリー全体を燃え上がらせる破壊シグナル」 です。

Job階層における例外のエスカレーション

コルーチンはすべて、階層構造(Jobツリー)の中で動作しています。上記のコードにおける親子関係を図解すると次のようになります。

viewModelScope (Root SupervisorJob)
    │
    └── launch (Parent Job)
            │
            └── async (Child Job)  💥 ここで例外が発生!

ここで重要なのは、launch と async のビルダーとしての挙動の違いと共通点です。

問題は、「async もまた launch と全く同様に、例外が発生した瞬間に親Jobへエスカレーションする」 という仕様です。多くの開発者は「async は結果を Deferred にカプセル化するのだから、例外も await() を呼ぶまで内部に隔離されている」と誤解しています。しかし、事実は異なります。

タイムラインを追う: なぜ try-catch は手遅れなのか?

例外が発生した瞬間のイベントループを時系列で追ってみましょう。

  1. async ブロック内で fetchUser() が例外を投げる。
  2. async の子Jobは即座に「失敗(Failed)」状態へ遷移する。
  3. ここが最重要: await() が呼ばれているかどうかに一切関係なく、子Jobは親Job(launch)へ『失敗シグナル』を叩き込む。
  4. 親Job(launch)は通常のJobであるため、子から受け取った失敗シグナルによって自身をキャンセル状態へ移行させ、ぶら下がっている他のすべての子コルーチンを道連れキャンセルする。
  5. 親Jobはさらに上位のルートスコープ(viewModelScope)へと例外をエスカレーションする。
  6. 例外は最上位の未処理例外ハンドラ(Androidでは Thread.defaultUncaughtExceptionHandler)に到達し、OSによってプロセスが殺される。

では、あの try { deferredUser.await() } catch は一体何をしていたのでしょうか?

await() は、単に「すでに失敗して死んだ async の結果を取り出そうとしたため、その例外を呼び出し元スレッドに再スロー(Rethrow)した」だけに過ぎません。つまり、あなたのコードが catch 節で例外を拾ったときには、すでに親Jobツリーは全滅し、OSへのクラッシュ報告パイプラインが不可逆に走り出していた のです。

3. 現場で横行する「2大まじない(誤用)」を暴く

このクラッシュに直面したエンジニアが、ドキュメントの斜め読みや適当なネット記事、AIの誤った回答を信じて導入しがちな「2大アンチパターン」が存在します。これらは現場レビューで絶対に弾かなければなりません。

誤用①: launch(SupervisorJob()) という無意味な気休め

「SupervisorJobを使えば子の例外が親に伝播しない」という知識を中途半端にかじった人が、次のようなコードを書きます。

// 💀 アンチパターン: 完全に無意味な SupervisorJob の渡し方
viewModelScope.launch(SupervisorJob()) {
    try {
        val deferred = async { fetchUser() }
        val user = deferred.await()
    } catch (e: Exception) {
        Log.e("TAG", "これで防げるはず? -> クラッシュします")
    }
}

これは1ミリも効果がありません。なぜなら、launch(context) の引数に渡した SupervisorJob() は、launch が生成する新しいコルーチンの「親のJob」を上書きするだけであり、launch 自体とその子である async の関係は通常の双方向キャンセル関係のまま だからです。

依然として async の失敗は launch を即死させ、未処理例外が投げられます。さらに悪いことに、引数で渡した SupervisorJob() は親スコープ(viewModelScope)から切り離された孤立Jobとなり、メモリリークとコルーチンのゾンビ化を招きます。

誤用②: async に CoroutineExceptionHandler を渡す空振り

「例外ハンドラを直接渡せばいいのでは?」と考え、次のように書くパターンです。

// 💀 アンチパターン: async に渡したハンドラは完全に無視される
val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("Handler", "キャッチしたつもり: $exception")
}

viewModelScope.launch {
    // 警告: async に渡した handler は一切呼ばれない!
    val deferred = async(handler) {
        fetchUser()
    }
    deferred.await()
}

Kotlin公式ドキュメントに明記されていますが、CoroutineExceptionHandler が機能するのは 「ルートコルーチン(スコープ直下の最上位Job)」でのみ です。子コルーチンとして起動された async や launch にコンテキストとしてハンドラを渡しても、フレームワーク内部で完全に無視され、例外は素通りして親Jobへと突き抜けます。

4. 処方箋: 失敗を局所化する3つの設計原則

では、どのように設計すればクラッシュを防ぎ、安全な並行処理とエラーハンドリングを実現できるのでしょうか?現場で徹底すべき3つの原則を提示します。

原則1: 単一の非同期処理なら async を即座に追放せよ

現場で見られる async/await 事故の8割以上は、「そもそも並行(Concurrent)処理をする必要がない単一の処理」 に対して書かれています。「バックグラウンドスレッドで処理したいから」という理由だけで安易に async を呼ぶのは明確なアンチパターンです。

単一の処理であれば、シンプルに withContext を使います。

// ✅ ベストプラクティス: 単一処理なら withContext を使う
fun loadUserProfile() {
    viewModelScope.launch {
        try {
            // withContext は現在のコルーチンを中断し、値を返す
            val user = withContext(Dispatchers.IO) {
                apiClient.fetchUser()
            }
            renderUser(user)
        } catch (e: Exception) {
            // 通常の同期コードと全く同じ感覚で 100% 安全にキャッチ可能!
            Log.w("UserVM", "安全にハンドリング完了: ${e.message}")
            showErrorToast()
        }
    }
}

withContext は子コルーチンを独立したJobツリーとして切り離すのではなく、同一コルーチン内でディスパッチャを切り替えて中断(Suspend)する設計です。内部で投げられた例外は自然に呼び出し元の try-catch に到達し、親Jobを破壊することなく安全に処理できます。

原則2: 複数APIを束ねるなら supervisorScope で絶縁壁を築け

「ユーザー情報」と「注文履歴」の2つのAPIを並行して同時に叩き、どちらか一方が失敗しても画面を可能な限り描画したい、という要件は頻出します。この場合、coroutineScope ではなく supervisorScope を使用します。

// ✅ ベストプラクティス: supervisorScope で子の失敗を局所化する
fun loadDashboard() {
    viewModelScope.launch {
        // supervisorScope が子の失敗を親や兄弟に波及させない「防火壁」になる
        supervisorScope {
            val userDeferred = async { apiClient.fetchUser() }
            val ordersDeferred = async { apiClient.fetchOrders() }

            val user = try {
                userDeferred.await()
            } catch (e: Exception) {
                Log.w("Dashboard", "ユーザー取得失敗(デフォルト値を使用)", e)
                User.Guest
            }

            val orders = try {
                ordersDeferred.await()
            } catch (e: Exception) {
                Log.w("Dashboard", "注文履歴取得失敗(空リストを使用)", e)
                emptyList()
            }

            renderDashboard(user, orders)
        }
    }
}

supervisorScope は、そのブロック内で起動された子コルーチン(async)に対して「親方向への例外伝播を遮断するスーパーバイザー特性」を与えます。仮に fetchUser() が爆発しても、ordersDeferred は道連れキャンセルされず、外側の launch もクラッシュしません。ここで初めて、userDeferred.await() の周囲に置いた try-catch が真の安全ネットとして機能します。

原則3: 例外を大域脱出に使わず、型安全な Result に閉じ込めろ

そもそも論として、「HTTP 404や500、ネットワーク切断」といった想定内のドメインエラーを Exception のスローという大域脱出メカニズムに頼っている設計自体が、非同期アーキテクチャの脆弱性の温床です。

リポジトリ層やAPIクライアント層の境界で、例外を捕捉して明示的な Result<T> や直和型(Sealed Interface)に変換して返却する設計に倒すべきです。

// コルーチンキャンセル例外を絶対に握りつぶさない安全なヘルパー
inline fun <T> safeRunCatching(block: () => T): Result<T> = runCatching(block).onFailure {
    if (it is CancellationException) throw it // 構造化並行性の生命線を保護
}

class UserRepository(private val api: ApiClient) {
    suspend fun getUser(): Result<User> = withContext(Dispatchers.IO) {
        safeRunCatching { api.fetchUser() }
    }
}

戻り値が Result<User> であれば、async の内部で予期せぬ例外が爆発してJobツリーを破壊するリスクそのものを根絶できます。userResult.fold(...) で成功と失敗を型安全に分岐するだけで、エラーハンドリングは驚くほど平穏になります。

5. まとめ: 「非同期コードはJobツリーの物理法則に従う」

「try-catchで囲んだのにアプリが落ちる」という挙動は、Kotlinの気まぐれでもバグでもありません。バックグラウンドで起動した処理がエラーになったとき、無関係なゾンビタスクがリソースを食い散らかしながら永遠に走り続けるのを防ぐために、構造化並行性が敷いた 厳格な物理法則 です。

「例外をどこでcatchするか」という手続き型の発想を捨て、「この処理が失敗したとき、どのJobの範囲までを巻き込んで破棄すべきか(Blast Radius)」というJobツリーの境界設計に思考をシフトすること。それこそが、本番環境で何があっても落ちない堅牢なKotlinアプリケーションを組み上げるプロフェッショナルの条件です。