- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- Other occupations (19)
- Development
- Business
Kotlin CoroutinesのCancellationの罠
Photo by Rostyslav Savchyn on Unsplash
はじめまして、Wantedlyのモバイルエンジニアの久保出といいます。
今回はKotlin CoroutinesでのCancellationの罠について書かせていただきます。
罠とか書いてますが、割と初歩的な内容です。
なおこの内容はpotatotips 72で話した内容を記事にしたものです。
TL;DR
コルーチンの中では、キャンセルのハンドリング以外でCancellationException
はキャッチすべきではない。
派生して、catch (e: Exception)
のようにジェネリックな例外もキャッチすべきではない。
気づき
ある日、Androidアプリで次のようなクラッシュレポートが出てきました。
該当するコードは次のようになっていました。
interface FetchDiscoverPostsUseCase {
suspend operator fun invoke(sectionId: DiscoverSectionId)
class Error(message: String, cause: Throwable) : Throwable(message, cause)
}
internal class FetchDiscoverPostsUseCaseImpl(
private val discoverRepository: DiscoverRepository,
) : FetchDiscoverPostsUseCase {
override suspend operator fun invoke(sectionId: DiscoverSectionId) {
try {
return discoverRepository.fetchDiscoverPosts(sectionId)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
throw FetchDiscoverPostsUseCase.Error("Failed to fetch discover posts for section: $sectionId", e)
}
}
}
※例外の名前が画像と違うけど本題と違うバグなので気にしないでください。
リポジトリの例外をラップしているだけですが、なぜかここがクラッシュしています。
クラッシュの原因を探る
発生箇所はわかったので原因を探っていきます。
CancellationException
クラッシュレポートを見直すと、cause
がJobCancellationException
になっています。
JobCancellationExceptionのコードを見るとinternalでドキュメントもないですが、スーパークラスのCancellationExceptionのドキュメントではこう書かれています。
Thrown by cancellable suspending functions if the Job of the coroutine is cancelled while it is suspending. It indicates normal cancellation of a coroutine. It is not printed to console/log by default uncaught exception handler. (see CoroutineExceptionHandler).
つまり、Job.cancel()
された場合に、コルーチンの仕組みとしてスローされる例外です。
コルーチンの中でこれをキャッチすることで、キャンセル時のハンドリングをすることができます。
We already know that a cancelled coroutine throws CancellationException in suspension points and that it is ignored by the coroutines' machinery.
Coroutine exceptions handling では、CancellationException
はコルーチンの仕組みとして無視されるとも書かれています。
CancellationExceptionのクラス階層
CancellationException
について、より詳細に見ていきます。
CancellationException
のKotlin/JVMでの実装は、java.util.concurrent.CancellationException
のtypealias
になっています。
java.util.concurrent.CancellationException
のクラス階層は次のようになっています。
java.lang.Object
└ java.lang.Throwable
└ java.lang.Exception
└ java.lang.RuntimeException
└ java.lang.IllegalStateException
└ java.util.concurrent.CancellationException
つまり、catch (e: Exception)
のようなジェネリックな例外キャッチをコルーチンの中でしてしまうと、CancellationException
もキャッチしてしまいます。
クラッシュの原因
本来コルーチンの仕組みとしてキャンセル時に投げられるCancellationException
をキャッチし、別の例外でラップして再スローしたことにより、キャンセル中に例外が発生したとみなされて、かつ適切な上位のコルーチンでのエラーハンドリングがなかったことが原因だとわかりました。
対処法
CancellationException
の親であるジェネリックな例外のキャッチをせず、tryで投げられる可能性のある例外だけをキャッチすることで、この問題は回避できます。
launch {
try {
throw SpecificException()
} catch (e: SpecificException) {
throw MyException(e)
}
}
IllegalStateException
を明示的にキャッチしたい場合は、次のようにCancellationException
の型チェックをして再スローするとよいでしょう。
launch {
try {
throw IllegalStateException()
} catch (e: IllegalStateException) {
throw e as? CancellationException ?: MyException(e)
}
}
CancellationException
を握りつぶすことでもクラッシュは防げますが、やってはいけません。CancellationException
は子から親へ伝搬させるべきものなので、子のコルーチンで握りつぶしてしまうと、親のコルーチンでキャンセルのハンドリングが不可能になってしまいます。
Lintの重要性
Lintを無視していることも今回の要因になっています。
問題のあるコードを次に抜き出します。
try {
return discoverRepository.fetchDiscoverPosts(sectionId)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
throw FetchDiscoverPostsUseCase.Error("Failed to fetch discover posts for section: $sectionId", e)
}
@Suppress("TooGenericExceptionCaught")
とジェネリックな例外キャッチのLintを無視する記述をしてしまっています。
これを無視せず、適切な例外をキャッチしていれば、今回の問題は起こらなかったことでしょう。
まとめ
Kotlinには検査例外がないため、例外のハンドリングは雑にcatch (e: Exception)
のようにしがちです。まれにAndroid公式のドキュメントでもcatch (e: Exception)している記述があるので気をつけましょう。
しかし、コルーチンの中ではコルーチンの外と比べると今回のようにプログラマが気をつけることが多いです。
Lintを無視したことも今回の要因なので、安直な@Suppress
は危険だということも学びました。
間違いなど指摘があればぜひ@swiz_ardまでお願いいたします。