こんにちは、こんばんは、kitakkunです。
AIによるKotlinのコードレビューで、「CancellationException を catch するな」と言われたことがある方は多いのではないでしょうか。
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 がキャンセルされた時、内部的には以下の処理が動きます。
- 親が子の状態を「キャンセル中」に変更
- 子は、処理が一時停止するsuspensionポイントで
CancellationExceptionをthrowする - 子は、コルーチンの完了を親に通知する
- 全ての子コルーチン完了後、親のキャンセルが完了する
シーケンス図にするとこんな感じになります。
このフローの一部となる CancellationException を catch してしまうと不整合が起こる、というのはなんとなく想像できると思います。
では、具体的にどんな問題が起こるのか、良くありそうな Coroutine のユースケースを例に考えてみます。
実害が及ぶパターン
CoroutineScope 内で while ループを作って定期実行するのは、ごく一般的なユースケースかと思います。
その中で CancellationException を catch してしまうと何が起こるのか・・?
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回目の
delayが完了し、do somethingを print - 2回目の
delayで中断中にjob.cancel()が呼ばれる -
delayは即座にCancellationExceptionをthrowするが、catchが握りつぶす - ループに戻る → 以降の
delayも「キャンセル済み」を検知して即throw→ また握りつぶす - 結果、
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
- 最初の
delay中にjobがキャンセル -
CancellationExceptionがthrow -
catchされてerrorが print される -
printlnはsuspensionポイントではないのでそのまま実行 -
delayでCancellationExceptionがthrowされコルーチンがキャンセル
という動きをしています。注目するべきは 4 で、本来正しくキャンセルされ実行されるべきではない println("do something1") が実行されてしまっています。
後続に suspension ポイントとなる処理ブロックがない限り、コルーチンの処理はキャンセルされず進み続けるので、こちらも注意が必要です。
対策
以上、簡単に CancellationException の catch が問題になる例を 2つ 見てきました。
この問題の対策として取りうる策をいくつか紹介すると:
-
CancellationExceptionを再throwする -
Throwableをcatchした後にensureActive()を呼び出し、キャンセル済みならCancellationExceptionを再throwさせる - (
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 関数の中で使うときは注意してください。
まとめ
CancellationException の catch は、suspend のスコープ内における try ~ catch のブロックを大きくし、ボイラープレートを増やしてしまうので、進んで書きたくはないかもしれません。
とはいえ、Kotlin Coroutines が特別な Exception の throw にキャンセルを頼っている都合上、避けて通ることはできませんし、非同期のタスクがリークする原因にもなるので、AIのコードレビューには素直に従うのが良さそうです。
ループ処理の例でわかるように、まあまあ危ないミスだとは思うので、事故らない工夫をしてみてもいいかもしれません。手軽なところでは detekt のカスタムルールで警告を出す、もう少し踏み込むなら compiler plugin の FIR チェッカーを作ってそもそもコンパイルを通らなくしてみるとか、(これは過激かもしれませんが)IRで勝手にケアしてあげるとか、いろいろ考えられそうです。