1
/
5

Kotlin CoroutinesのCancellationの罠

はじめまして、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

クラッシュレポートを見直すと、causeJobCancellationExceptionになっています。

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について、より詳細に見ていきます。

CancellationExceptionKotlin/JVMでの実装は、java.util.concurrent.CancellationExceptiontypealiasになっています。

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までお願いいたします。

Wantedly, Inc.では一緒に働く仲間を募集しています
11 いいね!
11 いいね!
同じタグの記事
今週のランキング
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?