Contents
1. launch と async の例外伝搬の違い
launch と async はどちらもコルーチンビルダーですが、失敗時の振る舞いが根本的に異なります。このセクションではそれぞれの特徴と、実際に例外がいつ・どこで再スローされるかを確認します。
1‑1. 基本的な挙動
launch は fire‑and‑forget 用に設計されており、内部で未捕捉例外が発生すると即座に親スコープへ伝搬し、スコープ全体がキャンセルされます。一方 async は結果を Deferred<T> に包むため、例外は 保留 され、呼び出し側が await()(または join()) を行ったときに初めて再スローされます。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// launch の例 val job = scope.launch { throw IllegalStateException("boom") // すぐ上位へ伝搬 → スコープキャンセル } // async の例 val deferred = scope.async { throw IllegalArgumentException("oops") } runCatching { deferred.await() } .onFailure { println("await で捕捉: $it") } // await 時点で例外が再スローされる |
1‑2. 実務上の使い分け指針
| ビルダー | 主な用途 | 例外処理のポイント |
|---|---|---|
launch |
UI 更新、I/O の副作用系 | 未捕捉例外はハンドラか SupervisorJob で吸収 |
async |
計算結果や API 呼び出しの戻り値 | 必ず await()/join() で例外を取得 |
2. 子コルーチンから親へ例外が伝搬する仕組み
子コルーチンが未捕捉例外を投げたとき、デフォルトの Job ではその例外が親スコープ全体に波及し、すべての子がキャンセルされます。逆に SupervisorJob を利用すると、失敗した子だけが止まり他は継続できます。
2‑1. デフォルト Job の伝搬
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
val parent = CoroutineScope(SupervisorJob() + Dispatchers.Default) parent.launch { launch { // 子①:例外で失敗 delay(100) throw RuntimeException("child1 fail") } launch { // 子②:正常に動作 repeat(5) { i -> println("子2: $i") delay(50) } } } |
上記は SupervisorJob を付与しているため、子①の例外が子②に影響しません。Job()(デフォルト)を使うと子①の失敗で子②もキャンセルされます。
2‑2. 部分的失敗を許容する設計パターン
SupervisorJobをスコープ全体に付与 → 長期タスクで部分失敗を許容したいときsupervisorScope { … }→ 短命な並列処理だけをサンドボックス化したいとき
両者とも「例外は孤立させ、他の子コルーチンは続行」する点が共通です。
3. CoroutineExceptionHandler の正しい配置
CoroutineExceptionHandler は 未捕捉例外 を一元的にロギングしたり UI に通知したりするために使います。ただし、ハンドラが受け取れるのは「スコープで捕捉されていない」例外だけです。
3‑1. 設置場所と適用範囲
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
val globalHandler = CoroutineExceptionHandler { _, exception -> Log.e("GlobalError", "未捕捉例外: $exception") } // ViewModel の例 class MyViewModel : ViewModel() { private val vmScope = viewModelScope + globalHandler fun loadData() = vmScope.launch { repository.fetch() } } |
viewModelScope にハンドラを合成すれば、ViewModel が生存している間はすべての子コルーチンで未捕捉例外がロギングされます。
3‑2. async の例外はハンドラだけで捕捉できない
|
1 2 3 4 5 6 7 8 9 |
val scope = CoroutineScope(Dispatchers.IO + globalHandler) scope.async { throw IllegalStateException("async error") } // ここでは何も起きない(例外は保留) runCatching { scope.async { ... }.await() } .onFailure { Log.e("AwaitError", it.toString()) } // await 時点で捕捉 |
async の結果を 必ず await() もしくは join() して例外を取得しなければ、ハンドラだけでは検知できません。
4. SupervisorJob と supervisorScope の実装パターン
4‑1. 長期スコープでの SupervisorJob
|
1 2 3 4 5 6 7 8 9 10 |
class MyViewModel : ViewModel() { // 独自スコープに SupervisorJob を付与 private val supervisorScope = viewModelScope + SupervisorJob() fun fetchMultiple() = supervisorScope.launch { launch { repositoryA.fetch() } // 失敗しても他は続く launch { repositoryB.fetch() } } } |
このパターンは ネットワーク呼び出しを並列実行 したいときに便利です。どちらかが失敗しても UI が完全に止まることはありません。
4‑2. 短命ブロックでの supervisorScope
|
1 2 3 4 5 6 7 8 |
fun loadPages() = viewModelScope.launch { supervisorScope { launch { pageA.load() } // 例外が起きてもこのスコープ自体は終了しない launch { pageB.load() } } // 上記の結果に関係なく UI 更新処理へ進める } |
supervisorScope は ローカルなサンドボックス として機能し、外部スコープへの影響を最小限に抑えます。
5. CancellationException の取り扱い
CancellationException はコルーチンの協調キャンセルを表す特別な例外です。捕捉したら必ず再スローしないと、キャンセル状態が失われてリソースリークにつながります。
5‑1. 再スローが必要な理由
|
1 2 3 4 5 6 7 8 9 |
scope.launch { try { delay(1000) // キャンセル対象のサスペンドポイント } catch (e: CancellationException) { Log.i("Cancel", "キャンセル検知") throw e // 必ず再送出して上位に伝搬させる } } |
catch ブロックでログやクリーンアップだけを行い、例外自体は捨てないことが重要です。
5‑2. キャンセルの双方向伝搬
| 条件 | 子 → 親へのキャンセル | 親 → 子へのキャンセル |
|---|---|---|
Job.cancel() 呼び出し |
即時 CancellationException が上位へ伝搬 |
すべての子が即座にキャンセル |
子コルーチンが例外を投げる(デフォルト Job) |
親もキャンセルされる | - |
SupervisorJob 使用時 |
子例外は親に波及しない | 明示的に cancel() したときだけ全体がキャンセル |
6. 例外ハンドリングのベストプラクティス(Android 向け)
6‑1. try / catch の粒度
例外捕捉は ビジネスロジック単位、もしくは withContext 内に限定し、広範囲で捕捉するとスタックトレースが失われます。
|
1 2 3 4 5 6 7 8 9 |
suspend fun fetchUser(): User = withContext(Dispatchers.IO) { try { api.getUser() } catch (e: IOException) { // ネットワーク障害だけをここで処理 throw e // 必要なら上位へ再送出 } } |
6‑2. viewModelScope とハンドラの併用
|
1 2 3 4 5 6 7 8 9 10 11 |
class MainViewModel : ViewModel() { private val handler = CoroutineExceptionHandler { _, ex -> _uiState.value = UiState.Error(ex.localizedMessage) } fun loadData() = (viewModelScope + handler).launch { val data = repository.load() _uiState.value = UiState.Success(data) } } |
viewModelScope が自動的にキャンセルされるため、UI が破棄されたときのリークを防げます。
6‑3. repeatOnLifecycle で安全なデータ収集
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class MainFragment : Fragment(R.layout.fragment_main) { private val viewModel: MainViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> // UI 更新処理 } } } } } |
repeatOnLifecycle は ライフサイクルが STARTED である間だけ コレクションを実行し、バックグラウンドになると自動的にキャンセルします。これにより例外も安全に処理できます。
公式参照: repeatOnLifecycle
7. テストで例外シナリオを検証する
7‑1. runTest を用いた決定的テスト
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class RepositoryTest { @Test fun `fetchUser throws IOException`() = runTest { val repo = FakeRepository(throwOnFetch = true) val exception = assertThrows<IOException> { repo.fetchUser() } assertEquals("Network error", exception.message) } } |
runTest は仮想タイムを使用するため、delay などの非同期処理が即座に完了し、例外伝搬ロジックを deterministic に確認できます。
公式参照: Testing coroutines
7‑2. SupervisorJob のテストポイント
|
1 2 3 4 5 6 7 8 9 10 11 |
@Test fun `supervisorScope isolates failures`() = runTest { val results = mutableListOf<String>() supervisorScope { launch { delay(10); results.add("A") } launch { throw IllegalStateException() } // 失敗しても他は続く launch { delay(20); results.add("B") } } assertEquals(listOf("A", "B"), results) } |
8. Kotlin 1.9 と Coroutines 1.8 の新機能(正確な情報)
| 新機能 | 説明 | 実務での活用例 |
|---|---|---|
| sealed interface(Kotlin 1.9) | sealed がインターフェースでも使えるようになり、エラーハンドリング用の型階層を簡潔に表現できる。 |
API の結果を Result<Success, Failure> として安全に扱う |
| structured concurrency の内部改善(Coroutines 1.8) | coroutineScope/supervisorScope の実装が軽量化され、コンパイル時チェックが強化された。新しい API は追加されていないため、既存の coroutineScope/supervisorScope をそのまま利用できる。 |
以前と同様に安全なスコープを作成しつつ、ビルドエラーで誤用を防止 |
| Dispatchers.Main.immediate のデフォルト改善 | UI スレッド上の即時実行が保証され、repeatOnLifecycle と相性が向上した。 |
UI 更新処理の遅延バグが減少し、フラッシュな描画が可能 |
公式参照: Kotlin 1.9 Release Notes ・ kotlinx.coroutines 1.8 Release Notes
まとめ
launchとasyncの例外伝搬は根本的に異なる。前者は即座にスコープ全体をキャンセルし、後者はawait()時点で再スローされるので必ず取得すること。CoroutineExceptionHandlerはトップレベルスコープ(例:viewModelScope)へ合成 すれば未捕捉例外の一元ロギングが実現でき、asyncの保留例外は個別にハンドリングする必要がある。- 部分失敗を許容したい場合は
SupervisorJobまたはsupervisorScopeを活用し、子コルーチンの失敗が他に波及しない設計を取る。 CancellationExceptionは必ず再スロー してキャンセル情報を上位へ伝搬させ、リソースリークを防止する。- Android のライフサイクルと組み合わせた安全なコルーチン管理(
viewModelScope+ハンドラ、repeatOnLifecycle)で UI の安定性を確保できる。 - テストは
runTestとassertThrowsで決定的に実施し、例外シナリオが正しく伝搬することを検証すべき。 - Kotlin 1.9 / Coroutines 1.8 の最新機能は型安全とスコープ安全性をさらに高めるので、プロジェクトの依存バージョン更新を検討する価値がある。
これらのポイントをコードベースに組み込むことで、非同期処理の堅牢性が大幅に向上し、予期せぬクラッシュやリソースリークのリスクを最小限に抑えることができます。ぜひ実務で試してみてください。