当前位置:网站首页>Kotlin 由浅入深,带你搞懂协程异常处理的那点事
Kotlin 由浅入深,带你搞懂协程异常处理的那点事
2022-08-10 22:52:00 【代码与思维】
关于协程的异常处理,一直以来都不是一个简单问题。因为涉及到了很多方面,包括 异常的传递 ,结构化并发下的异常处理 ,异常的传播方式 ,不同的Job 等,所以常常让很多(特别是刚使用协程的,也不乏老手)同学摸不着头脑。
常见有如下两种处理方式:
try catchCoroutineExceptionHandler
但这两种方式(特别是第二种)到底该什么时候用,用在哪里,却是一个问题?
比如虽然知道 CoroutineExceptionHandler ,但为什么增加了却还是崩溃?到底应该加在哪里? 尝试半天发现无解,最终又只能直接 try catch ,粗暴并有效,最终遇到此类问题,直接下意识 try 住。
try catch虽然直接,一定程度上也帮我们规避了很多使用方面的问题,但同时也埋下了很多坑,也就是说,并不是所有协程的异常都可以try住(取决于使用位置),其也不是任何场景的最优解。
鉴于此,本篇将从头到尾,帮助你理清以下问题:
- 什么是结构化并发?
- 协程的异常传播流程与形式
- 协程的异常处理方式
- 为什么有些异常处理了却还是崩了
- SupervisorJob 的使用场景
supervisorScope与coroutineScope- 异常处理方式的场景推荐
本文尽可能会用大白话与你分享理解,如有遗漏或理解不当,也欢迎评论区反馈。
好了,让我们开始吧!
结构化并发
在最开始前,我们先搞清楚什么是 结构化并发,这对我们理解协程异常的传递将非常有帮助。
让我们先将思路转为日常业务开发中,比如在某某业务中,可能存在好几个需要同时处理的逻辑,比如同时请求两个网络接口,同时操作两个子任务等。我们暂且称上述学术化概念为 多个并发操作。
而每个并发操作其实都是在处理一个单独的任务,这个 任务 中,可能还存在 子任务 ; 同样对于这个子任务来说,它又是其父任务的子单元。每个任务都有自己的生命周期,子任务的生命周期会继承父任务的生命周期,比如如果父任务关闭,子任务也会被取消。而如果满足这样特性,我们就称其就是 结构化并发。
在协程中,我们常用的 CoroutineScope,正是基于这样的特性,即其也有自己的作用域与层级概念。
比如当我们每次调用其扩展方法 launch() 时,这个内部又是一个新的协程作用域,新的作用域又会与父协程保持着层级关系,当我们 取消 CoroutineScope 时,其所有子协程也都会被关闭。
如下代码片段:
val scope = CoroutineScope(Job())
val jobA = scope.launch(CoroutineName("A")) {
val jobChildA = launch(CoroutineName("child-A")) {
delay(1000)
println("xxx")
}
// jobChildA.cancel()
}
val jobB = scope.launch(CoroutineName("B")) {
delay(500)
println("xxx")
}
// scope.cancel()
我们定义了一个名为 scope 的作用域, 其中有两个子协程 jobA,B,同时 jobA 又有一个子协程 jobChildA。
如果我们要取消jobB,并不会影响jobA,其依然会继续执行;
但如果我们要取消整个作用域时 scope.cancel(),jobA,jobB 都会被取消,相应 jobA 被取消时, 因为其也有自己的作用域,所以 jobChildA 也会被取消,以此类推。而这就是协程的 结构化并发特性。
异常传播流程
默认情况下,任意一个协程发生异常时都会影响到整个协程树,而异常的传递通常是双向的,也即协程会向子协程与父协程共同传递,如下方所示:

整体流程如下:
- 先 cancel 子协程
- 取消自己
- 将异常传递给父协程
- (重复上述过程,直到根协程关闭)
举个例子,比如下面这段代码:

