• 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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    先启动一个子协程,它返回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")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    运行结果:
    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")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    先猜猜①会打印吗?有同学说不会打印,因为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")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    观察打印结果,我们发现①始终无法打印出来,我们有理由相信协程执行到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")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    如上,给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")
                    }
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    2. 协程异常处理几种方式

    try…catch异常

    上面提及了协程的取消异常,它是比较特殊的异常,我们先来看看普通的异常处理。

        fun testException() {
            runBlocking {
                try {
                    var job1 = launch(Dispatchers.IO) {
                        println("job1 start")
                        //异常
                        1 / 0
                        println("job1 end")
                    }
                } catch (e: Exception) {
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    先猜猜这样能够捕获异常吗?根据我们上篇线程异常捕获的经验,此处的子协程运行在子线程里,在子线程里发生的异常,主线程当然无法通过try 捕获到。
    当然,万能的方式是在子协程里捕获:

        fun testException2() {
            runBlocking {
                var job1 = launch(Dispatchers.IO) {
                    try {
                        println("job1 start")
                        //异常
                        1 / 0
                        println("job1 end")
                    } catch (e : Exception) {
                        println("e=$e")
                    }
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    全局捕获异常

    与线程类似,协程也可以全局捕获异常。

        //创建处理异常对象
        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")
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    如上Demo,先定义一个异常处理对象,然后将它与协程作用域关联起来。
    当子协程发生了异常,这个异常往上抛给父Job,最后交给CoroutineExceptionHandler 处理。
    image.png
    此时,ArithmeticException 异常被CoroutineExceptionHandler 捕获了。
    注,虽然能够捕获异常,但是发生异常的协程还是不能往下执行了。

    3. 协程异常传递原理

    协程对异常的再加工

    launch{}
    
    • 1

    花括号里的内容即为协程体,而执行这部分的逻辑在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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    如果我们定义了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}")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    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}")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    如上,父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) //②
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    分为两个要点:

    #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) {
                //...
            }
        }
        //..
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    调用至此,实际上是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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    如果你看过上篇文章的分析,再看此处就比较容易了,此处再贴一下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)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    对于①来说,list 里的node 为ChildHandleNode,node.invoke(cause)其实调用的就是childJob.parentCancelled(job),而childJob 表示每个子Job。

        #JobSupport.kt
        public final override fun parentCancelled(parentJob: ParentJob) {
            //遍历Job 下的子Job,取消它们
            cancelImpl(parentJob)
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    就这么层层遍历下去,直至取消完所有层级的子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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

    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}")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    仅仅改动了一个地方:将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
    }
    
    • 1
    • 2
    • 3
    • 4

    不做任何处理,当然就不能取消父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}")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    取消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}")
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    当父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 协程系列全面解读

  • 相关阅读:
    【牛客网-公司真题-前端入门篇】——奇安信春招笔试-前端-卷2
    基于QT技术实现无线点菜系统设计与实现
    基于VGG16改进的特征检测器
    Tomcat启动控制台乱码问题
    哪些车企AEB标配率「不及格」
    AI是未来?——知识导航
    C++算法:柱状图中最大的矩形
    App上架Apple App Store和Google Play流程
    Cell|易基因微量DNA甲基化测序助力中国科学家成功构建胚胎干细胞嵌合体猴,登上《细胞》封面
    增删改查模块测试用例设计
  • 原文地址:https://blog.csdn.net/wekajava/article/details/126172969