LoginSignup
1
1

More than 1 year has passed since last update.

【Coroutinesガイド】例外ハンドリング

Posted at

※ソース記事はこちら
このセクションでは、例外ハンドリングと例外時のキャンセルについて取り扱う。キャンセルされたコルーチンは、suspendの時点で、CancellationExceptionをスローし、それはコルーチンの仕組みにより無視されていることをすでに知っている。ここでは、例外がキャンセルの間にスローされたり、同じコルーチンの複数の子が例外をスローする場合、何が起こるかを見ていく。

例外の伝播

コルーチンビルダーは二つの特徴に分けられる。例外を自動で伝播する(launchactor)か、ユーザーに晒す(asyncproduce)かである。これらのビルダーがルートコルーチンを作るために使われると、それは他のコルーチンの子ではないので、前者のビルダーは、JavaのThread.uncaughtExceptionHandlerに似たuncaught例外として扱い、一方後者は、最終的な例外を、例えばawaitreceive(producereceiveチャネルセクションで扱う)経由で、consumeするかどうかはユーザー次第である。
GlobalScopeを使ってルートコルーチンを作る簡単な例により、デモンストレーションすることができる。

GlobalScopeは、手に負えない方向に裏目に出ることがある繊細なAPIである。アプリケーション全体のためにルートコルーチンを作ることは、GlobalScopeの滅多にない合理的な使用の一つであるため、GlobalScopeの使用を@OptIn(DelicateCoroutinesApi::class)で明示的にオプトインしなければならない。

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val job = GlobalScope.launch { // launchを使うルートコルーチン
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Thread.defaultUncaughtExceptionHandlerによってコンソール出力される
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // asyncを使うルートコルーチン
        println("Throwing exception from async")
        throw ArithmeticException() // 何も出力されない。awaitを呼ぶことはユーザー次第である
   }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

このコードの出力は次のようになる。(debugモードで)

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException

CoroutineExceptionHandler

コンソールにuncaught例外を出力するデフォルトのふるまいをカスタマイズすることは可能である。ルートコルーチンのCoroutineExceptionHandlerコンテキスト要素を、カスタム例外ハンドリングが起こるかもしれないルートコルーチンとそのすべての子のための、一般的なcatchブロックとして使うことができる。それはThread.uncaughtExceptionHandlerに似ている。CoroutineExceptionHandler内の例外からは回復することはできない。ハンドラーが呼び出されるとき、コルーチンはすでに対応する例外で完了している。通常、ハンドラーは例外をログ出力したり、いくつかの種類のエラーメッセージを表示したり、終了したり、アプリケーションを再起動したりするために使われる。
CoroutineExceptionHandleruncaught例外、つまり他の方法ではハンドルされなかった例外、においてのみ呼び出される。特に、すべての子のコルーチン(別のJobのコンテキストで作られたコルーチン)は、その例外ハンドリングを親のコルーチンに移譲し、それも親に移譲し、ルートまでそれが続くため、それらのコンテキストに設置されたCoroutineExceptionHandlerは決して使われない。さらに、asyncビルダーは、常にすべての例外をキャッチし、結果として生じるDeferredオブジェクト内でそれらを表すため、そのCoroutineExceptionHandlerも効果が無い。

監督スコープで動作するコルーチンは、親へ例外を伝播せず、このルールから除外されている。このドキュメントのもっと先にある監視セクションで、より詳細が提供されている。

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) { // GlobalScopeで動作するルートコルーチン
        throw AssertionError()
    }
    val deferred = GlobalScope.async(handler) { // これもルートだが、launchでなくasync
        throw ArithmeticException() // 何も出力されない。deferred.await()を呼ぶことはユーザー次第である
    }
    joinAll(job, deferred)    
}

このコードの出力は以下の通り。

CoroutineExceptionHandler got java.lang.AssertionError

キャンセルと例外

キャンセルは例外と密接に関連している。コルーチンはキャンセルのために、内部でCancellationExceptionを使用しており、これらの例外はすべてのハンドラで無視されるため、catchブロックで得られる追加のデバッグ情報の元としてのみ、使うべきである。コルーチンが、Job.cancelを使ってキャンセルされるとき、それは終了するが、その親はキャンセルされない。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        val child = launch {
            try {
                delay(Long.MAX_VALUE)
            } finally {
                println("Child is cancelled")
            }
        }
        yield()
        println("Cancelling child")
        child.cancel()
        child.join()
        yield()
        println("Parent is not cancelled")
    }
    job.join()    
}

このコードの出力は以下のとおり。

Cancelling child
Child is cancelled
Parent is not cancelled

コルーチンがCancellationException以外の例外に出くわした場合、親をその例外でキャンセルする。このふるまいは、上書きすることはできず、構造化並列性のために安定したコルーチンの階層を提供するために使われている。CoroutineExceptionHandlerの実装は、子のコルーチンのためには使われていない。

これらの例では、CoroutineExceptionHandlerは常にGlobalScopeで作られたコルーチンに設置されている。メインのrunBlockingスコープで起動したコルーチンに対して例外ハンドラを設置するのは意味がない。なぜなら、メインコルーチンは、設置された例外ハンドラに関わらず、子が例外で完了するときは、常にキャンセルしようとするからである。

