🙆‍♀️

KotlinコルーチンのCancellationExceptionについて扱い方

に公開
2

Kotlinコルーチンの例外処理について

📚公式Docs:Coroutines (kotlinx.coroutines) Coroutine exceptions handling

🐱 はじめに

つい先日、Kotlinで並行処理の実装: コルーチンを整理するという記事を書きましたが、
実際に使いながらコルーチンでの例外の扱いついてなどを把握できていなかったことに気づいたため、
今回は"Kotlinコルーチンの例外処理について"まとめていきたいと思う。

CancellationExceptionExceptionとついてるから他の例外と同じと思いやすいが、
正常なフローであるということを実際に動かしながら学ぼうという回です!!!!

📖 概要|CancellationExceptionとは

最初に簡単にまとめると、CancellationExceptionは、
コルーチンがキャンセルされたときに発生する特殊な例外です。
通常の例外とは異なる特性があり,大きく以下の3つの特徴がある。

クラス階層

Throwable
  └─ Exception
       └─ RuntimeException
            └─ IllegalStateException
                 └─ CancellationException
                      └─ TimeoutCancellationException
                      └─ JobCancellationException

📖 結論:CancellationExceptionの扱い方

推奨パターンの概要

ユースケース 推奨パターン 備考
単純なリソース解放 finallyブロック ほとんどのケースでこれで十分
複雑なクリーンアップ catchして再スロー キャンセル時の特別な処理が必要な場合
タイムアウトをエラーとする withTimeout + ビジネス例外に変換 HTTP 408エラーとして扱う
タイムアウトを許容 withTimeoutOrNull タイムアウト時はnullを返す
キャンセル後の重要処理 NonCancellable + タイムアウト 本当に必要な場合のみ使用

先にまとめたらこんな感じなのだが、じゃあなぜそうしなきゃいけないのか、
ここから詳しくみていく。

📖 どんなときにCancellationExceptionが投げられるのか

CancellationExceptionを投げる主要なメソッド・関数

メソッド/関数 投げる例外 用途
job.cancel() CancellationException コルーチンを手動でキャンセル
(ユーザー操作やビジネスロジックによる中断)
withTimeout() TimeoutCancellationException タイムアウト付き処理
(外部API、DBクエリなど)
ensureActive() CancellationException CPU集約処理でキャンセル状態を
明示的にチェック(ループ処理など)
delay() CancellationException 指定時間待機
(タイマー、ポーリング、リトライの待機時間)
yield() CancellationException 他のコルーチンに実行機会を譲る
(CPU集約処理の分割)

CancellationExceptionは上記のようなメソッドを使った、
主に以下の4つのタイミングで発生する

  1. 明示的なキャンセル - job.cancel()を呼び出したとき
  2. タイムアウト - withTimeout()で制限時間を超えたとき
  3. CPU集約処理のチェック - ensureActive()yield()を呼び出したとき
  4. 待機中のキャンセル - delay()実行中にキャンセルされたとき

それぞれのタイミングでどんな挙動になるか、動作確認も含めて記載していく✏️

1. 明示的なキャンセル(job.cancel())

明示的なキャンセル(job.cancel())による挙動をみる
/**
 * 例1: 基本的なCancellationExceptionの発生
 * 
 * job.cancel()を明示的に呼び出すと、コルーチン内でCancellationExceptionが発生します。
 * delay()などのキャンセレーション可能な関数を使用している場合のみです。
 */
fun demonstrateBasicCancellationException() = runBlocking {
    val job = launch {
        try {
            println("🔄 コルーチン開始")
            delay(1000) // キャンセレーション可能な関数
            println("✅ コルーチン完了(通常は実行されない)")
        } catch (e: CancellationException) {
            println("❌ CancellationExceptionキャッチ: ${e.message}")
            println("❌ 例外クラス: ${e.javaClass.simpleName}")
            throw e // 再スロー(推奨)
        }
    }
    
    delay(300) // 少し待機してからキャンセル
    println("📝 job.cancel()を実行")
    job.cancel()
    job.join()
    
    println("✅ 処理完了")
}

実行結果:

