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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,682评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,277评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,083评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,763评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,785评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,624评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,358评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,261评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,722评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,900评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,030评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,737评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,360评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,941评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,057评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,237评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,976评论 2 355

推荐阅读更多精彩内容