在上图中,我们创建了 两个子协程A,B,并在 A中 抛出异常,查看结果如右图所示, 当子协程A异常被终止时,我们的子协程B与父协程都受到影响被终止。
当然如果不想在协程异常时,同级别子协程或者父协程受到影响,此时就可以使用
SupervisorJob,这个我们放在下面再谈。
异常传播形式
在协程中,异常的传播形式有两种,一种是自动传播( launch 或 actor),一种是向用户暴漏该异常( async 或 produce ),这两种的区别在于,前者的异常传递过程是层层向上传递(如果异常没有被捕获),而后者将不会向上传递,会在调用处直接暴漏。
记住上述思路对我们处理协程的异常将会很有帮助。
异常处理方式
tryCatch
一般而言, tryCath 是我们最常见的处理异常方式,如下所示:
fun main() = runBlocking {
launch {
try {
throw NullPointerException()
} catch (e: Exception) {
e.printStackTrace()
}
}
println("嘿害哈")
}
当异常发生时,我们底部的输出依然能正常打印,这也不难理解,就像我们在 Android 或者 Java 中的使用一样。但有些时候这种方式并不一定能有效,我们在下面中会专门提到。但大多数情况下,tryCatch 依然如万金油一般,稳定且可靠。
CoroutineExceptionHandler
其是用于在协程中全局捕获异常行为的最后一种机制,你可以理解为,类似 Thread.uncaughtExceptionHandler 一样。
但需要注意的是,CoroutineExceptionHandler 仅在未捕获的异常上调用,也即这个异常没有任何方式处理时(比如在源头tryCatch了),由于协程是结构化的,当子协程发生异常时,它会优先将异常委托给父协程区处理,以此类推 直到根协程作用域或者顶级协程 。因此其永远不会使用我们子协程 CoroutineContext 传递的 CoroutineExceptionHandler(SupervisorJob 除外),对于 async 这种,而是直接向用户直接暴漏该异常,所以我们在具体调用处直接处理就行。
如下示例所示:
val scope = CoroutineScope(Job())
scope.launch() {
launch(CoroutineExceptionHandler { _, _ -> }) {
delay(10)
throw RuntimeException()
}
}

不难发现异常了,原因就是我们的 CoroutineExceptionHandler 位置不是根协程或者 CoroutineScope 初始化时。
如果我们改成下述方式,就可以正常处理该异常:
// 1\. 初始化scope时
val scope = CoroutineScope(Job() + CoroutineExceptionHandler { _, _ -> })
// 2\. 根协程
scope.launch(CoroutineExceptionHandler { _, _ -> }) { }
SupervisorJob
supervisorJob 是一个特殊的Job,其会改变异常的传递方式,当使用它时,我们子协程的失败不会影响到其他子协程与父协程,通俗点理解就是:子协程会自己处理异常,并不会影响其兄弟协程或者父协程,如下图所示:

举个简单的例子:
val scope = CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { _, _ -> })
scope.launch(CoroutineName("A")) {
delay(10)
throw RuntimeException()
}
scope.launch(CoroutineName("B")) {
delay(100)
Log.e("petterp", "正常执行,我不会收到影响")
}
当协程A失败时,协程B依然可以正常打印。
如果我们将上述的示例改一下,会发生什么情况?如下所示:
val scope = CoroutineScope(CoroutineExceptionHandler { _, _ -> })
scope.launch(SupervisorJob()) {
launch(CoroutineName("A")) {
delay(10)
throw RuntimeException()
}
launch(CoroutineName("B")) {
delay(100)
Log.e("petterp", "正常执行,我不会收到影响")
}
}
猜一猜B协程内部的log能否正常打印?
结果是不能
为什么? 我不是已经使用了 SupervisorJob() 吗?我们用一张图来看一下:

