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

相关推荐
500了4 小时前
Kotlin基本知识
android·开发语言·kotlin
人工智能的苟富贵5 小时前
Android Debug Bridge(ADB)完全指南
android·adb
小雨cc5566ru10 小时前
uniapp+Android面向网络学习的时间管理工具软件 微信小程序
android·微信小程序·uni-app
bianshaopeng11 小时前
android 原生加载pdf
android·pdf
hhzz11 小时前
Linux Shell编程快速入门以及案例(Linux一键批量启动、停止、重启Jar包Shell脚本)
android·linux·jar
火红的小辣椒12 小时前
XSS基础
android·web安全
勿问东西14 小时前
【Android】设备操作
android
五味香14 小时前
C++学习,信号处理
android·c语言·开发语言·c++·学习·算法·信号处理
图王大胜16 小时前
Android Framework AMS(01)AMS启动及相关初始化1-4
android·framework·ams·systemserver
工程师老罗18 小时前
Android Button “No speakable text present” 问题解决
android