Kotlin 协程异常处理机制探寻

1,368 阅读7分钟

异常传播

协程构建器有两种形式:自动传播异常(launchactor)或向用户暴露异常(asyncproduce)。 当这些构建器用于创建一个协程时,即该协程不是另一个协程的协程, 前者这类构建器将异常视为未捕获异常,类似 Java 的 Thread.uncaughtExceptionHandler, 而后者则依赖用户来最终消费异常,例如通过 awaitreceive

使用launch启动协程

Try catch机制

一般为了保证代码稳定性,我们会添加try catch去执行一些兜底措施,但是try catch真的是万能的吗?在kotlin的协程中使用会怎么样呢?我们尝试使用一下

try {

    CoroutineScope(Dispatchers.Main).launch {

    launch {

            Log.e("Bril", "throw a exception")

            throw IndexOutOfBoundsException()

        }

 }

} catch (e: Exception) {

    Log.e("Bril", "catch a exception$e")

}

11.png

竟然失败了!看来不能在协程的外部使用try catch,那么里面使用呢?

CoroutineScope(Dispatchers.Main).launch {

  launch {

  Log.e("Bril", "throw a exception")

        try {

            throw IndexOutOfBoundsException()

        } catch (e: Exception) {

            Log.e("Bril", "catch a exception$e")

        }

    }

 }

22.png

跟普通try catch的效果一样,try catch它又可以了!为什么会这样呢?其实这这跟协程的结构化并发机制有关。

结构化并发

在kotlin的协程中,全局的GlobalScope是一个作用域,每个协程自身也是一个作用域,新建的协程与它的父作用域存在一个级联的关系,也就是一个父子关系层次结构。而这级联关系主要在于:

  1. 父作用域的生命周期持续到所有子作用域执行完;
  2. 当结束父作用域结束时,同时结束它的各个子作用域;
  3. 子作用域未捕获到的异常将不会被重新抛出,而是一级一级向父作用域传递,这种异常传播将导致父父作用域失败,进而导致其子作用域的所有请求被取消。

上面的三点也就是协程结构化并发的特性。所以上述try catch失效的例子中,子协程未捕获的异常,会一级一级向上抛出,直到root协程,但是root协程没有办法处理,就会导致协程失败。

CoroutineExceptionHandler

介绍

除了内部使用try catch,我们可以自定义一个coroutineExceptionHandler处理异常。首先我们了解一下coroutineExceptionHandler

未捕获异常打印到控制台的默认行为是可自定义的。 协程中的 CoroutineExceptionHandler 上下文元素可以被用于这个根协程通用的 catch 块,及其所有可能自定义了异常处理的子协程。 它类似于 Thread.uncaughtExceptionHandler) 。 你无法从 CoroutineExceptionHandler 的异常中恢复。当调用处理者的时候,协程已经完成并带有相应的异常。通常,该处理者用于记录异常,显示某种错误消息,终止和(或)重新启动应用程序。

这是kotlin 官方对它的定义,大体意思就是CoroutineExceptionHandler相当于全局的catach操作,可以在这里对发生异常后进行一些操作。我们实践一下。

实践

首先,自定义一个CoroutineExceptionHandler。

val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->

         Log.e("Bril", "catch by coroutineExceptionHandler")

 }

然后在协程中使用

CoroutineScope(coroutineExceptionHandler).launch {
    launch {
        Log.e("Bril", "第一个子协程")
        throw IndexOutOfBoundsException()
    }

    launch {
        Log.e("Bril", "第二个子协程")
    }
}

image.png

不足之处

可见CoroutineExceptionHandler处理了我们的异常,但是它也有一些不足之处

  1. 首先根据协程的结构化并发特性,子协程未捕获的异常会向上传递,父协程会失败,并且取消所有的子协程。
  2. 根据它的定义,他是一种全局的处理机制,不能处理局部异常。
  3. 它只能在顶级协程中生效。

所以如果你想并行执行多个操作,并且之间互相不影响,这时CoroutineExceptionHandler就显得不合适了,使用SupervisorJob,supervisorScope就可以解决上面的问题。

SupervisorJob&SupervisorScope

介绍

作用域

顶级作用域:没有父协程的作用域就是顶级作用域。

协同作用域:在协程中启动一个协程,新协程为所在协程的子协程。子协程所在的作用域默认为协同作用域。此时子协程抛出未捕获的异常时,会将异常传递给父协程处理,如果父协程被取消,则所有子协程同时也会被取消,即取消是双向传递的

主从作用域:也叫主从监督域,该作用域下的协程取消操作是单向传播的,也就是说子协程的异常,不会导致其他子协程被取消。但是如果父协程取消了,所有的子协程都会被取消,即取消是向下单向传递的, 这是跟协同作用域最大的区别。