もともとの例外は、すべての子が終了したときに親によってのみハンドルされ、それは、次の例によって実証される。

import kotlinx.coroutines.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // 最初の子
            try {
                delay(Long.MAX_VALUE)
            } finally {
                withContext(NonCancellable) {
                    println("Children are cancelled, but exception is not handled until all children terminate")
                    delay(100)
                    println("The first child finished its non cancellable block")
                }
            }
        }
        launch { // 二番目の子
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    job.join() 
}

このコードは次のように出力する。

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

例外の集約

コルーチンの複数の子が例外で失敗するとき、一般的なルールは、"最初の例外が勝つ"であるため、最初の例外はハンドリングされる。最初の例外の後で発生したすべての追加の例外は、抑制された例外として、最初の例外に付属される。

import kotlinx.coroutines.*
import java.io.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // これは他の兄弟がIOExceptionで失敗するとき、キャンセルされる
            } finally {
                throw ArithmeticException() // 二番目の例外
            }
        }
        launch {
            delay(100)
            throw IOException() // 最初の例外
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}

この上記のコードは、suppressed例外をサポートするJDK7以上においてのみ、適切に動作するだろう。

このコードの出力は次のとおり。

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

このメカニズムは現在Javaバージョン7以上でのみ、動作する。JSとNativeの制限は一時的であり、将来改善するだろう。

Cancellation例外は、透過的であり、デフォルトでラップされない。

import kotlinx.coroutines.*
import java.io.*

@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) {
        val inner = launch { // このすべてのコルーチンの山は、キャンセルされる            
            launch {
                launch {
                    throw IOException() // もともとの例外
                }
            }
        }
        try {
            inner.join()
        } catch (e: CancellationException) {
            println("Rethrowing CancellationException with original cause")
            throw e // cancellation例外がリスローされるが、もともとのIO例外がハンドラーに到達する  
        }
    }
    job.join()    
}

このコードの出力は次のとおり。

Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

監視

以前学んだように、キャンセルはコルーチン階層全体を通じて伝播する、双方向の関係である。単一方向のキャンセルが必要な場合についてみていこう。
そのような必要性の良い例は、スコープに定義されたjobを持つ、UIコンポーネントである。何らかのUIの子タスクが失敗したときは、必ずしも常にUIコンポーネント全体をキャンセル(効果的にkillする)必要はなく、UIコンポーネントが破棄される(そしてそのジョブがキャンセルされる)とき、結果は最早必要ないのため、すべての子ジョブはキャンセルする必要がある。
別の例は、複数の子ジョブを起動するサーバープロセスであり、それらの実行を監視する必要があり、それらの失敗を追跡し、失敗したもののみ再起動するだけ、というものがある。

監視ジョブ

SupervisorJobをこれらの目的に使うことができる。キャンセルが下流にのみ伝播する唯一の例外を持つ、通常のJobに似ている。これは次の例を使って、簡単に実証することができる。

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // 最初の子の起動 -- この例では例外は無視される。(実際にはしないこと!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("The first child is failing")
            throw AssertionError("The first child is cancelled")
        }
        // 二つ目の子の起動
        val secondChild = launch {
            firstChild.join()
            // 最初の子のキャンセルは二つ目の子には伝播しない。
            println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // しかしsupervisorのキャンセルは伝播する。
                println("The second child is cancelled because the supervisor was cancelled")
            }
        }
        // 最初の子が失敗し完了するまで待機する
        firstChild.join()
        println("Cancelling the supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}

このコードの出力は以下の通り。

The first child is failing
The first child is cancelled: true, but the second one is still active
Cancelling the supervisor
The second child is cancelled because the supervisor was cancelled

監視スコープ

coroutineScopeの代わりに、スコープ付き並列性のために、supervisorScopeを使うことができる。それは自分自身が失敗する場合、一方向にキャンセルを伝播し、自分のすべての子のみをキャンセルする。それはちょうどcoroutineScopeのように、すべての子の完了も待つ。

import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        supervisorScope {
            val child = launch {
                try {
                    println("The child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("The child is cancelled")
                }
            }
            // yieldを使って子に実行と出力の機会を与える
            yield()
            println("Throwing an exception from the scope")
            throw AssertionError()
        }
    } catch(e: AssertionError) {
        println("Caught an assertion error")
    }
}

このコードの出力は以下の通り。

The child is sleeping
Throwing an exception from the scope
The child is cancelled
Caught an assertion error

監視スコープでの例外

通常と監視ジョブのもう一つの重要な違いは、例外ハンドリングである。それぞれの子は例外ハンドリングのメカニズム経由で、自分自身で例外をハンドリングすべきである。この違いは、子の失敗が親に伝播しないという事実から来る。それは、supervisorScopeの内側で直接起動されたコルーチンは、ルートコルーチンがするのと同じ方法で、自分のスコープに設置されたCoroutineExceptionHandlerを使うことを意味する。(詳細はCoroutineExceptionHandlerのセクションを参照のこと)

import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    supervisorScope {
        val child = launch(handler) {
            println("The child throws an exception")
            throw AssertionError()
        }
        println("The scope is completing")
    }
    println("The scope is completed")
}

このコードの出力は以下のとおり。

The scope is completing
The child throws an exception
CoroutineExceptionHandler got java.lang.AssertionError
The scope is completed
1
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
1
1