Kotlin
coroutine

Kotlin coroutinesでの例外ハンドリングの罠

More than 1 year has passed since last update.

はじめに

みなさん、coroutines使ってますか?
Kotlinのcoroutinesを使えば、非同期処理や並行処理を簡単に書くことが出来ます。
一方で、例外のハンドリング方法に一定の癖があることも事実です。
この記事ではcoroutine内で例外が発生したときの挙動を紹介しつつ、どのように扱えばいいのかについて述べたいと思います。

注: 本稿はkotlinx.coroutines v0.16時点に書かれています。

前提

以下のコードにおいて、work()の中身を変えていったときにどのような挙動になるかを見ていくことにします。

fun start(): Unit = runBlocking {
    try {
        work()
        println("完了だよ")
    } catch (t: Throwable) {
        println("エラーだよ")
    }
}

suspend fun work() {
    // ここ
}

launch coroutine builder

launch - join

まずはlaunch coroutine builderで作成したcoroutineをjoinで完了まで中断する書き方です。

suspend fun work() {
    launch(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }.join()
}

このケースの場合、アプリケーションがクラッシュします。
スレッドが異なるため、場合によっては完了だよと表示されます。

launch - joinせず

joinで中断しない場合はどうなるでしょうか。

suspend fun work() {
    launch(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }
    delay(1000)
}

こちらもjoinした場合と同じようにアプリケーションがクラッシュします。
すなわち、launchで起動したコルーチンは、原則としてその中で例外処理を行ってあげる必要があります。

suspend fun work() {
    launch(CommonPool) {
        try {
            delay(500)
            throw RuntimeException("エラー")
        } catch (t: Throwable) {
            println("launch内エラーだよ")
        }
    }.join()
}

async coroutine builder

async - await

次はasync coroutine builderで作成したcoroutineをawaitで中断する場合です。

suspend fun work() {
    async<Unit>(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }.await()
}

この場合、start()のcatch節に流れるため、エラーだよが表示されます。

async - awaitせず

awaitで中断しない場合はどうでしょうか。

suspend fun work() {
    async<Unit>(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }
    delay(1000)
}

この場合、例外はスルーされて完了だよが表示されます。
async coroutine builderで作成したcoroutine内で例外が発生すると、coroutineはfailed状態で終了します。
failed状態のcoroutineから値を取り出そうとした(つまりawaitした)時点でfailedとなった原因の例外を送出するため、awaitしない場合は例外がスルーされてしまうわけです。

async - join

asyncDeferred<T>を返しますが、これはJobインターフェースを実現しています。つまりjoinすることもできます。

suspend fun work() {
    async<Unit>(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }.join()
}

この場合も例外はスルーされて完了だよが表示されます。
例外を送出するのはあくまでawaitである点は注意が必要です。

以上を踏まえてどうすればいいか

async<Unit>を使う

例外はなるべく上流でキャッチするのが原則ですので、基本的には伝搬してほしいものです。
その場合、もっとも簡単な方法はkotlinx.coroutinesのissueにもあるようにlaunch-joinのかわりにasync<Unit>-awaitを使うことです。
下記のようなutilityを生やしておくと便利に使えると思います。

fun asyncUnit(context: CoroutineContext, block: suspend CoroutineScope.() -> Unit): Deferred<Unit> =
    async(context, block = block)

suspend fun work() {
    asyncUnit(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }.await()
}

可能ならrunを使おう

単に実行スレッドを切り替えたい場合はcoroutine builderではなくrunを使うことができます。

suspend fun work() {
    run(CommonPool) {
        delay(500)
        throw RuntimeException("エラー")
    }
}

この場合、例外は伝搬されます。
runはsuspending functionで、中のコードブロックが完了するまで呼び出し元のcoroutineを中断します。
非同期処理を別スレッドで行いたい場合など、awaitをセットで呼ぶような状況(並列実行しない状況)ではrunを使う方が便利です。

それでも伝えたい例外がある

launch coroutine builderの場合はasyncという代替がありますが、actor coroutine builderの場合はありません。

suspend fun work() {
    val actor = actor<Int>(CommonPool) {
        for (message in channel) {
            println("receive: $message")
            if (message == 3) {
                throw RuntimeException("エラー")
            }   
        }
    }
    repeat(10) { actor.send(it) }
}

上記のコードの場合、4を送ろうとするactor.sendworkは中断しつづけるため、完了だよエラーだよも出力されません。
こういった場合はChannel<Throwable>を使って直接的に例外をやりとりすることになります。

suspend fun work() {
    val error = Channel<Throwable>(capacity = Channel.CONFLATED)
    val actor = actor<Int>(CommonPool) {
        try {
            for (message in channel) {
                println("receive: $message")
                if (message == 3) {
                    throw RuntimeException("エラー")
                }   
            }
        } catch (t: Throwable) {
            error.offer(t)
        }
    }
    repeat(10) {
        select<Unit> {
            error.onReceive { throw it }
            actor.onSend(it) {}
        }
    }
}

上記のコードの場合、actor内で発生した例外をselect内で再throwしているため、エラーだよが表示されます。

まとめ

  • 新しくcoroutineを作った場合、例外は基本的にその中で処理する必要がある
  • asyncで作ったcoroutineの場合はawaitすると例外を伝搬するような挙動になるので便利
  • 困ったらChannel

それでは楽しいcoroutinesライフを!