🔄 コルーチン開始
📝 job.cancel()を実行
❌ CancellationExceptionキャッチ: StandaloneCoroutine was cancelled
❌ 例外クラス: JobCancellationException
✅ 処理完了

2. タイムアウト(withTimeout())

タイムアウトwithTimeout()
fun demonstrateCatchAndRethrow() = runBlocking {
    println("\n=== パターン2: CancellationExceptionを捕捉 ===")
    
    val job = launch {
        try {
            println("🔄 処理開始")
            val data = loadData()
            processData(data)
            println("✅ 処理完了")
        } catch (e: CancellationException) {
            println("⚠️ CancellationException捕捉")
            println("🧹 複雑なクリーンアップ処理")
            
            // キャンセル時の特別な処理
            saveCurrentState()
            notifyUser("処理がキャンセルされました")
            
            throw e  // ✅ 必ず再スロー!
        }
    }
    
    delay(100)
    println("❌ キャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

suspend fun loadData(): String {
    delay(50)
    return "データ"
}

suspend fun processData(data: String) {
    delay(1000)
}

fun saveCurrentState() {
    println("   → 現在の状態をDBに保存")
}

fun notifyUser(message: String) {
    println("   → ユーザーに通知: $message")
}

実行結果:

=== パターン2: CancellationExceptionを捕捉 ===
🔄 処理開始
❌ キャンセル実行
⚠️ CancellationException捕捉
🧹 複雑なクリーンアップ処理
   → 現在の状態をDBに保存
   → ユーザーに通知: 処理がキャンセルされました
✅ 終了

重要: throw e を忘れると、キャンセル状態が失われる!

3. CPU集約処理でのキャンセルチェック(ensureActive/yield)

CPU集約処理でのキャンセルチェック

CPU集約的な処理(ループなど)では、delay()のようなサスペンド関数を呼ばないため、
キャンセルが検知されません。このような場合はensureActive()yield()を使います。

fun demonstrateCpuIntensiveTask() = runBlocking {
    println("=== CPU集約処理でのキャンセル ===")
    
    val job = launch {
        try {
            println("🔄 大量計算開始")
            repeat(1000) { i ->
                // ❌ Thread.sleep()はキャンセルを無視
                // Thread.sleep(1)
                
                // ✅ ensureActive()でキャンセル状態をチェック
                ensureActive()
                
                // 重い計算処理
                if (i % 100 == 0) {
                    println("   進捗: $i/1000")
                }
            }
            println("✅ 計算完了")
        } catch (e: CancellationException) {
            println("❌ 計算キャンセル: ${e.message}")
            throw e
        }
    }
    
    delay(50)
    println("📝 キャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

実行結果:

=== CPU集約処理でのキャンセル ===
🔄 大量計算開始
   進捗: 0/1000
📝 キャンセル実行
❌ 計算キャンセル: StandaloneCoroutine was cancelled
✅ 終了

yield()を使う例:

fun demonstrateYield() = runBlocking {
    println("\n=== yield()でのキャンセル ===")
    
    val job = launch {
        try {
            println("🔄 処理開始")
            repeat(5) { i ->
                println("   ステップ $i")
                yield()  // 他のコルーチンに実行機会を譲る + キャンセルチェック
            }
            println("✅ 処理完了")
        } catch (e: CancellationException) {
            println("❌ キャンセル検知")
            throw e
        }
    }
    
    delay(10)
    job.cancel()
    job.join()
    println("✅ 終了")
}

実行結果:

=== yield()でのキャンセル ===
🔄 処理開始
   ステップ 0
   ステップ 1
❌ キャンセル検知
✅ 終了

ポイント:

  • ensureActive() - キャンセル状態をチェックし、キャンセル済みなら即座にCancellationExceptionをスロー
  • yield() - 他のコルーチンに実行機会を譲りつつ、キャンセルもチェック
  • CPU集約処理では定期的にこれらを呼ぶ必要がある

4. 待機中のキャンセル(delay)

delay実行中のキャンセル

delay()はサスペンド関数で、内部でキャンセル状態を自動的にチェックします。

fun demonstrateDelayWithCancellation() = runBlocking {
    println("=== delay中のキャンセル ===")
    
    val job = launch {
        try {
            println("🔄 10秒待機開始")
            delay(10000)  // 10秒待機
            println("✅ 待機完了(実行されない)")
        } catch (e: CancellationException) {
            println("❌ 待機中にキャンセル: ${e.message}")
            throw e
        }
    }
    
    delay(500)  // 0.5秒後にキャンセル
    println("📝 待機中にキャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

実行結果:

=== delay中のキャンセル ===
🔄 10秒待機開始
📝 待機中にキャンセル実行
❌ 待機中にキャンセル: StandaloneCoroutine was cancelled
✅ 終了

複数のdelay()を使う例:

fun demonstrateMultipleDelays() = runBlocking {
    println("\n=== 複数のdelay()とキャンセル ===")
    
    val job = launch {
        try {
            println("🔄 ステップ1")
            delay(100)
            println("✅ ステップ1完了")
            
            println("🔄 ステップ2")
            delay(100)
            println("✅ ステップ2完了")
            
            println("🔄 ステップ3(長い待機)")
            delay(10000)  // ここでキャンセルされる
            println("✅ ステップ3完了(実行されない)")
        } catch (e: CancellationException) {
            println("❌ キャンセル検知")
            throw e
        }
    }
    
    delay(250)  // ステップ2とステップ3の間でキャンセル
    println("📝 キャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

実行結果:

=== 複数のdelay()とキャンセル ===
🔄 ステップ1
✅ ステップ1完了
🔄 ステップ2
✅ ステップ2完了
🔄 ステップ3(長い待機)
📝 キャンセル実行
❌ キャンセル検知
✅ 終了

ポイント:

  • delay()は協調的なキャンセルポイント
  • 待機中にキャンセルされると即座にCancellationExceptionをスロー
  • 複数のdelay()がある場合、次のdelay()呼び出し時にキャンセルが検知される

📖 協調的キャンセルとは?

コルーチンのキャンセルは協調的(cooperative) だ。

協調的キャンセルの意味

キャンセルが機能する場合 vs 機能しない場合

キャンセルが機能する場合・しない場合の比較

✅ キャンセルが機能する場合

fun demonstrateCooperativeCancellation() = runBlocking {
    println("=== 協調的キャンセル:機能する例 ===")
    
    val job = launch {
        try {
            repeat(5) { i ->
                println("🔄 反復 $i")
                delay(100)  // ✅ サスペンド関数:キャンセルをチェック
            }
            println("✅ 完了")
        } catch (e: CancellationException) {
            println("❌ キャンセル検知")
            throw e
        }
    }
    
    delay(250)
    println("📝 キャンセル実行")
    job.cancelAndJoin()
    println("✅ 終了")
}

実行結果:

=== 協調的キャンセル:機能する例 ===
🔄 反復 0
🔄 反復 1
🔄 反復 2
📝 キャンセル実行
❌ キャンセル検知
✅ 終了

❌ キャンセルが機能しない場合

fun demonstrateNonCooperativeCancellation() = runBlocking {
    println("\n=== 非協調的:キャンセルが機能しない例 ===")
    
    val startTime = System.currentTimeMillis()
    val job = launch {
        var nextPrintTime = startTime
        var i = 0
        
        // ❌ キャンセルポイントがない無限ループ
        while (i < 5) {
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("🔄 反復 $i: スリープ中...")
                i++
                nextPrintTime += 500
            }
            // ❌ Thread.sleep()はキャンセルを無視する
            Thread.sleep(100)
        }
        println("✅ 完了(キャンセルされない)")
    }
    
    delay(1000)
    println("📝 キャンセル実行(効果なし)")
    job.cancelAndJoin()
    println("✅ 終了(ジョブが完了するまで待機)")
}

実行結果:

=== 非協調的:キャンセルが機能しない例 ===
🔄 反復 0: スリープ中...
🔄 反復 1: スリープ中...
📝 キャンセル実行(効果なし)
🔄 反復 2: スリープ中...
🔄 反復 3: スリープ中...
🔄 反復 4: スリープ中...
✅ 完了(キャンセルされない)
✅ 終了(ジョブが完了するまで待機)

重要: Thread.sleep()を使うとキャンセルが無視される!

非協調的な処理を協調的にする方法

CPU集約処理を協調的にする

❌ 問題のあるコード

fun problematicCpuIntensiveTask() = runBlocking {
    println("=== 問題:キャンセルできないCPU集約処理 ===")
    
    val job = launch {
        var sum = 0L
        for (i in 1..1_000_000_000) {
            sum += i
            // ❌ キャンセルポイントがない
        }
        println("結果: $sum")
    }
    
    delay(100)
    println("📝 キャンセル実行(効果なし)")
    job.cancel()
    
    // ⚠️ キャンセルが効かないので完了を待つ
    println("⏳ 完了を待機中...")
}

✅ 改善したコード

fun cooperativeCpuIntensiveTask() = runBlocking {
    println("\n=== 改善:協調的なCPU集約処理 ===")
    
    val job = launch {
        try {
            var sum = 0L
            for (i in 1..1_000_000_000) {
                sum += i
                
                // ✅ 定期的にキャンセルをチェック
                if (i % 1_000_000 == 0) {
                    ensureActive()  // または yield()
                }
            }
            println("結果: $sum")
        } catch (e: CancellationException) {
            println("❌ 計算がキャンセルされました")
            throw e
        }
    }
    
    delay(100)
    println("📝 キャンセル実行")
    job.cancelAndJoin()
    println("✅ 終了")
}

実行結果:

=== 改善:協調的なCPU集約処理 ===
📝 キャンセル実行
❌ 計算がキャンセルされました
✅ 終了

協調的キャンセルをまとめると

ポイント 説明
協調的 コルーチン自身がキャンセルをチェックする必要がある
強制終了ではない 外部から強制的に停止させることはできない
キャンセルポイント delay(), yield(), ensureActive()などで明示的にチェック
ブロッキング操作 Thread.sleep()などはキャンセルを無視する
実装方法 CPU集約処理では定期的にensureActive()を呼ぶ

この協調的な設計により、コルーチンは安全にリソースをクリーンアップしてから終了できる。

📖 通常の例外との違いを実際に動かして確認

例外の伝播比較

項目 通常の例外 CancellationException
親への伝播 ✅ 伝播する ❌ 伝播しない
兄弟コルーチンへの影響 ✅ キャンセルされる ❌ 影響なし
エラーとしての扱い ✅ エラー ❌ 正常なフロー
CoroutineExceptionHandler ✅ 呼ばれる ❌ 呼ばれない

通常の例外(RuntimeException)のケース

通常の例外の伝播を確認
fun demonstrateNormalException() = runBlocking {
    println("=== 通常の例外の場合 ===")
    
    val handler = CoroutineExceptionHandler { _, exception ->
        println("❌ ExceptionHandler: ${exception.javaClass.simpleName}")
    }
    
    try {
        coroutineScope {
            launch(handler) {
                println("🔄 子コルーチン1開始")
                delay(100)
                throw RuntimeException("エラー発生")  // 通常の例外
            }
            
            launch {
                println("🔄 子コルーチン2開始")
                delay(1000)
                println("✅ 子コルーチン2完了")  // ❌ 実行されない
            }
        }
    } catch (e: Exception) {
        println("❌ 親で例外キャッチ: ${e.message}")
    }
    
    println("✅ 処理完了")
}

実行結果:

=== 通常の例外の場合 ===
🔄 子コルーチン1開始
🔄 子コルーチン2開始
❌ ExceptionHandler: RuntimeException
❌ 親で例外キャッチ: エラー発生
✅ 処理完了

ポイント:

  • 通常の例外は親に伝播する
  • 兄弟コルーチンもキャンセルされる
  • ExceptionHandlerが呼ばれる

CancellationExceptionのケース

CancellationExceptionの伝播を確認
fun demonstrateCancellationException() = runBlocking {
    println("\n=== CancellationExceptionの場合 ===")
    
    val handler = CoroutineExceptionHandler { _, exception ->
        println("❌ ExceptionHandler: ${exception.javaClass.simpleName}")
    }
    
    try {
        coroutineScope {
            val job1 = launch(handler) {
                println("🔄 子コルーチン1開始")
                delay(100)
                println("❌ 子コルーチン1をキャンセル")
                throw CancellationException("意図的なキャンセル")
            }
            
            launch {
                println("🔄 子コルーチン2開始")
                delay(200)
                println("✅ 子コルーチン2完了")  // ✅ 実行される!
            }
        }
    } catch (e: Exception) {
        println("❌ 親で例外キャッチ: ${e.message}")
    }
    
    println("✅ 処理完了")
}

実行結果:

=== CancellationExceptionの場合 ===
🔄 子コルーチン1開始
🔄 子コルーチン2開始
❌ 子コルーチン1をキャンセル
✅ 子コルーチン2完了
✅ 処理完了

ポイント:

  • CancellationExceptionは親に伝播しない
  • 兄弟コルーチンは影響を受けない
  • ExceptionHandlerは呼ばれない
  • 親のcatchブロックも実行されない

📖 具体的な扱い方

パターン1: 基本形 finallyでリソース解放

ほとんどのケースではこれで十分。
(例外が発生しても、正常終了しても、必ず実行されるという意味合い)

パターン2: CancellationExceptionを捕捉する場合

複雑なクリーンアップ処理が必要な場合など使用。
throw e を忘れると、キャンセル状態が失われるので注意!

例のコード
fun demonstrateCatchAndRethrow() = runBlocking {
    println("\n=== パターン2: CancellationExceptionを捕捉 ===")
    
    val job = launch {
        try {
            println("🔄 処理開始")
            val data = loadData()
            processData(data)
            println("✅ 処理完了")
        } catch (e: CancellationException) {
            println("⚠️ CancellationException捕捉")
            println("🧹 複雑なクリーンアップ処理")
            
            // キャンセル時の特別な処理
            saveCurrentState()
            notifyUser("処理がキャンセルされました")
            
            throw e  // ✅ 必ず再スロー!
        }
    }
    
    delay(100)
    println("❌ キャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

suspend fun loadData(): String {
    delay(50)
    return "データ"
}

suspend fun processData(data: String) {
    delay(1000)
}

fun saveCurrentState() {
    println("   → 現在の状態をDBに保存")
}

fun notifyUser(message: String) {
    println("   → ユーザーに通知: $message")
}

実行結果:

=== パターン2: CancellationExceptionを捕捉 ===
🔄 処理開始
❌ キャンセル実行
⚠️ CancellationException捕捉
🧹 複雑なクリーンアップ処理
   → 現在の状態をDBに保存
   → ユーザーに通知: 処理がキャンセルされました
✅ 終了

パターン3: TimeoutCancellationExceptionの扱い

タイムアウトは2つの扱い方があります。

タイムアウトをエラーとして扱う(withTimeout)

例のコード
fun demonstrateTimeoutAsError() = runBlocking {
    println("\n=== パターン3-1: タイムアウトをエラーとして扱う ===")
    
    try {
        withTimeout(500) {
            println("🔄 処理開始")
            delay(1000)
            println("✅ 処理完了")  // ❌ ここは実行されない
        }
    } catch (e: TimeoutCancellationException) {
        println("❌ タイムアウト発生: ${e.message}")
        // ビジネス例外に変換
        throw BusinessException("処理時間が制限を超えました", e)
    }
}

class BusinessException(message: String, cause: Throwable?) : 
    RuntimeException(message, cause)

実行結果:

=== パターン3-1: タイムアウトをエラーとして扱う ===
🔄 処理開始
❌ タイムアウト発生: Timed out waiting for 500 ms
Exception in thread "main" BusinessException: 処理時間が制限を超えました

Spring Bootでの実装例:

@RestController
class UserController(
    private val userService: UserService
) {
    @GetMapping("/users/{id}")
    suspend fun getUser(@PathVariable id: Long): UserDto {
        return try {
            withTimeout(3000) {  // 3秒でタイムアウト
                userService.findById(id)
            }
        } catch (e: TimeoutCancellationException) {
            throw ServiceTimeoutException(
                "ユーザー情報の取得がタイムアウトしました",
                e
            )
        }
    }
}

@ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
class ServiceTimeoutException(message: String, cause: Throwable?) :
    RuntimeException(message, cause)

HTTPレスポンス: 408 Request Timeout

タイムアウトを許容する(withTimeoutOrNull)

例のコード
fun demonstrateTimeoutOrNull() = runBlocking {
    println("\n=== パターン3-2: タイムアウトを許容する ===")
    
    val result = withTimeoutOrNull(500) {
        println("🔄 処理開始")
        delay(1000)
        "完了"
    }
    
    if (result == null) {
        println("⏱️ タイムアウト(例外なし)")
        println("📝 デフォルト値を使用")
    } else {
        println("✅ 結果: $result")
    }
    
    println("✅ 処理完了")
}

実行結果:

=== パターン3-2: タイムアウトを許容する ===
🔄 処理開始
⏱️ タイムアウト(例外なし)
📝 デフォルト値を使用
✅ 処理完了

Spring Bootでの実装例:

@GetMapping("/users/{id}/profile")
suspend fun getUserProfile(@PathVariable id: Long): UserProfileDto {
    // タイムアウトしてもnullを返すだけ(例外は投げない)
    val user = withTimeoutOrNull(3000) {
        userService.findById(id)
    }
    
    return if (user != null) {
        UserProfileDto.from(user)
    } else {
        // タイムアウト時はデフォルトプロフィールを返す
        UserProfileDto.createDefault(id)
    }
}

HTTPレスポンス: 200 OK(タイムアウトでも正常レスポンス)

パターン4: 例外の型の順序に注意

TimeoutCancellationExceptionIllegalStateExceptionを継承しているため、
順序が重要!

例外の継承関係:

IllegalStateException
  └─ CancellationException
       └─ TimeoutCancellationException
正しい例外ハンドリングの順序
fun demonstrateExceptionOrder() = runBlocking {
    println("\n=== パターン4: 例外の型の順序 ===")
    
    try {
        withTimeout(500) {
            delay(1000)
        }
    } catch (e: TimeoutCancellationException) {
        // ✅ より具体的な例外を先に
        println("❌ タイムアウト: ${e.message}")
    } catch (e: CancellationException) {
        // ⚠️ 通常のキャンセル
        println("⚠️ キャンセル: ${e.message}")
    } catch (e: IllegalStateException) {
        // ⚠️ その他のIllegalState
        println("❌ 不正な状態: ${e.message}")
    }
}

実行結果:

=== パターン4: 例外の型の順序 ===
❌ タイムアウト: Timed out waiting for 500 ms

パターン5: キャンセル後にsuspend関数を呼ぶ

通常、キャンセル後はsuspend関数を呼べないが、NonCancellableを使うと可能。

NonCancellableの使用例
fun demonstrateNonCancellable() = runBlocking {
    println("\n=== パターン5: NonCancellableの使用 ===")
    
    val job = launch {
        try {
            println("🔄 処理開始")
            delay(1000)
            println("✅ 処理完了")
        } finally {
            println("🧹 finallyブロック開始")
            
            // ❌ 通常のfinallyではsuspend関数は動作しない
            // delay(100)  // これはキャンセルされて実行されない
            
            // ✅ NonCancellableを使う
            withContext(NonCancellable) {
                println("📝 重要なデータを保存中...")
                delay(200)  // ✅ これは実行される
                saveImportantData()
                println("✅ データ保存完了")
            }
        }
    }
    
    delay(100)
    println("❌ キャンセル実行")
    job.cancel()
    job.join()
    println("✅ 終了")
}

suspend fun saveImportantData() {
    // 外部APIへの保存などの重要な処理
    delay(100)
    println("   → 重要データをDBに保存")
}

実行結果:

=== パターン5: NonCancellableの使用 ===
🔄 処理開始
❌ キャンセル実行
🧹 finallyブロック開始
📝 重要なデータを保存中...
   → 重要データをDBに保存
✅ データ保存完了
✅ 終了

注意点:

  • NonCancellable本当に必要な場合のみ使用
  • 長時間実行される処理には使わない(リソースリークの原因)
  • タイムアウトを設定することを推奨

📖 CancellationExceptionを再スローしたら、そのあとどうなるの?

結論:再スローしても例外として扱われない!

コード例と実行結果
fun demonstrateRethrowBehavior() = runBlocking {
    println("=== CancellationExceptionを再スローした場合 ===")
    
    try {
        val job = launch {
            try {
                println("🔄 処理開始")
                delay(1000)
                println("✅ 処理完了")
            } catch (e: CancellationException) {
                println("⚠️ CancellationException捕捉")
                println("🧹 クリーンアップ処理")
                throw e  // ✅ 再スロー
            }
        }
        
        delay(100)
        println("❌ キャンセル実行")
        job.cancel()
        job.join()
        
        println("✅ 処理完了(例外は発生していない)")
    } catch (e: Exception) {
        println("❌ 例外キャッチ: ${e.javaClass.simpleName}")
    }
}

実行結果:

=== CancellationExceptionを再スローした場合 ===
🔄 処理開始
❌ キャンセル実行
⚠️ CancellationException捕捉
🧹 クリーンアップ処理
✅ 処理完了(例外は発生していない)

CancellationExceptionを再スローしても、例外として外に伝播しない
これはコルーチンライブラリの設計で、以下のようになっている。
この設計によって、構造化された並行処理で個別のタスクを安全にキャンセルできるようになっている!

コルーチンライブラリの例外処理フロー

子コルーチン内で例外発生

コルーチンライブラリがキャッチ

例外の型をチェック

    ├─ CancellationException? 
    │   ↓ YES
    │   cancelInternal() → 子だけキャンセル、終了

    └─ 通常の例外?
        ↓ YES
        cancelParent() → 親もキャンセル

Spring Bootでの動作を確認

ケース1: 通常の例外の場合

通常の例外の場合のコードと実行結果
@RestController
class ExceptionTestController {
    
    @GetMapping("/test/normal-exception")
    suspend fun testNormalException(): String {
        return coroutineScope {
            launch {
                delay(100)
                throw RuntimeException("エラー発生")  // ← 通常の例外
            }
            delay(200)
            "完了"
        }
    }
}

結果: 500 Internal Server Error

{
  "timestamp": "2024-11-16T12:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "エラー発生",
  "path": "/test/normal-exception"
}

ケース2: CancellationExceptionの場合

CancellationExceptionの場合のコードと実行結果
@GetMapping("/test/cancellation-exception")
suspend fun testCancellationException(): String {
    return coroutineScope {
        val job = launch {
            try {
                delay(1000)
            } catch (e: CancellationException) {
                println("CancellationException捕捉")
                throw e  // 再スロー
            }
        }
        
        delay(100)
        job.cancel()  // キャンセル
        job.join()
        
        "完了"  // ← これが返される
    }
}

結果: 200 OK

完了

** CancellationExceptionは再スローしても、HTTPレスポンスには影響しない

ケース3: withTimeout()の場合

withTimeout()の場合のコードと実行結果
@GetMapping("/test/timeout")
suspend fun testTimeout(): String {
    return withTimeout(500) {
        delay(1000)  // タイムアウト
        "完了"
    }
}

結果: 500 Internal Server Error

{
  "timestamp": "2024-11-16T12:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "Timed out waiting for 500 ms",
  "path": "/test/timeout"
}

なぜ500になるのか?

withTimeout()TimeoutCancellationException呼び出し元に伝播する。
これは以下の理由からだ

  1. withTimeout()タイムアウトをエラーとして扱う設計
  2. そのため、例外を外に投げる
  3. Spring MVCがこれを500エラーとして処理

再スローの目的:キャンセル状態を保持する

再スローの目的は500エラーにすることではなく、キャンセル状態を正しく伝播させること

再スローの目的を示すコード
fun demonstrateRethrowPurpose() = runBlocking {
    println("\n=== 再スローの目的 ===")
    
    println("【❌ 再スローしない場合】")
    val job1 = launch {
        try {
            delay(1000)
        } catch (e: CancellationException) {
            println("CancellationException捕捉")
            // ❌ 再スローしない
        }
        
        // ⚠️ キャンセル済みなのに処理が続行される
        println("この処理は実行されるべきではない")
        try {
            delay(100)  // キャンセル済みコルーチンでdelay()を呼ぶとエラー
        } catch (e: Exception) {
            println("エラー: ${e.javaClass.simpleName}")
        }
    }
    
    delay(100)
    job1.cancel()
    job1.join()
    
    println("\n【✅ 再スローする場合】")
    val job2 = launch {
        try {
            delay(1000)
        } catch (e: CancellationException) {
            println("CancellationException捕捉")
            throw e  // ✅ 再スロー
        }
        
        // ✅ ここは実行されない(正しい動作)
        println("この行は実行されない")
    }
    
    delay(100)
    job2.cancel()
    job2.join()
    
    println("✅ 完了")
}

実行結果:

=== 再スローの目的 ===
【❌ 再スローしない場合】
CancellationException捕捉
この処理は実行されるべきではない
エラー: JobCancellationException

【✅ 再スローする場合】
CancellationException捕捉
✅ 完了
状況 再スローする? HTTPステータス 理由
job.cancel()でキャンセル ✅ する 200 OK 正常なキャンセルフロー
withTimeout()でタイムアウト ❌ しない(変換) 408 Timeout ビジネス例外に変換
クリーンアップが必要 ✅ する 200 OK キャンセル状態を保持
単純なリソース解放 - (finallyで十分) 200 OK 捕捉不要

❌ やってはいけないアンチパターン

アンチパターン1: CancellationExceptionを握りつぶす

// ❌ 最悪のパターン
launch {
    try {
        delay(1000)
    } catch (e: CancellationException) {
        println("キャンセルされました")
        // ❌ 再スローしない
    }
    // ⚠️ キャンセル済みなのに処理が続行される
    println("この処理は実行されるべきではない")
}

何が起こるか:

  • キャンセル状態が失われる
  • その後のsuspend関数呼び出しでエラー
  • リソースリーク
  • 予期しない動作

アンチパターン2: Exceptionで捕捉して再スローしない

// ❌ 危険なパターン
launch {
    try {
        throw CancellationException("明示的なキャンセル")
    } catch (e: Exception) {
        // CancellationExceptionも捕捉される(明示的にスローした場合)
        logger.error("エラー", e)
        // ❌ 再スローしない
    }
}

アンチパターン3: finallyでsuspend関数を呼ぶ(NonCancellableなし)

// ❌ 動作しないパターン
launch {
    try {
        delay(1000)
    } finally {
        // ❌ キャンセル後なので実行されない
        delay(100)
        saveData()
    }
}

📖 まとめ

CancellationExceptionの本質

  • 正常なフローの一部であり、エラーではない
  • 親に伝播しないため、他のコルーチンに影響を与えない
  • 協調的にしか機能しないため、サスペンド関数を使う必要がある

実務での推奨アプローチ

  1. 基本はfinallyで十分 - リソース解放はfinallyブロックで行う
  2. 複雑な処理は捕捉して再スロー - キャンセル時の特別な処理が必要な場合のみ
  3. タイムアウトは用途に応じて - エラーならwithTimeout、許容ならwithTimeoutOrNull
  4. Spring Bootでは適切に変換 - ビジネス例外に変換してHTTPステータスを制御

これらの原則を守ることで、コルーチンの例外処理を正しく、安全に実装できる。

Discussion

hondayahondaya

Coroutineの例外処理はハマりポイントなの所なので体系立って、実用的な例と一緒に解説されていて非常にわかりやすかったです!

一点気になったのが、

協調的キャンセルのベストプラクティス
ブロッキングIOはwithContext(Dispatchers.IO)で - キャンセル可能に

の部分で、Blocking I/Oの際にDispatcher.IOを指定した際は、I/O待ちでスレッドがブロックされることがあってもCPUリソースを無駄にしないようにスレッドプールを大きめに確保して、Concurrencyをあげるものの認識で、キャンセル可能になる認識ではなかったので、ここの部分が読んでいて少し引っかかりました!🤔

1
AirichanAirichan

見ていただき&コメントありがとうございます✨
確かにですね...!
協調的キャンセルのベストプラクティスから削除して修正しました!

1