Kotlin系列文章- Kotlin专栏:
概述
上篇文章 深入Kotlin协程系列|图解上下文 中分析了协程上下文的数据结构:
这篇文章结合具体的代码示例看一下上下文中的 Job 元素以及与之相关的协程取消和异常流程表现。
在此之前,猜猜下面代码的输出是啥?
Kotlin
val scope = CoroutineScope(CoroutineExceptionHandler { _, _ -> println("handle top") })
scope.launch {
launch(Job() + CoroutineExceptionHandler { _, _ -> println("handle inner 1") }) {
delay(100)
throw Exception()
}
delay(200)
println("111")
launch(CoroutineExceptionHandler { _, _ -> println("handle inner 2") }) {
delay(100)
throw Exception()
}
delay(200)
println("222")
}
之前在 协程取消和异常处理 一文中就解析过协程异常和取消的源码流程,但都是从源码运行角度去看的,看的时候可能有点吃力(后面找个时间把异常流程原理这块重新做个整理)。这篇文章换个角度,从实际代码表现来再次理解协程的取消和异常流程。
异常处理的结论
首先贴一下 协程取消和异常处理 中的结论:
- 父协程需要等待所有子协程处于完成或者取消状态才能完成自身。
- 协程调用 cancel 时会取消它的所有子协程,默认不会取消它的父协程(被取消的协程会在挂起点抛出 CancellationException 异常且它会被协程的机制所忽略,当前协程不会再继续执行)。
- 协程的取消只是在第一层包装 AbstractCoroutine 中修改协程的状态,不会影响到第二层包装 BaseContinuationImpl 中的执行逻辑,即协程的取消只是修改状态,不会取消协程的实际执行逻辑,需要主动判断 isActive 或者遇到挂起点才会取消当前协程的执行。
- 当协程发生未捕获的异常时会取消它的所有子协程,且不会接着执行自身,可能会取消它的父协程(默认会取消),默认会一步步取消上层的协程,最后取消根协程,且根协程来处理异常。当异常属于 CancellationException 或者使用了 SupervisorJob 和 supervisorScope 时,不会影响父协程,原理是重写了 childCancelled 方法。
- async 协程本身不会处理异常,传入自定义 CoroutineExceptionHandler 无效,但是会在 await 时抛出异常。
协程异常能整体catch吗
下面代码表现是啥样的?
Kotlin
CoroutineScope(Dispatchers.Main).launch {
try {
launch {
println("111")
throw Exception("error")
}
} catch (e: Exception) {
println("222")
}
}
协程异常是从发生异常的子协程一层一层往上传递的,一路火花带闪电,默认全给你取消了,直到顶层协程,如果顶层协程上下文中没有添加 CoroutineExceptionHandler,且当前线程也没有设置 UncaughtExceptionHandler,则会崩溃。所以在上面代码中 catch 是没用的,子协程在发生异常后,会往上层抛,代码里的 catch 根本不会生效。
具体原因可以回忆一下我们协程运行时的源码:
通过之前解析协程原理系列的文章可以知道,协程体代码块是在 invokeSuspend 里跑的,在上面的 try-catch 中,先我们一步 catch 住了(最终会抛上去),所以我们手动添加的 catch 无效。
因此我们需要在顶层协程 添加 CoroutineExceptionHandler,或者给当前线程设置 UncaughtExceptionHandler 处理异常,注意,一定要在顶层协程添加 CoroutineExceptionHandler 才有效,如下代码也是会崩溃的:
Kotlin
CoroutineScope(Dispatchers.Main).launch {
launch(CoroutineExceptionHandler { coroutineContext, throwable -> }) {
throw Exception("error")
}
}
那稍微改一下,这段代码会崩溃吗:
Kotlin
CoroutineScope(Dispatchers.Main).launch {
launch(Job() + CoroutineExceptionHandler { coroutineContext, throwable -> }) {
throw Exception("error")
}
}
这个答案我们在下面揭晓。
CoroutineScope和Job
先来个比较简单的 case:
Kotlin
val scope = CoroutineScope(Job())
scope.launch {
println("111")
delay(1000)
println("222")
}
scope.launch {
println("333")
delay(1000)
println("444")
}
scope.cancel()
这个输出我们很容易得出,这俩协程用的是同一个 scope 作用域,因此调用 scope.cancel()
的话,两个协程都会取消。
scope.cancel()
干了啥?我们看看它的实现:
Kotlin
public fun CoroutineScope.cancel(cause: CancellationException? = null) {
val job = coroutineContext[Job] ?: error("Scope cannot be cancelled because it does not have a job: $this")
job.cancel(cause)
}
可以看出这个取消最终也是通过 job.cancel
来处理的,那 CoroutineScope 又是长啥样的?
Kotlin
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
它只有一个 coroutineContext 属性,表示协程的作用域,作用域里面有当前协程的上下文。在创建 CoroutineScope 的时候会判断传入的 context 里有没有传入 Job 上下文,如果没有的话会默认添加一个:
Kotlin
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
那么我们再看另一个示例:
Kotlin
val job1 = Job()
val job = CoroutineScope(job1).launch {
println("$this")
println("${job1 == this.coroutineContext[Job]?.parent}") // true
}
println("$job") // == 上面的 this
这段代码可以验证 Job 的父子关系,上面协程代码块里的 this 是一个新的 CoroutineScope 作用域,我们已经知道 CoroutineScope 中只有一个 coroutineContext 上下文属性,因此这俩作用域的区别就在 coroutineContext 里。分析协程 launch 的源码,发现这个 this 是 AbstractCoroutine 的实例,它实现了 CoroutineScope 和 Job 接口,同时返回的 job 对象也是它,不过只能使用它作为 Job 对象的一些能力(比如cancel)。
那这个 AbstractCoroutine 实例的上下文是怎么创建的呢?
Kotlin
public abstract class AbstractCoroutine<in T>(
parentContext: CoroutineContext,
initParentJob: Boolean,
active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
public override val coroutineContext: CoroutineContext get() = parentContext + this
}
其中 parentContext 是父 scope 里的上下文加上 launch 时传入的上下文,而 AbstractCoroutine 里的上下文是 parentContext 加上自己,根据 深入Kotlin协程系列|图解上下文 中分析的,它的 Key 是 Job,因此会替换父上下文中的 Job 元素,且 AbstractCoroutine 初始化时会通过
initParentJob
方法来初始化父子 Job 关系,所以它的 parent 是父上下文中的 Job 对象。
看到这,我们可以确定上一节给的这个示例不会崩溃了:
Kotlin
CoroutineScope(Dispatchers.Main).launch { // 1
launch(Job() + CoroutineExceptionHandler { coroutineContext, throwable -> }) { // 2
throw Exception("error")
}
}
因为启动协程 2 时,传入了一个新的 Job 对象,这样就会把 1 协程 scope 对象里创建的 Job 对象替换掉了,因此 1 和 2 之间的 Job 没有父子关系,2 协程就相当于顶层协程。
SupervisorJob
SupervisorJob 是一种特殊的 Job,会改变协程默认的异常传递方式,使用 SupervisorJob 的子协程在发生异常后,不会影响到父协程和其他兄弟协程。原理是重写了 childCancelled 方法:
Kotlin
// SupervisorJob
override fun childCancelled(cause: Throwable): Boolean = false
// Job
public open fun childCancelled(cause: Throwable): Boolean {
if (cause is CancellationException) return true
return cancelImpl(cause) && handlesException
}
具体是咋起作用的,可以参考之前的文章 协程取消和异常处理。子协程的异常 cause 传递到 SupervisorJob 这一层后,不再往上抛了。
Kotlin
val scope = CoroutineScope(
CoroutineExceptionHandler { _, _ -> println("error")} + SupervisorJob()
)
scope.launch { // 1
delay(100)
throw RuntimeException()
}
scope.launch { // 2
delay(200)
println("111")
}
上面代码中在 1 协程发生异常后,2 协程不会被取消,因为 SupervisorJob 发功了。再看一段代码:
Kotlin
val scope = CoroutineScope(CoroutineExceptionHandler { _, _ -> println("error") })
scope.launch { // 3
launch(SupervisorJob()) { // 1
delay(100)
throw RuntimeException()
}
launch {
delay(200)
println("111") // 2
}
}
上面 2 处能正常打印,看起来符合预期,我们再把 1 处的 SupervisorJob()
换成 Job()
,发现 2 还是能正常打印,这是为啥?
原因跟上一节最后一个示例一样,手动传入 Job 对象后,1 处的协程跟 3 协程不再是父子关系了,所以异常不会抛给它。我们把 1 处手动传入的 Job 对象去掉,发生异常后,2 也不会打印了,因为恢复了兄弟关系,而异常默认会影响兄弟协程。
async
老规矩,先上个栗子:
Kotlin
val scope = CoroutineScope(Job())
val deferred = scope.async {
throw Exception("error")
}
scope.launch {
try {
deferred.await()
} catch (e: Exception) {
println("${e.message}")
}
}
这段代码是可以正常捕获的,因为 async 协程 本身不会处理异常,在 await 时才抛出异常。我们稍微改下逻辑:
Kotlin
val scope = CoroutineScope(Job())
scope.launch { // 1
val deferred = async { // 2
throw Exception("error")
}
try {
deferred.await()
} catch (e: Exception) {
println("${e.message}")
}
}
猜猜这段代码能捕获到不?答案是 No。因为按照异常传递的逻辑,1 是 2 协程的父协程,2 里面抛出异常后,会传递给 1 处理,而 1 没有任何捕获措施,所以就崩溃了。我们所说的 async 协程不处理异常是指 DeferredCoroutine 没有重写 handleJobException 方法,所以在 async 的地方不会崩溃,但异常的传递还是生效的,具体逻辑也可以参考 协程取消和异常处理。要处理的话,参考前面的写法,给 1 设置一个 CoroutineExceptionHandler 就行。
好的,我们再稍微改一下代码:
Kotlin
val scope = CoroutineScope(Job())
scope.launch { // 1
val deferred = async(Job()) { // 2
throw Exception("error")
}
try {
deferred.await()
} catch (e: Exception) {
println("${e.message}")
}
}
这样子就能正常捕获啦。原因在前面解释过几次了:2 协程 可以理解成顶层协程,跟 1 协程无关,所以自然不会传递到 1 协程。
总结
插入一个字节跳动的招聘广告:【招聘】深圳/广州 - 字节跳动 - 剪映&CapCut全球工具方向 - Android ,有想法和疑问的同学可以加我微信私聊或者评论,可以帮写推荐语之类的,其他端也可以帮内推,目前都还比较缺人。
看到这,文章开头的代码是不是知道输出啥了?感兴趣的话可以自己验证下。
这篇文章在 深入Kotlin协程系列|图解上下文 和 协程取消和异常处理 的基础上,整理了一下协程异常和取消(取消也是一种特殊的异常)的相关内容:
- 处理协程异常可以使用 CoroutineExceptionHandler 或 try-catch,但这两种方式都跟它所处的代码位置有关,位置不对,异常处理会失效,导致崩溃。
- 每个协程都有自己的作用域,作用域里有协程的上下文,用来决定当前协程该怎么运行(运行线程,异常处理等)。
- 协程之间的 Job 可以有父子关系,但由于 CoroutineContext 中唯一 Key 的特性,在启动子协程时如果传入了新的 Job 对象,就会换掉这两个协程之间的父子关系。
- async 协程在 await 时才会让用户消费异常,所以可以考虑 try-catch await 的地方,它在 async 的地方不会处理崩溃,但异常的传递是生效的。
- 如果不希望子协程的异常影响到父协程,可以考虑 SupervisorJob 和 supervisorScope
文中内容如有错误欢迎指出,共同进步!更新不易,觉得不错的留个赞再走哈(发现上篇文章的收藏量比赞多了一倍多,白嫖怪!!!)~
苍耳叔叔的专栏们:
- Android视图系统:Android 视图系统相关的底层原理解析。
- Kotlin专栏:Kotlin 学习相关的博客,包括协程, Flow 等。
- Android架构学习之路:架构不是一蹴而就的,希望我们有一天的时候,能够从自己写的代码中找到架构的成就感,而不是干几票就跑路。工作太忙,更新比较慢,大家有兴趣的话可以一起学习。
- Android实战系列:记录实际开发中遇到和解决的一些问题。
- Android优化系列:记录Android优化相关的文章,持续更新。