使用了SupervisorJob或者SupervisorScope就是主从作用域。

实践

协同作用域中,当一个协程出现未捕获的异常时

CoroutineScope(Dispatchers.Main + coroutineExceptionHandler).launch  { 

  Log.e("Bril", "开启协程")

  launch  { 

      Log.e("Bril", "第一个子协程")

      val result = 1 / 0

   } 



  launch  { 

     delay(100)

     Log.e("Bril", "第二个子协程")

   } 



  launch  { 

     delay(100)

     Log.e("Bril", "第三个子协程")

   } 

  } 

44.png

可以看到,第一个协程发生异常,剩余的其他子协程都失败了。接着我们使用主从作用域尝试一下

CoroutineScope(Dispatchers.Main + coroutineExceptionHandler).launch {

 Log.e("Bril", "开启协程")

    supervisorScope {

    launch {

            Log.e("Bril", "第一个子协程")

            val result = 1 / 0

        }

 }



  launch {

        delay(100)

        Log.e("Bril", "第二个子协程")

    }



  launch {

        delay(100)

        Log.e("Bril", "第三个子协程")

    }

 }

55.png

可以看到,虽然第一个子协程发生了异常,但不会影响到其他的子协程,使用SupervisorJob同理

CoroutineScope(Dispatchers.Main + coroutineExceptionHandler).launch {

 Log.e("Bril", "开启协程")

    launch(SupervisorJob()) {

        Log.e("Bril", "第一个子协程")

        val result = 1 / 0

    }



  launch {

        delay(100)

        Log.e("Bril", "第二个子协程")

    }



  launch {

        delay(100)

        Log.e("Bril", "第三个子协程")

    }

 }

66.png

为什么这两个等价呢,查看SupervisorScope的源码可以看到

 /**

 * Creates a [CoroutineScope] with [SupervisorJob] and calls the specified suspend block with this scope.

 * The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides

 * context's [Job] with [SupervisorJob].

 * ...

 */

public suspend fun <R> supervisorScope(block: suspend CoroutineScope.() -> R): R {

   ...

}

所以我们在使用SupervisorScope时的job就是SupervisorJob,所以两者的作用是等价的。但是当SupervisorJob或者SupersvisorScope失败时,它本身的子协程也都会被取消。

CoroutineScope(Dispatchers.Main + coroutineExceptionHandler).launch {

 Log.e("Bril", "开启协程")

    launch(SupervisorJob()) {

    launch {

            Log.e("Bril", "第一个子协程")

            val result = 1 / 0

        }



     launch {

         Log.e("Bril", "主从作用域的第二个子协程")

        }

 }



  launch {

        delay(100)

        Log.e("Bril", "第二个子协程")

    }



  launch {

        delay(100)

        Log.e("Bril", "第三个子协程")

    }

 }

77.png

可以看到,主从作用域内的第二个子协程因为第一个子协程的异常,导致失败了,对此我的理解是:从主从作用域内看,取消依旧遵循协程的双向传递机制,但是对外部而言,取消则是变成了单项传递,不影响外部的其他兄弟协程和父协程。

使用Async启动协程

上面我们将的都是launch启动的情况,那使用async启动的协程会有不同吗?我们尝试一下

CoroutineScope(Dispatchers.Main).launch {

 val deferred = CoroutineScope(CoroutineName("xxx")).async {

 throw IndexOutOfBoundsException()

    }



 try {

        deferred.await()

    } catch (e: Exception) {

        Log.e("Bril", "catch a exception")

    }

 }

88.png

可以看到这时候外部的try catch生效了,这是因为前面提到的,async是向用户暴露异常的,所以我们能捕获到(具体原理请期待下一篇文章)其他同launch。

注意:只有当async是顶级协程的时候,才会将异常封装到Deferred中,其他情况下,还是会将异常向上抛出,不论是否调用await()方法。

总结

经过以上的尝试,我们大概对kotlin 协程的异常有了一个简单的认识,总结一下就是三点:

  • 想要处理特定部分的异常,可以使用try catch。
  • 想要全局处理异常,一个任务异常,其他任务不执行,可以使用CoroutineExceptionHandler,节省资源消耗。
  • 想要处理异常,但不影响其他同级任务,可以使用SupervisorJob或者SupervisorScope。

注释

未捕获的异常

协程调用cancel时,不会导致父协程取消,因为cancel是通过CancellationException来取消,这个异常会被所有的处理者忽略。但是如果协程遇到了CancellationException以外的异常,它会使用这个异常取消它的父协程,这个就是未捕获的异常。