异常传播
协程构建器有两种形式:自动传播异常(launch 与 actor)或向用户暴露异常(async 与 produce)。 当这些构建器用于创建一个根协程时,即该协程不是另一个协程的子协程, 前者这类构建器将异常视为未捕获异常,类似 Java 的
Thread.uncaughtExceptionHandler
, 而后者则依赖用户来最终消费异常,例如通过 await 或 receive。
使用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")
}
竟然失败了!看来不能在协程的外部使用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")
}
}
}
跟普通try catch的效果一样,try catch它又可以了!为什么会这样呢?其实这这跟协程的结构化并发机制有关。
结构化并发
在kotlin的协程中,全局的GlobalScope是一个作用域,每个协程自身也是一个作用域,新建的协程与它的父作用域存在一个级联的关系,也就是一个父子关系层次结构。而这级联关系主要在于:
- 父作用域的生命周期持续到所有子作用域执行完;
- 当结束父作用域结束时,同时结束它的各个子作用域;
- 子作用域未捕获到的异常将不会被重新抛出,而是一级一级向父作用域传递,这种异常传播将导致父父作用域失败,进而导致其子作用域的所有请求被取消。
上面的三点也就是协程结构化并发的特性。所以上述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", "第二个子协程")
}
}
不足之处
可见CoroutineExceptionHandler处理了我们的异常,但是它也有一些不足之处
- 首先根据协程的结构化并发特性,子协程未捕获的异常会向上传递,父协程会失败,并且取消所有的子协程。
- 根据它的定义,他是一种全局的处理机制,不能处理局部异常。
- 它只能在顶级协程中生效。
所以如果你想并行执行多个操作,并且之间互相不影响,这时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", "第三个子协程")
}
}
可以看到,第一个协程发生异常,剩余的其他子协程都失败了。接着我们使用主从作用域尝试一下
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", "第三个子协程")
}
}
可以看到,虽然第一个子协程发生了异常,但不会影响到其他的子协程,使用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", "第三个子协程")
}
}
为什么这两个等价呢,查看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", "第三个子协程")
}
}
可以看到,主从作用域内的第二个子协程因为第一个子协程的异常,导致失败了,对此我的理解是:从主从作用域内看,取消依旧遵循协程的双向传递机制,但是对外部而言,取消则是变成了单项传递,不影响外部的其他兄弟协程和父协程。
使用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")
}
}
可以看到这时候外部的try catch生效了,这是因为前面提到的,async是向用户暴露异常的,所以我们能捕获到(具体原理请期待下一篇文章)其他同launch。
注意:只有当async是顶级协程的时候,才会将异常封装到Deferred中,其他情况下,还是会将异常向上抛出,不论是否调用await()方法。
总结
经过以上的尝试,我们大概对kotlin 协程的异常有了一个简单的认识,总结一下就是三点:
- 想要处理特定部分的异常,可以使用try catch。
- 想要全局处理异常,一个任务异常,其他任务不执行,可以使用CoroutineExceptionHandler,节省资源消耗。
- 想要处理异常,但不影响其他同级任务,可以使用SupervisorJob或者SupervisorScope。
注释
未捕获的异常
协程调用cancel时,不会导致父协程取消,因为cancel是通过CancellationException来取消,这个异常会被所有的处理者忽略。但是如果协程遇到了CancellationException以外的异常,它会使用这个异常取消它的父协程,这个就是未捕获的异常。