6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CancellationExceptionはなぜcatchしてはいけないのか

6
Posted at

こんにちは、こんばんは、kitakkunです。

AIによるKotlinのコードレビューで、「CancellationExceptioncatch するな」と言われたことがある方は多いのではないでしょうか。

suspend fun doSomething() {
    try {
        doSomethingCanThrowException()
    } catch (e: Throwable) {
        e.printStackTrace()
    }
}

このような suspend 関数を書くと、高確率で以下のように書き換えるように言われます。

suspend fun doSomething() {
    try {
        doSomethingCanThrowException()
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        e.printStackTrace()
    }
}

これは、Throwable または Exception としての catch では範囲が広すぎて、CancellationException まで拾ってしまい、結果として Coroutine キャンセルの仕組みを壊してしまうからです。

とは言っても、具体的にどんな実害があり、どのくらい危険なのかが想像しづらいと思ったので、記事として整理することにしました。

CancellationException は何者か

Kotlin Coroutines は 「CancellationException という特別な例外を投げて伝搬させる」ことによって、非同期で動くコルーチンのキャンセルを実現しています。

コルーチンは親子関係を持ち、親の job がキャンセルされた時、内部的には以下の処理が動きます。

  1. 親が子の状態を「キャンセル中」に変更
  2. 子は、処理が一時停止するsuspensionポイントでCancellationExceptionthrowする
  3. 子は、コルーチンの完了を親に通知する
  4. 全ての子コルーチン完了後、親のキャンセルが完了する

シーケンス図にするとこんな感じになります。

このフローの一部となる CancellationExceptioncatch してしまうと不整合が起こる、というのはなんとなく想像できると思います。

では、具体的にどんな問題が起こるのか、良くありそうな Coroutine のユースケースを例に考えてみます。

実害が及ぶパターン

CoroutineScope 内で while ループを作って定期実行するのは、ごく一般的なユースケースかと思います。

その中で CancellationExceptioncatch してしまうと何が起こるのか・・?

runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    val job = scope.launch {
        while(true) {
            try {
                delay(1.seconds)
                println("do something")
            } catch (e: Throwable) {
                println("error: ${e.message}")
            }
        }
    }

    delay(1.5.seconds) // whileループの2回目のdelayでsuspendするまで待機
    job.cancel()

    awaitCancellation() // プログラムが終了しないように止める
}

このサンプルコードを実行すると、無限にエラーメッセージがコンソールに流れ続けることが確認できます。

do something
error: StandaloneCoroutine was cancelled
error: StandaloneCoroutine was cancelled
error: StandaloneCoroutine was cancelled
error: StandaloneCoroutine was cancelled
error: StandaloneCoroutine was cancelled
error: StandaloneCoroutine was cancelled
...

このとき何が起きているのかというと

  1. 1回目の delay が完了し、do something を print
  2. 2回目の delay で中断中に job.cancel() が呼ばれる
  3. delay は即座に CancellationExceptionthrow するが、catch が握りつぶす
  4. ループに戻る → 以降の delay も「キャンセル済み」を検知して即 throw → また握りつぶす
  5. 結果、delay が一切待たなくなり、catch を最速で回し続ける制御不能な無限ループに陥る。当然コルーチンも完了せず親もキャンセルされない。

という状況になっていて、シーケンス図にすると以下のような感じになります。

こうして見ると、見た目からは想像できないくらい怖い動きをしています。
ちなみにこのコードは job.cancel()job.cancelAndJoin() に変えると、完了を待ち続けて呼び出し側ごと永遠にハングします

応用編

前セクションの例を参考に、無限ループがないパターンのコードの動き方を追ってみます。
無限ループではない場合でも、致命的ではないですが一定悪い影響は出てきます。

以下の例を考えます。どんな出力になるでしょうか?

runBlocking {
    val job = launch {
        try {
            delay(1.seconds)
        } catch (e: Throwable) {
            println("error")
        }
        println("do something1")
        delay(1.seconds)
        println("do something2")
    }

    delay(0.5.seconds)
    job.cancel()
    job.join() // キャンセル完了を待つ
}

実行してみると以下の2行が表示されます。

error
do something1
  1. 最初の delay 中に job がキャンセル
  2. CancellationExceptionthrow
  3. catch されて error が print される
  4. println はsuspensionポイントではないのでそのまま実行
  5. delayCancellationExceptionthrow されコルーチンがキャンセル

という動きをしています。注目するべきは 4 で、本来正しくキャンセルされ実行されるべきではない println("do something1") が実行されてしまっています。

後続に suspension ポイントとなる処理ブロックがない限り、コルーチンの処理はキャンセルされず進み続けるので、こちらも注意が必要です。

対策

以上、簡単に CancellationExceptioncatch が問題になる例を 2つ 見てきました。
この問題の対策として取りうる策をいくつか紹介すると:

  1. CancellationException を再 throw する
  2. Throwablecatch した後に ensureActive() を呼び出し、キャンセル済みなら CancellationException を再 throw させる
  3. while ループであれば)while(true) の代わりに while(isActive) を使ってループさせる
// 1-1
try {
    doSomething()
} catch (e: CancellationException) {
   throw e
} catch (e: Throwable) {
   // ...
}

// 1-2
try {
    doSomething()
} catch (e: Throwable) {
   if (e is CancellationException) throw e
   // ...
}

// 2
try {
    doSomething()
} catch (e: Throwable) {
   // ensureActive() はキャンセル済みかをチェックし、該当すれば CancellationException を再 throw する
   coroutineContext.ensureActive()
   // ...
}

// 3
while(isActive) {
    try {
        doSomething()
    } catch (e: Throwable) {
        // エラーハンドリング
    }
}

1 が一番素直な気はしますが、ensureActive() も結構見る印象です。

なお、runCatching { } も内部は catch (Throwable) 相当なので、try-catch を書いた自覚がないまま同じ問題を踏みます。suspend 関数の中で使うときは注意してください。

まとめ

CancellationExceptioncatch は、suspend のスコープ内における try ~ catch のブロックを大きくし、ボイラープレートを増やしてしまうので、進んで書きたくはないかもしれません。

とはいえ、Kotlin Coroutines が特別な Exceptionthrow にキャンセルを頼っている都合上、避けて通ることはできませんし、非同期のタスクがリークする原因にもなるので、AIのコードレビューには素直に従うのが良さそうです。

ループ処理の例でわかるように、まあまあ危ないミスだとは思うので、事故らない工夫をしてみてもいいかもしれません。手軽なところでは detekt のカスタムルールで警告を出す、もう少し踏み込むなら compiler plugin の FIR チェッカーを作ってそもそもコンパイルを通らなくしてみるとか、(これは過激かもしれませんが)IRで勝手にケアしてあげるとか、いろいろ考えられそうです。

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?