深入Kotlin协程系列|关于Job和异常处理,不再困惑

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 来处理的,另外 coroutineContext[Job] 是不是很熟悉?在 深入Kotlin协程系列|图解上下文 中已经学会这种用法啦。

看看 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优化相关的文章,持续更新。

相关推荐
幻雨様3 小时前
UE5多人MOBA+GAS 45、制作冲刺技能
android·ue5
Jerry说前后端5 小时前
Android 数据可视化开发:从技术选型到性能优化
android·信息可视化·性能优化
Meteors.6 小时前
Android约束布局(ConstraintLayout)常用属性
android
alexhilton6 小时前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw10 小时前
安卓图片性能优化技巧
android
风往哪边走10 小时前
自定义底部筛选弹框
android
Yyyy48211 小时前
MyCAT基础概念
android
Android轮子哥12 小时前
尝试解决 Android 适配最后一公里
android
雨白13 小时前
OkHttp 源码解析:enqueue 非同步流程与 Dispatcher 调度
android
风往哪边走13 小时前
自定义仿日历组件弹框
android