前言
协程系列文章:
- 一个小故事讲明白进程、线程、Kotlin 协程到底啥关系?
- 少年,你可知 Kotlin 协程最初的样子?
- 讲真,Kotlin 协程的挂起/恢复没那么神秘(故事篇)
- 讲真,Kotlin 协程的挂起/恢复没那么神秘(原理篇)
- Kotlin 协程调度切换线程是时候解开真相了
- Kotlin 协程之线程池探索之旅(与Java线程池PK)
- Kotlin 协程之取消与异常处理探索之旅(上)
- Kotlin 协程之取消与异常处理探索之旅(下)
- 来,跟我一起撸Kotlin runBlocking/launch/join/async/delay 原理&使用
上篇分析了线程异常&取消操作以及协程Job相关知识,有了这些基础知识,我们再来看协程的取消与异常处理就比较简单了。
通过本篇文章,你将了解到:
- 协程取消的几种方式
- 协程异常处理几种方式
- 协程异常传递原理
1. 协程取消的几种方式
非阻塞状态时取消
先看Demo:
class CancelDemo {
fun testCancel() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
Thread.sleep(200)
var count = 0
while (count < 1000000000) {
count++
}
println("job1 end count:$count")
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消协程)
job1.cancel()
println("end cancel job1")
}
}
}
fun main(args: Array) {
var demo = CancelDemo()
demo.testCancel()
Thread.sleep(1000000)
}
先启动一个子协程,它返回Job对象,当子协程成功运行后再取消它。
结果如下:
该打印反馈出两个信息:
- 子协程启动并运行后才开始取消它。
- 子协程并没有终止运行,而是正常运行到结束。
你可能对第2点比较困惑,为啥取消没效果呢?
还记得我们上篇分析的线程的终止吗?在非阻塞状态下,通过Thread.interrupt()调用下仅仅只是唤醒线程并且设置标记位。
与线程类似,协程Job.cancel()函数仅仅只是将state值改变而已,当然我们可以主动获取协程当前的状态。
runBlocking() {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
Thread.sleep(80)
var count = 0
//判断协程的状态,若是活跃则继续循环
//isActive = coroutineContext[Job]?.isActive ?: true
while (count < 1000000000 && isActive) {
count++
}
println("job1 end count:$count")
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消协程)
job1.cancel()
println("end cancel job1")
}
}
运行结果:
从打印结果可以看出:
协程确实被取消了,可以通过Job.isActive 判断取消是否成功,若Job.isActive = false 则表示协程被取消了。
阻塞状态时取消
说到阻塞状态,你可能会说:"简单,我几行代码就给你演示了:"
fun testCancel3() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
Thread.sleep(3000)
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消协程)
job1.cancel()
println("end cancel job1")
}
}
先猜猜①会打印吗?有同学说不会打印,因为Thread.sleep(xx)方法会抛出异常。
实际结果却是:①会打印。
认为不会打印的同学可能将线程的阻塞与协程的阻塞(挂起)混淆了,Thread.sleep(xx)是阻塞协程所在的线程,它是线程的专属方法,因此它会响应线程的中断:Thread.interrupt()并抛出异常,而不会响应协程的Job.cancel()函数。
协程阻塞(挂起)并不会阻塞其所在的线程,改造Demo如下:
fun testCancel4() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
//协程挂起
println("job1 start")
delay(3000)
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消协程)
job1.cancel()
println("end cancel job1")
}
}
观察打印结果,我们发现①始终无法打印出来,我们有理由相信协程执行到delay(xx)时抛出了异常,导致后续的代码无法执行,接着验证猜想。
fun testCancel4() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
//协程挂起
println("job1 start")
try {
delay(3000)
} catch (e : Exception) {
println("delay exception:$e")
}
println("coroutine isActive:$isActive")//①
}
Thread.sleep(100)
println("start cancel job1")
//取消job(取消协程)
job1.cancel()
println("end cancel job1")
}
}
如上,给delay(xx)函数加了异常处理,打印结果如下:
果然不出所料,Job.cancel(xx)引发了delay(xx)异常,它抛出的异常为:JobCancellationException,该异常在JVM平台继承自CancellationException。
如何"优雅"地取消协程
结合阻塞/非阻塞状态下取消协程的分析,与线程处理方式类似:对于阻塞状态的协程,我们可以捕获异常,对于非阻塞的地方我们使用状态判断。
根据不同的结果来决定协程被取消后代码的处理逻辑。
fun testCancel5() {
runBlocking() {
var job1 = launch(Dispatchers.IO) {
try {
//挂起函数
} catch (e : Exception) {
println("delay exception:$e")
}
if (!isActive) {
println("cancel")
}
}
}
}
2. 协程异常处理几种方式
try...catch异常
上面提及了协程的取消异常,它是比较特殊的异常,我们先来看看普通的异常处理。
fun testException() {
runBlocking {
try {
var job1 = launch(Dispatchers.IO) {
println("job1 start")
//异常
1 / 0
println("job1 end")
}
} catch (e: Exception) {
}
}
}
先猜猜这样能够捕获异常吗?根据我们上篇线程异常捕获的经验,此处的子协程运行在子线程里,在子线程里发生的异常,主线程当然无法通过try 捕获到。
当然,万能的方式是在子协程里捕获:
fun testException2() {
runBlocking {
var job1 = launch(Dispatchers.IO) {
try {
println("job1 start")
//异常
1 / 0
println("job1 end")
} catch (e : Exception) {
println("e=$e")
}
}
}
}
全局捕获异常
与线程类似,协程也可以全局捕获异常。
//创建处理异常对象
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("handle exception:$exception")
}
fun testException3() {
runBlocking {
//声明协程作用域
var scope = CoroutineScope(Job() + exceptionHandler)
var job1 = scope.launch(Dispatchers.IO) {
println("job1 start")
//异常
1 / 0
println("job1 end")
}
}
}
如上Demo,先定义一个异常处理对象,然后将它与协程作用域关联起来。
当子协程发生了异常,这个异常往上抛给父Job,最后交给CoroutineExceptionHandler 处理。
此时,ArithmeticException 异常被CoroutineExceptionHandler 捕获了。
注,虽然能够捕获异常,但是发生异常的协程还是不能往下执行了。
3. 协程异常传递原理
协程对异常的再加工
launch{}
花括号里的内容即为协程体,而执行这部分的逻辑在BaseContinuationImpl.resumeWith()函数里:
你可发现此处的重点?
这里将协程体的执行加了try...catch 捕获了,也就是说不论协程体里发生了什么异常,在这里都能够被捕获。
你可能会问了,既然能够捕获,为啥还会有异常抛出呢?我们有理由相信,协程内部一定记录了这个异常,然后在某个地方再次将它抛出。
此处捕获了异常之后,将它构造为Result,并记录在变量outcome里,接着看看后续对这个值的处理。
流程有点长,直接看调用栈:
重点看红色框里的两个函数。
#handleCoroutineExceptionImpl.kt
public fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
try {
//从context里取出异常处理对象,对应外部设置的全局捕获回调对象
context[CoroutineExceptionHandler]?.let {
//具体处理
it.handleException(context, exception)
//处理ok,直接退出
return
}
} catch (t: Throwable) {
handleCoroutineExceptionImpl(context, handlerException(exception, t))
return
}
//再次尝试处理
handleCoroutineExceptionImpl(context, exception)
}
internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
// 尝试handler处理
// 从当前线程抛出异常
val currentThread = Thread.currentThread()
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
}
如果我们定义了CoroutineExceptionHandler,那么使用该Handler处理异常,如果没有定义,则直接抛出异常。
以上即为协程对异常的再加工处理过程。
异常在协程之间的传递(Job)
先看Demo:
fun testException4() {
runBlocking {
//声明协程作用域
var rootJob = Job()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
//异常
1 / 0
println("job1 end")
}
job1.join()
//检查父Job 状态
println("rootJob isActive:${rootJob.isActive}")
}
}
rootJob 作为父Job,通过launch(xx)函数创建了子Job:job1。
等待job1执行完毕后,再检查父Job 状态。
打印结果如下:
此时我们发现:
当子Job 发生异常时,会取消父Job。
除了对父Job 有影响,对其它兄弟Job 是否有影响呢?
继续做尝试:
fun testException5() {
runBlocking {
//声明协程作用域
var rootJob = Job()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(100)
//异常
1 / 0
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(200)
//检查jo2状态
println("jo2 isActive:$isActive")
}
job1.join()
//检查父Job 状态
println("rootJob isActive:${rootJob.isActive}")
}
}
如上,父Job 分别创建了两个子Job:job1、job2,当job1 发生异常时,分别检测父Job与job2的状态,打印结果如下:
很明显得出结论:
当子Job 发生异常时,会将异常传递给父Job,父Job 先将自己名下的所有子Job都取消,然后将自己取消,最后继续将异常往上抛。
这部分的传递依靠Job 链完成,上篇文章我们有深入分析过Job 结构:
从源码分析其传递流程,先看调用栈:
重点看notifyCancelling(xx)函数:
#JobSupport.kt
//list == 子Job 链表
private fun notifyCancelling(list: NodeList, cause: Throwable) {
//回调,忽略
onCancelling(cause)
//取消所有子Job
notifyHandlers(list, cause)//①
//取消父Job
cancelParent(cause) //②
}
分为两个要点:
①
#JobSupport.kt
private inline fun notifyHandlers(list: NodeList, cause: Throwable?) {
var exception: Throwable? = null
list.forEach { node ->
try {
//遍历list,调用node
node.invoke(cause)
} catch (ex: Throwable) {
//...
}
}
//..
}
调用至此,实际上是job1.notifyCancelling(xx),因为job1没有子Job,因此①处list 里没有节点。
②
#JobSupport.kt
private fun cancelParent(cause: Throwable): Boolean {
val isCancellation = cause is CancellationException
val parent = parentHandle
if (parent === null || parent === NonDisposableHandle) {
//没有父Job,无法继续往上,停止
return isCancellation
}
//取消父Job
return parent.childCancelled(cause) || isCancellation
}
如果你看过上篇文章的分析,再看此处就比较容易了,此处再贴一下Node 结构:
#JobSupport.kt
//主要有2个成员变量
//childJob: ChildJob 表示当前node指向的子Job
//parent: Job 表示当前node 指向的父Job
internal class ChildHandleNode(
@JvmField val childJob: ChildJob
) : JobCancellingNode(), ChildHandle {
override val parent: Job get() = job
//父Job 取消其所有子Job
override fun invoke(cause: Throwable?) = childJob.parentCancelled(job)
//子Job向上传递,取消父Job
override fun childCancelled(cause: Throwable): Boolean = job.childCancelled(cause)
}
对于①来说,list 里的node 为ChildHandleNode,node.invoke(cause)其实调用的就是childJob.parentCancelled(job),而childJob 表示每个子Job。
#JobSupport.kt
public final override fun parentCancelled(parentJob: ParentJob) {
//遍历Job 下的子Job,取消它们
cancelImpl(parentJob)
}
就这么层层遍历下去,直至取消完所有层级的子Job。
而对于②而言,parent.childCancelled(cause)==job.childCancelled(cause),而job 表示的是当前job 的父Job。
#JobSupport.kt
public open fun childCancelled(cause: Throwable): Boolean {
//如果是取消异常,则忽略
if (cause is CancellationException) return true
//取消父Job
return cancelImpl(cause) && handlesException
}
这段代码透露出两个意思:
- 取消时候产生的异常称为"取消异常",该异常比较特殊,当某个job 发生异常时,它不会往上传递。
- 如果不是取消异常,则调用cancelImpl(xx)函数,该函数取消当前Job的所有子Job 与自己。
因为Job 链类似树的结构,因此异常传递是递归形式的。
Job 发生异常时,不仅取消自己名下的所有Job,也会取消父Job,往上递归直至根Job。
SupervisorJob 作用与原理
作用
子协程发生异常后,会取消父协程、兄弟协程的执行,这在有些场景是不合理的,因为伤害范围太广,明明是一个子协程的锅,非得所有协程来背。
还好官方考虑过这个问题,提供了SupervisorJob 来解决该问题。
fun testException6() {
runBlocking {
//声明协程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(100)
//异常
1 / 0
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(200)
//检查jo2状态
println("jo2 isActive:$isActive")
}
job1.join()
//检查父Job 状态
println("rootJob isActive:${rootJob.isActive}")
}
}
仅仅改动了一个地方:将Job()换为SupervisorJob()。
结果如下:
job1 发生异常的时候,job2 和父job都没受到影响。
原理
当需要取消父Job 时,势必会调用到:job.childCancelled(cause)
而SupervisorJob 重写了该函数:
#Supervisor.kt
private class SupervisorJobImpl(parent: Job?) : JobImpl(parent) {
override fun childCancelled(cause: Throwable): Boolean = false
}
不做任何处理,当然就不能取消父Job了,不能取消父Job,也就不能取消父Job 下的子Job。
对比Job()与SupervisorJob() 可知:
取消异常的传递
job.childCancelled(cause) 表示要取消父Job,而该函数实现里有对取消异常进行了特殊处理,因此取消异常不会往上传递。
fun testException7() {
runBlocking {
//声明协程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(2000)
println("job1 end")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(1000)
//检查jo2状态
println("jo2 isActive:$isActive")
}
Thread.sleep(300)
job1.cancel()
//检查父Job 状态
println("rootJob isActive:${rootJob.isActive}")
}
}
取消job1,不会影响父Job,也不会影响子Job。
当取消父Job时,查看子Job 是否受影响。
fun testException8() {
runBlocking {
//声明协程作用域
var rootJob = SupervisorJob()
var scope = CoroutineScope(rootJob)
var job1 = scope.launch(Dispatchers.IO + exceptionHandler) {
println("job1 start")
Thread.sleep(2000)
println("jo1 isActive:$isActive")
}
var job2 = scope.launch {
println("job2 start")
Thread.sleep(1000)
//检查jo2状态
println("jo2 isActive:$isActive")
}
Thread.sleep(300)
rootJob.cancel()
//检查父Job 状态
println("rootJob isActive:${rootJob.isActive}")
}
}
当父Job 取消时,子Job 都会被取消。
至此,所有内容分析完毕,小结一下之前的内容:
- 协程的异常会沿着Job链传递,子协程发生异常会导致父协程(祖父协程...)、兄弟协程的取消。
- 若要防止上述情况,需要使用SupervisorJob作为父Job,它将忽略子Job产生的异常,不将它传递出去。
- 取消异常不会向上传递,父协程的取消会导致其下所有的子协程被取消。
关于协程的取消与异常处理到此分析完毕,下篇将分析launch/async/delay/runBlocking 的使用、原理以及异同点。
本文基于Kotlin 1.5.3,文中完整Demo请点击
您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力
持续更新中,和我一起步步为营系统、深入学习Android/Kotlin
1、Android各种Context的前世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分发全套服务
6、Android invalidate/postInvalidate/requestLayout 彻底厘清
7、Android Window 如何确定大小/onMeasure()多次执行原因
8、Android事件驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标彻底明了
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显示过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置基础系列
18、Android Jetpack 易懂易学系列
19、Kotlin 轻松入门系列
20、Kotlin 协程系列全面解读