如上图所示,我们在 scope.launch 时传递了 SupervisorJob ,看着似乎没什么问题,我们期望的是 SupervisorJob 也会传递到子协程。但实则不会,因为子协程在 launch 时会创建新的协程作用域,其会使用默认新的 Job 替代我们传递 SupervisorJob ,所以导致我们传递的 SupervisorJob 被覆盖。所以如果我们想让子协程不影响父协程或者其他子协程,此时就必须再显示添加 SupervisorJob。
正确的打开方式如下所示:
scope.launch {
launch(CoroutineName("A") + SupervisorJob()) {
delay(10)
throw RuntimeException()
}
launch(CoroutineName("B")) {
delay(200)
Log.e("petterp", "猜猜我还能不能打印")
}
}
总结如下:
SupervisorJob可以用来改变我们的协程异常传递方式,从而让子协程自行处理异常。但需要注意的是,因为协程具有结构化的特点,SupervisorJob仅只能用于同一级别的子协程。如果我们在初始化scope时添加了SupervisorJob,那么整个scope对应的所有 根协程 都将默认携带SupervisorJob,否则就必须在CoroutineContext显示携带SupervisorJob。
小测试
try不住的异常
如下的代码,可以 try 住吗?
val scope = CoroutineScope(Job())
try {
//A
scope.launch {
throw NullPointerException()
}
} catch (e: Exception) {
e.printStackTrace()
}
答案是不会
为什么呢?我不是已经在 A 外面try了吗?
默认情况下,如果 异常没有被处理,而且顶级协程
CoroutineContext中没有携带CoroutineExceptionHandler,则异常会传递给默认线程的ExceptionHandler。在 Android 中,如果没有设置 Thread.setDefaultUncaughtExceptionHandler , 这个异常将立即被抛出,从而导致引发App崩溃。
我们在 launch 时,因为启动了一个新的协程作用域,而新的作用域内部已经是新的线程(可以理解为),因为内部发生异常时因为没有被直接捕获 , 再加上其Job不是 SupervisorJob ,所以异常将向上开始传递,因为其本身已经是根协程,此时根协程的 CoroutineContext 也没有携带 CoroutineExceptionHandler, 从而导致了直接异常。
CoroutinexxHandler 不生效?
下列代码中,添加的 CoroutineExceptionHandler 会生效吗?
val scope = CoroutineScope(Job())
scope.launch {
val asyncA = async(SupervisorJob()+CoroutineExceptionHandler { _, _ -> }) {
throw RuntimeException()
}
val asyncB = async(SupervisorJob()+CoroutineExceptionHandler { _, _ -> }) {
throw RuntimeException()
}
asyncA.await()
asyncB.await()
}
答案是: 不会生效
Tips: 如果你不是很理解
async的CoroutineContext里此时为什么要加SupervisorJob,请看下面,会再做解释。
你可能会想,这还不简单吗,上面不是已经提过了,如果根协程或者scope中没有设置 CoroutineExceptionHandler,异常会被直接抛出,所以这里肯定异常了啊。
如果你这样想了,恭喜回答正确~
那该怎么改一下上述示例呢?
scope 初始化时 或者 根协程里 加上 CoroutineExceptionHandler,或者直接 async 里面 try catch 都可以。那还有没有其他方式呢?
此处停留10s 思考,loading…
如果你还记得我们最开始说过的异常的 传播形式 ,就会知道,对于 async 这种,在其异常时,其会主动向用户暴漏,而不是优先向上传递。
也就是说,我们直接可以在 await() 时 try Catch 。代码如下:
scope.launch {
val asyncA = async(SupervisorJob()){}
val asyncB = async(SupervisorJob()){}
val resultA = kotlin.runCatching { asyncA.await() }
val resultB = kotlin.runCatching { asyncB.await() }
}
runCatching是 kotlin 中对于tryCatch的一种包装,其会将结果使用Result类进行包装,从而让我们能更直观的处理结果,从而更加符合 kotlin 的语法习惯。
Tips
为什么上述 async 里要添加 SupervisorJob() ,这里再做一个解释。
val scope = CoroutineScope(Job())
scope.launch {
val asyncA = async(SupervisorJob()) { throw RuntimeException()}
val asyncB = async xxx
}
因为 async 时内部也是新的作用域,如果 async 对应的是根协程,那么我们可以在 await() 时直接捕获异常。怎么理解呢?
如下示例:
val scope = CoroutineScope(Job())
// async 作为根协程
val asyncA = scope.async { throw NullPointerException() }
val asyncB = scope.async { }
scope.launch {
// 此时可以直接tryCatch
kotlin.runCatching {
asyncA.await()
asyncB.await()
}
}
但如果 async 其对应的不是根协程(即不是 scope直接.async ),则会先将异常传递给父协程,从而导致异常没有在调用处暴漏,我们的tryCatch 自然也就无法拦截。如果此时我们为其增加 SupervisorJob() ,则标志着其不会主动传递异常,而是由该协程自行处理。所以我们可以在调用处(await()) 捕获。
相关扩展
supervisorScope
官方解释如下:
使用
SupervisorJob创建一个CoroutineScope并使用此范围调用指定的挂起块。提供的作用域从外部作用域继承其coroutineContext,但用SupervisorJob覆盖上下文的Job。一旦给定块及其所有子协程完成,此函数就会返回。
通俗点就是,我们帮你创建了一个 CoroutineScope ,初始化作用域时,使用 SupervisorJob 替代默认的Job,然后将其的作用域扩展至外部调用。如下代码所示:
val scope = CoroutineScope(CoroutineExceptionHandler { _, _ -> })
scope.launch() {
supervisorScope {
// launch A
launch(CoroutineName("A")) {
delay(10)
throw RuntimeException()
}
// launch B
launch(CoroutineName("B")) {
delay(100)
Log.e("petterp", "正常执行,我不会收到影响")
}
}
}
当 supervisorScope 里的所有子协程执行完成时,其就会正常退出作用域。
需要注意的是,supervisorScope 内部的 Job 为 SupervisorJob ,所以当作用域中子协程异常时,异常不会主动层层向上传递,而是由子协程自行处理,所以意味着我们也可以为子协程增加 CoroutineExceptionHandler 。如下所示:

当子协程异常时,因为我们使用了 supervisorScope ,所以异常此时不会主动传递给外部,而是由子类自行处理。
当我们在内部 launch 子协程时,其实也就是类似 scope.launch ,所以此时子协程A相也就是根协程,所以我们使用 CoroutineExceptionHandler 也可以正常拦截异常。但如果我们子协程不增加 CoroutineExceptionHandler ,则此时异常会被supervisorScope 抛出,然后被外部的 CoroutineExceptionHandler 拦截(也就是初始化scope作用域时使用的 ExceptionHandler)。
相应的,与 supervisorScope 相似的,还有一个 coroutineScope ,下面我们也来说一下这个。
coroutineScope
其主要用于并行分解协程子任务时而使用,当其范围内任何子协程失败时,其所有的子协程也都将被取消,一旦内部所有的子协程完成,其也会正常返回。
如下示例:

当子协程A 异常未被捕获时,此时 子协程B 和整个 协程作用域 都将被异常取消,此时异常将传递到顶级 CoroutineExceptionHandler
场景推荐
严格意义上来说,所有异常都可以用 tryCatch 去处理,只要我们的处理位置得当。但这并不是所有方式的最优解,特别是如果你想更优雅的处理异常时,此时就可以考虑 CoroutineExceptionHandler 。下面我们通过实际需求来举例,从而体会异常处理的的一些实践。
什么时候该用 SupervisorJob ,什么时候该用 Job?
引用官方的一句话就是:想要避免取消操作在异常发生时被传播,记得使用 SupervisorJob ;反之则使用 Job。
对于一个普通的协程,如何处理我的异常?
对于一个普通的协程,你可以在其协程作用域内使用 tryCatch(runCatching) ,如果其是根协程,你也可以使用 CoroutineExceptionHandler 作为最后的拦截手段 ,如下所示:
val scope = CoroutineScope(Job())
scope.launch {
runCatching { }
}
scope.launch(CoroutineExceptionHandler { _, throwable -> }) {
}
在某个子协程中,想使用 SupervisorJob 的特性去作为某个作用域去执行?
val scope = CoroutineScope(Job())
scope.launch(CoroutineExceptionHandler { _, _ -> }) {
supervisorScope {
launch(CoroutineName("A")) {
throw NullPointerException()
}
launch(CoroutineName("B")) {
delay(1000)
Log.e("petterp", "依然会正常执行")
}
}
}
SupervisorJob+tryCatch
我们有两个接口 A,B 需要同时请求,当接口A异常时,需要不影响B接口的正常展示,当接口B异常时,此时界面展示异常信息。伪代码如下:
val scope = CoroutineScope(Job())
scope.launch {
val jobA = async(SupervisorJob()) {
throw NullPointerException()
}
val jobB = async(SupervisorJob()) {
delay(100)
1
}
val resultA = kotlin.runCatching { jobA.await() }
val resultB = kotlin.runCatching { jobB.await() }
}
CoroutineExceptionHandler+SupervisorJob
如果你有一个顶级协程,并且需要自动捕获所有的异常,则此时可以选用上述方式,如下所示:
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("petterp", "自动捕获所有异常")
}
val ktxScope = CoroutineScope(SupervisorJob() + exceptionHandler)
作者:Petterp
链接:https://juejin.cn/post/7130132604568731655
来源:稀土掘金
边栏推荐
猜你喜欢

Detailed installation steps and environment configuration of geemap

CFdiv2-Beautiful Mirrors-(期望)

OneNote 教程,如何在 OneNote 中整理笔记本?

北京党建展馆vr全景制作优点有哪些

Nodes in the linked list are flipped in groups of k

使用方便、易于集成、可扩展的用于物流运输行业的文档管理软件

Flink(Pometheus监控)

【640. Solving Equations】

高学历毕业生,该学单片机还是plc?

Pro-test is effective | A method to deal with missing features of risk control data
随机推荐
This visual tool artifact is more intuitive and easy to use!love so much
怼不过产品经理?因为你不懂DDD领域建模与架构设计
完全自定义MaterialButtonToggleGroup颜色。
canvas
What would happen if disconnecting during the process of TCP connection?
二叉树 | 递归遍历 | leecode刷题笔记
实例051:按位与
实例052:按位或
API服务网关
HGAME 2022 Final writeup
Glide缓存核心原理详解
Configuring vim(7) from scratch - autocommands
实例050:随机数
pytorch tear CNN
PlaidCTF 2022 Amongst Ourselves: Shipmate writeup
高精度减法
MySQL:MySQL的集群——主从复制的原理和配置
解析方法的参数列表(包含参数名称)
高学历毕业生,该学单片机还是plc?
DC-9靶场下载及渗透实战详细过程(DC靶场系列)