Kotlin 协程之取消与异常处理探索之旅(下)

前言

协程系列文章:

上篇分析了线程异常&取消操作以及协程Job相关知识,有了这些基础知识,我们再来看协程的取消与异常处理就比较简单了。
通过本篇文章,你将了解到:

  1. 协程取消的几种方式
  2. 协程异常处理几种方式
  3. 协程异常传递原理

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<String>) {
    var demo = CancelDemo()
    demo.testCancel()
    Thread.sleep(1000000)
}

先启动一个子协程,它返回Job对象,当子协程成功运行后再取消它。
结果如下:
image.png

该打印反馈出两个信息:

  1. 子协程启动并运行后才开始取消它。
  2. 子协程并没有终止运行,而是正常运行到结束。

你可能对第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")
        }
    }

运行结果:
image.png
从打印结果可以看出:

协程确实被取消了,可以通过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)函数加了异常处理,打印结果如下:

image.png
果然不出所料,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 处理。
image.png
此时,ArithmeticException 异常被CoroutineExceptionHandler 捕获了。
注,虽然能够捕获异常,但是发生异常的协程还是不能往下执行了。

3. 协程异常传递原理

协程对异常的再加工

launch{}

花括号里的内容即为协程体,而执行这部分的逻辑在BaseContinuationImpl.resumeWith()函数里:
image.png
你可发现此处的重点?
这里将协程体的执行加了try…catch 捕获了,也就是说不论协程体里发生了什么异常,在这里都能够被捕获。
你可能会问了,既然能够捕获,为啥还会有异常抛出呢?我们有理由相信,协程内部一定记录了这个异常,然后在某个地方再次将它抛出。
此处捕获了异常之后,将它构造为Result,并记录在变量outcome里,接着看看后续对这个值的处理。
流程有点长,直接看调用栈:
image.png

重点看红色框里的两个函数。

#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 状态。
打印结果如下:
image.png

此时我们发现:

当子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的状态,打印结果如下:
image.png
很明显得出结论:

当子Job 发生异常时,会将异常传递给父Job,父Job 先将自己名下的所有子Job都取消,然后将自己取消,最后继续将异常往上抛。

这部分的传递依靠Job 链完成,上篇文章我们有深入分析过Job 结构:
image.png

从源码分析其传递流程,先看调用栈:
image.png

重点看notifyCancelling(xx)函数:

#JobSupport.kt
//list == 子Job 链表
private fun notifyCancelling(list: NodeList, cause: Throwable) {
    //回调,忽略
    onCancelling(cause)
    //取消所有子Job
    notifyHandlers<JobCancellingNode>(list, cause)//①
    //取消父Job
    cancelParent(cause) //②
}

分为两个要点:

#JobSupport.kt
private inline fun <reified T: JobNode> notifyHandlers(list: NodeList, cause: Throwable?) {
    var exception: Throwable? = null
    list.forEach<T> { 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
    }

这段代码透露出两个意思:

  1. 取消时候产生的异常称为"取消异常",该异常比较特殊,当某个job 发生异常时,它不会往上传递。
  2. 如果不是取消异常,则调用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()。
结果如下:
image.png
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() 可知:
image.png

取消异常的传递

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 都会被取消。

至此,所有内容分析完毕,小结一下之前的内容:

  1. 协程的异常会沿着Job链传递,子协程发生异常会导致父协程(祖父协程…)、兄弟协程的取消。
  2. 若要防止上述情况,需要使用SupervisorJob作为父Job,它将忽略子Job产生的异常,不将它传递出去。
  3. 取消异常不会向上传递,父协程的取消会导致其下所有的子协程被取消。

关于协程的取消与异常处理到此分析完毕,下篇将分析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 协程系列全面解读


版权声明:本文为wekajava原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。