14

掌握Kotlin Coroutine之 Exception

 3 years ago
source link: http://blog.chengyunfeng.com/?p=1092
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在计算机世界中异常处理(Exception handling)是一个非常重要的概念。程序中存在各种异常问题需要处理,程序需要能从异常中恢复。而 Coroutine 主要用于异步编程的地方,所以异常处理会更加复杂。本节来介绍在 Coroutine 中异常处理需要注意的地方。

异常的传递

不同的Coroutine builder函数有不同的异常传递策略,在 Coroutine 中异常传递分为两种类型,一种是自动向上传递(launchactor);另外一种是把错误信息暴露给调用者(asyncproduce)。前者并没有主动处理异常,而后者依赖调用者来最终处理异常。

比如下面的示例代码在 launch 中抛出IndexOutOfBoundsException异常会导致应用 Crash:

val job = GlobalScope.launch {
    Log.d(TAG, "Throwing exception from launch")
    //这个异常将会导致应用 Crash
    throw IndexOutOfBoundsException()

Crash log:

E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-1
    Process: org.goodev.coroutinedemo, PID: 14615
    java.lang.IndexOutOfBoundsException
        at org.goodev.coroutinedemo.MainActivity$onCreate$1$1.invokeSuspend(MainActivity.kt:41)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)
        at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:233)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)

CoroutineExceptionHandler

对于通过launchactor创建的Coroutine,如果里面抛出了异常需要通过CoroutineExceptionHandler来捕获异常。例如:

