Contents
1. 例外はスコープ階層で子 → 親へ自動伝搬する
概要
Kotlin の構造化並行性では CoroutineScope がツリー構造を形成し、各 Job が親子関係を持ちます。子コルーチンが例外で失敗すると、その Job は Cancelled 状態になり、親の Job に通知されます(Kotlin Coroutines Guide – Structured Concurrency)。
実装例(エラーハンドリング付き)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import kotlinx.coroutines.* fun main() = runBlocking { // root は this.runBlocking の Job をそのまま利用 val root = this // 子 A:正常に終了 val jobA = launch(root.coroutineContext) { delay(100) println("A completed") } // 子 B:例外をスロー → 失敗が親へ伝搬 val jobB = launch(root.coroutineContext) { delay(50) throw IllegalStateException("B failed") // ← ここで例外発生 } try { joinAll(jobA, jobB) // jobB が失敗すると root がキャンセルされる } catch (e: CancellationException) { println("Root cancelled because of: ${e.cause}") } } |
実行結果
|
1 2 |
Root cancelled because of: java.lang.IllegalStateException: B failed |
jobA は jobB の例外が伝搬した瞬間にキャンセルされ、リソースリークを防げます。
2. launch と async の例外取得 – 安全なパターン
| 特性 | launch |
async |
|---|---|---|
| 戻り値 | Job(結果なし) |
Deferred<T>(結果または例外を保持) |
| 例外の流れ | コルーチンコンテキストへ即時伝搬 → 未捕捉ならアプリ全体がクラッシュ | await() 時に再スローされるまで保留 |
推奨コード
launch の内部で try / catch
|
1 2 3 4 5 6 7 8 9 10 11 |
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope.launch { try { delay(100) throw IllegalArgumentException("Invalid data") } catch (e: Exception) { println("Caught inside launch: ${e.message}") } } |
async の例外は await() で取得
|
1 2 3 4 5 6 7 8 |
val deferred = scope.async { delay(50) throw IllegalStateException("Network error") } runCatching { deferred.await() } .onFailure { e -> println("Caught from async: ${e.message}") } |
ポイント
asyncの結果を忘れると「サイレント失敗」になるので、必ずawait()かjoin()で取得しましょう。
launch系はCoroutineExceptionHandlerと併用すると未捕捉例外の一括処理が可能です(次節参照)。
3. CoroutineExceptionHandler の正しい組み合わせ
ハンドラの登録方法
|
1 2 3 4 5 6 7 |
val handler = CoroutineExceptionHandler { _, exception -> println("Global handler: ${exception.localizedMessage}") } // SupervisorJob と併用すると、子コルーチンの失敗がハンドラに届く val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) |
SupervisorJob + CoroutineExceptionHandler のベストプラクティス
|
1 2 3 4 5 6 7 8 9 10 11 |
scope.launch { // 子1:例外はハンドラで捕捉される launch { throw IllegalStateException("Task 1 failed") } // 子2:個別に try/catch しても OK launch { try { delay(30); error("Task 2 failed") } catch (e: Exception) { println("Handled locally: ${e.message}") } } } |
SupervisorJobは子の失敗が他の子や親に波及しないようにする一方で、未捕捉例外はハンドラへ流れる ため、全体の安全性が確保できます。
4. SupervisorJob / supervisorScope による部分失敗許容パターン
基本的な使い方
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
val supervisor = CoroutineScope(SupervisorJob() + Dispatchers.Default) supervisor.launch { // 成功タスク launch { delay(100); println("Task A completed") } // 失敗タスク(例外はハンドラへ) launch { throw IllegalArgumentException("Task B error") } // 別成功タスク launch { delay(150); println("Task C completed") } } |
出力例
|
1 2 3 4 |
Task A completed Task C completed Global handler: Task B error |
UI での実装例(ViewModel)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class MainViewModel : ViewModel() { private val handler = CoroutineExceptionHandler { _, ex -> _uiState.value = UiState.Error(ex.message ?: "不明エラー") } // SupervisorJob が失敗を局所化、handler が全体の例外を捕捉 private val vmScope = viewModelScope + SupervisorJob() + handler fun loadDashboard() = vmScope.launch { // それぞれ独立して失敗可 async { repository.getUserInfo() }.await() .also { _uiState.value = UiState.UserLoaded(it) } async { repository.getNewsFeed() }.await() .also { _uiState.value = UiState.NewsLoaded(it) } } } |
バックグラウンドバッチ処理の例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class BatchProcessor( private val repo: Repository, private val logger: Logger ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) fun start(items: List<Item>) = scope.launch { supervisorScope { items.map { item -> launch { runCatching { repo.process(item) } .onFailure { e -> logger.error("Item ${item.id} failed", e) } } }.joinAll() } } fun cancel() = scope.cancel() } |
5. Kotlin 1.9 の runBlocking 改善とテスト戦略
改善点(公式リリースノート参照)
Kotlin 1.9 では runBlocking がキャンセル伝搬を即時に行うよう内部実装が変更 されました(Kotlin 1.9 Release Notes – Coroutines)。これにより、テストや CLI ツールで runBlocking が途中でキャンセルされた際に残存ジョブが残り続けるリスクが大幅に低減されます。
実務向けテスト例 – runTest + Turbine
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.time.Duration.Companion.seconds import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class RepositoryTest { private val api = mockk<Api>() private val repo = Repository(api) @Test fun `fetchData が例外をスローしたときリトライが行われる`() = runTest { // 1回目は失敗、2回目で成功 coEvery { api.getData() } .throws(RuntimeException("Network error")) .andThenReturns(Data("ok")) repo.fetchData() .test { assertEquals(Result.success(Data("ok")), awaitItem()) awaitComplete() } // リトライが2回呼ばれたことを検証 coVerify(exactly = 2) { api.getData() } } } |
CI で安定させるコツ
- ディスパッチャは明示的に指定
runTest { dispatcher = Dispatchers.IO }のように本番と同じディスパッチャを使用すると、環境差異が減ります。 - 仮想時間の制御
advanceUntilIdle()を使ってタイマーや遅延を即座に進め、テスト実行時間を短縮します。 - 例外検証は統一
assertFailsWith<Exception>と Turbine のawaitError()を組み合わせると、型・メッセージの両方を確実にチェックできます。
6. 記事の要点まとめ
- 例外伝搬は子 → 親へ自動で流れ、
SupervisorJobと併用すれば失敗が他タスクに波及しません。 launchは内部でtry/catchもしくはCoroutineExceptionHandlerを必ず設定し、asyncはawait()で例外取得を徹底します。- ベストプラクティス:
SupervisorJob + CoroutineExceptionHandlerの組み合わせで未捕捉例外を一元管理。 - UI 層はハンドラ+Snackbar 等でユーザーに即時フィードバック、Repository は
retryWhenと指数バックオフで自動リトライ、バッチ処理はsupervisorScopeで部分失敗許容し個別ロギングを行います。 - Kotlin 1.9 の
runBlocking改善によりテストや CLI ツールの安定性が向上。runTestと Turbine を活用すれば例外シナリオも deterministic に検証可能です。
これらのパターンを自プロジェクトに組み込めば、クラッシュや予期せぬエラーによるユーザー体験の低下を防止しつつ、保守性・可観測性に優れたコードベースが実現できます。