val handler = CoroutineExceptionHandler { context, exception ->
    Log.e(TAG, "Caught $exception")
launch {
    val job = GlobalScope.launch(handler) {
        Log.d(TAG, "Throwing exception from launch")
        throw IndexOutOfBoundsException()
    job.join()

CoroutineExceptionHandler 继承自 CoroutineContext 接口。

上面创建了一个CoroutineExceptionHandler实例,然后使用其作为Context参数调用launch函数。

async函数的异常处理则不能使用上面的方式,需要在调用suspending point的地方用try-catch来捕获这种异常,比如:

    val deferred = GlobalScope.async {
        println("Throwing exception from async")
        throw ArithmeticException() // 这个异常需要在下面处理
        deferred.await()
        Log.d(TAG, "Unreached")
    } catch (e: ArithmeticException) {
        Log.d(TAG, "Caught ArithmeticException")

CancellationException

在 Coroutine 中CancellationException是一个特殊的异常,当调用一个suspend函数并等待该函数执行完毕的时候被取消了,则会抛出CancellationException表示这个Coroutine 被取消了。所以这个异常是不需要我们处理的,由Coroutine实现内部处理。

当使用Job.cancel来取消一个任务,同时不指定取消的原因则这个任务终止执行,但是并不会取消父任务。这个机制可以在父任务里面通过cancel()函数来取消不需要的子任务,这样取消该子任务并不影响父任务和其他子任务的执行。

如果Coroutine遇到了不是CancellationException的异常,则会使用这个异常来取消父Coroutine。为了保证稳定的Coroutine层级关系(用来实现structured concurrency),这种行为不能被修改。 然后当所有的子Coroutine都终止后,父Coroutine会收到原来抛出的异常信息。

也就是说,如果在父Coroutine F 里面启动了两个子Coroutine AB,如果在执行A的时候抛出了异常E,而这个时候B还没有执行完,F遇到异常后会先去取消B的执行,等B被完全终止了则F继续执行并抛出异常E

下面是一个在Java环境的示例演示:

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
            println("Caught $exception")
    val job = GlobalScope.launch(handler) {
        launch { // the first child
                delay(Long.MAX_VALUE)
            } finally {
            // 注意这个 NonCancellable 任务
                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 { // the second child
            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
Caught java.lang.ArithmeticException

如果多个子Coroutine都抛出了异常会出现啥情况呢?通用规则是最先抛出的异常被暴露出来。但是呢,这样会导致异常信息丢失,比如在子任务A中抛出异常,然后取消子任务B,但是在Bfinally块中又出现了异常BE,则这个后出现的异常BE就丢失了。

目前 Coroutine 的实现,在 JDK7+ 版本上可以支持同时显示后面的异常信息,比如下面的例子:

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught $exception with suppressed ${exception.suppressed.contentToString()}")
    val job = GlobalScope.launch(handler) {
        launch {
                delay(Long.MAX_VALUE)
            } finally {
                throw ArithmeticException()
        launch {
            delay(100)
            throw IOException()
        delay(Long.MAX_VALUE)
    job.join()  

在JDK7+ 的版本上结果如下:

Caught java.io.IOException with suppressed [java.lang.ArithmeticException]

但是在安卓上运行则ArithmeticException异常就丢失了。

Supervision(监管)

在前面我们了解到,在Coroutine的层级结构中,取消行为是双向的 —- 取消父任务会同时取消所有子任务,而某一个子任务出现的异常被取消,则会导致父任务和所有其他子任务也被同时取消。如果我们需要单向取消行为应该肿么办呢?

比如在安卓应用中,打开一个界面,在这个Activity 的 scope 中启动了多个任务加载不同的数据,而这些加载不同数据的子任务是相互独立的,某一个失败了不应该影响其他子任务的执行;而如果这个 Activity 退出被销毁了,则所有请求数据的子任务就没有必要了,需要被取消。 这种行为可以通过SupervisorJob来实现。

还记得 MainScope() 函数的实现吗? 就是使用了 SupervisorJob。再来看看其代码:

public fun MainScope(): CoroutineScope
  = ContextScope(SupervisorJob() + Dispatchers.Main)

SupervisorJob 的取消行为是单向的,取消父任务可以同时取消所有子任务,而子任务的取消,不会导致父任务和其他子任务取消。

另外 coroutineScope 函数和supervisorScope 函数的区别也是这样的。

由于 supervisor 任务是单向取消的,所以子任务的异常需要子任务自己单独处理,异常无法向上传递。

    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // launch the first child -- its exception is ignored for this example (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("First child is failing")
            throw AssertionError("First child is cancelled")
        // launch the second child
        val secondChild = launch {
            firstChild.join()
            // Cancellation of the first child is not propagated to the second child
            println("First child is cancelled: ${firstChild.isCancelled}, but second one is still active")
                delay(Long.MAX_VALUE)
            } finally {
                // But cancellation of the supervisor is propagated
                println("Second child is cancelled because supervisor is cancelled")
        // wait until the first child fails & completes
        firstChild.join()
        println("Cancelling supervisor")
        supervisor.cancel()
        secondChild.join()

上面代码的log如下:

First child is failing
First child is cancelled: true, but second one is still active
Cancelling supervisor
Second child is cancelled because supervisor is cancelled

Supervision scope

对应于 coroutineScope 同样还有一个supervisorScope,里面的子 Coroutine 的取消策略和 SupervisorJob 一样。

        supervisorScope {
            val child = launch {
                    println("Child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("Child is cancelled")
            // 使用 yield 函数让子Coroutine有机会执行
            yield()
            println("Throwing exception from scope")
            throw AssertionError()
    } catch(e: AssertionError) {
        println("Caught assertion error")
Child is sleeping
Throwing exception from scope
Child is cancelled
Caught assertion error

Supervised coroutine 的异常处理

普通任务和 supervisor 任务的另外一个区别就在于异常处理的不同。每个子Coroutine 都应该通过异常处理器来处理自己的异常。之所以这样是因为 Supervised Coroutine 中子Job的异常不会传递给父Job,所以需要自己处理。

  val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught $exception")
    supervisorScope {
        val child = launch(handler) {
            println("Child throws an exception")
            throw AssertionError()
        println("Scope is completing")
    println("Scope is completed")
Scope is completing
Child throws an exception
Caught java.lang.AssertionError
Scope is completed

Coroutine 中异常处理和普通代码的异常处理还是有很大区别的。所以在使用 Coroutine 的时候需要好好设计下异常应该在何处处理并恰当的处理好相关的异常。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK