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

前言

协程系列文章:

我们知道线程可以被终止,线程里可以抛出异常,类似的协程也会遇到此种情况。本篇将从线程的终止与异常处理分析开始,逐渐引入协程的取消与异常处理。
通过本篇文章,你将了解到:

  1. 线程的终止
  2. 线程的异常处理
  3. 协程的Job 结构

1. 线程的终止

如何终止一个线程

阻塞状态下终止

先看个Demo:

class ThreadDemo {
    fun testStop() {
        //构造线程
        var t1 = thread {
            println("thread start")
            Thread.sleep(2000)
            println("thread end")
        }
        //1s后中断线程
        Thread.sleep(1000)
        t1.interrupt()
    }
}

fun main(args : Array<String>) {
    var threadDemo = ThreadDemo()
    threadDemo.testStop()
}

结果如下:


image.png

可以看出,"thread end" 没有打印出来,说明线程被成功中断了。
上述Demo里线程能够被中断的本质是:

Thread.sleep(xx)方法会检测中断状态,若是发现发生了中断,则抛出异常。

非阻塞状态下终止

改造一下Demo:

class ThreadDemo {
    fun testStop() {
        //构造线程
        var t1 = thread {
            var count = 0
            println("thread start")
            while (count < 100000000) {
                count++
            }
            println("thread end count:$count")
        }
        //等待线程运行
        Thread.sleep(10)
        println("interrupt t1 start")
        t1.interrupt()
        println("interrupt t1 end")
    }
}

运行结果如下:


image.png

可以看出,线程启动后,中断线程,而最后线程依然正常运行到结束,说明此时线程并没有被中断。
本质原因:

interrupt() 方法仅仅只是唤醒线程与设置中断标记位。

此种场景下如何终止一个线程呢?我们继续改造一下Demo:

class ThreadDemo {
    fun testStop() {
        //构造线程
        var t1 = thread {
            var count = 0
            println("thread start")
            //检测是否被中断
            while (count < 100000000 && !Thread.interrupted()) {
                count++
            }
            println("thread end count:$count")
        }
        //等待线程运行
        Thread.sleep(10)
        println("interrupt t1 start")
        t1.interrupt()
        println("interrupt t1 end")
    }
}

对比之前的Demo,仅仅只是添加了中断标记检测:Thread.interrupted()。
该方法返回true表示该线程被中断了,于是我们手动停止计数。
结果如下:


image.png

由此可见,线程被成功终止了。

综上所述,如何终止一个线程我们有了结论:


image.png

更加深入的分析原理以及两者的结合使用请移步:Java “优雅”地中断线程(实践篇)

2. 线程的异常处理

不论在Java 还是Kotlin里,异常都是可以通过try...catch 捕获。
典型如下:

    fun testException() {
        try {
            1/0
        } catch (e : Exception) {
            println("e:$e")
        }
    }

结果:


image.png

成功捕获了异常。

改造一下Demo:

    fun testException() {
        try {
            //开启线程
            thread {
                1/0
            }
        } catch (e : Exception) {
            println("e:$e")
        }
    }

大家先猜测一下结果,能够捕获异常吗?
接着来看结果:


image.png

很遗憾,无法捕获。
根本原因:

异常的捕获是针对当前线程的堆栈。而上述Demo是在main(主)线程里进行捕获,而异常时发生在子线程里。

你可能会说,简单我直接在子线程里进行捕获即可。

    fun testException() {
        thread {
            try {
                1/0
            } catch (e : Exception) {
                println("e:$e")
            }
        }
    }

这么做没毛病,很合理也很刚。
考虑另一种场景:若是主线程想要获取子线程异常的原因,进而做不同的处理。
这时候就引入了:UncaughtExceptionHandler。
继续改造Demo:

    fun testException3() {
        try {
            //开启线程
            var t1 = thread(false){
                1/0
            }
            t1.name = "myThread"
            //设置
            t1.setUncaughtExceptionHandler { t, e ->
                println("${t.name} exception:$e")
            }
            t1.start()
        } catch (e : Exception) {
            println("e:$e")
        }
    }

其实就是注册了个回调,当线程发生异常时会调用uncaughtException(xx)方法。
结果如下:


image.png

说明成功捕获了异常。

3. 协程的Job 结构

Job 基础

Job 的创建

在分析协程的取消与异常之前,先要弄清楚父子协程的结构。

class JobDemo {
    fun testJob() {
        //父Job
        var rootJob: Job? = null
        runBlocking {
            //启动子Job
            var job1 = launch {
                println("job1")
            }
            //启动子Job
            var job2 = launch {
                println("job2")
            }
            rootJob = coroutineContext[Job]
            job1.join()
            job2.join()
        }
    }
}

如上,通过runBlocking 启动一个协程,此时它作为父协程,在父协程里又依次启动了两个协程作为子协程。
launch()函数为CoroutineScope 的扩展函数,它的作用是启动一个协程:

#Builders.common.kt
fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    //构造新的上下文
    val newContext = newCoroutineContext(context)
    //协程
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    //开启
    coroutine.start(start, coroutine, block)
    //返回协程
    return coroutine
}

以返回StandaloneCoroutine 为例,它继承自AbstractCoroutine,进而继承自JobSupport,而JobSupport 实现了Job接口,具体实现类即为JobSupport。

我们知道协程是比较抽象的事物,而Job 作为协程具象性的表达,表示协程的作业。
通过Job,我们可以控制、监控协程的一些状态,如:

    //属性
     job.isActive //协程是否活跃
     job.isCancelled //协程是否被取消
     job.isCompleted//协程是否执行完成
     ...
    //函数
    job.join()//等待协程完成
    job.cancel()//取消协程
    job.invokeOnCompletion()//注册协程完成回调
    ...

Job 的存储

Demo里通过launch()启动了两个子协程,暴露出来两个子Job,而它们的父Job 在哪呢?
从runBlocking()里寻找答案:

#Builers.kt
fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
    //...
    //创建BlockingCoroutine,它也是个Job
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
}

BlockingCoroutine 继承自AbstractCoroutine,AbstractCoroutine里有个成员变量:

#AbstractCoroutine.kt
    //this 指代AbstractCoroutine 本身,也就是BlockingCoroutine
    public final override val context: CoroutineContext = parentContext + this

不仅是BlockingCoroutine,StandaloneCoroutine 也继承自AbstractCoroutine,由此可见:

Job实例索引存储在对应的Context(上下文)里,通过context[Job]即可索引到具体的Job对象。

父子Job 关联

绑定关系初步建立

我们通常说的协程是结构化并发,它的状态比如异常可以在协程之间传递,怎么理解结构化这概念呢?重点在于理解父子协程、平级子协程之间是如何关联的。
还是上面的Demo,稍微改造:

    fun testJob2() {
        runBlocking {//父Job==rootJob
            //启动子Job
            var job1 = launch {
                println("job1")
            }
        }
    }

从job1的创建开始分析,先看AbstractCoroutine 的实现:

#AbstractCoroutine.kt
abstract class AbstractCoroutine<in T>(
    parentContext: CoroutineContext,//父协程的上下文
    initParentJob: Boolean,//是否需要关联父子Job,默认true
    active: Boolean //默认true
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

    init {
        //关联父子Job
        //parentContext[Job] 即为从父Context里取出父Job
        if (initParentJob) initParentJob(parentContext[Job])
    }
}

#JobSupport.kt
protected fun initParentJob(parent: Job?) {
    if (parent == null) {
        //没有父Job,根Job 没有父Job
        parentHandle = NonDisposableHandle
        return
    }
    parent.start() // make sure the parent is started
    //绑定父子Job      ①
    val handle = parent.attachChild(this)
    //返回父Handle,指向链表 ②
    parentHandle = handle
    //...
}

分两个点 ①和 ②,先看①:

#JobSupport.kt
//ChildJob 为接口,接口里的函数是用来给父Job取消其子Job用的
//JobSupport 实现了ChildJob 接口
public final override fun attachChild(child: ChildJob): ChildHandle {
    //ChildHandleNode(child) 构造ChildHandleNode 对象
    return invokeOnCompletion(onCancelling = true, handler = ChildHandleNode(child).asHandler) as ChildHandle
}

#JobSupport.kt
public final override fun invokeOnCompletion(
    onCancelling: Boolean,
    invokeImmediately: Boolean,
    handler: CompletionHandler
): DisposableHandle {
    //创建
    val node: JobNode = makeNode(handler, onCancelling)
    loopOnState { state ->
        when (state) {
            //根据state,组合为一个ChildHandleNode 的链表
            //比较繁琐,忽略
            //返回链表头
        }
    }
}

最终的目的是返回ChildHandleNode,它可能是个链表。
再看②,将返回的结果记录在子Job的parentHandle 成员变量里。
小结一下:

  1. 父Job 构造ChildHandleNode 节点放入到链表里,每个节点存储的是子Job以及父Job 本身,而该链表可以与父Job里的state 互转。
  2. 子Job 的成员变量parentHandle 指向该链表。

由1.2 步骤可知,子Job 通过parentHandle 可以访问父Job,而父Job 通过state可以找出其下关联的子Job,如此父子Job就建立起了联系。


image.png

Job 链构建

上面分析了父子Job 之间是如何建立联系的,接下来重点分析子Job之间是如何关联的。
重点看看ChildHandleNode 的构造:

#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)
}

可以看出,ChildHandleNode 里的invoke()、childCancelled()函数最终都依靠Job 实现其功能。
通过查找,很容易发现parentCancelled()/childCancelled()函数在JobSupport 均有实现。

ChildHandleNode 最终继承自LockFreeLinkedListNode,该类是一个线程安全的双向链表,双向链表我们很容易想到其实现的核心是依赖前驱后驱指针。

#LockFreeLinkedList.kt
public actual open class LockFreeLinkedListNode {
    //后驱指针
    private val _next = atomic<Any>(this) // Node | Removed | OpDescriptor
    //前驱指针
    private val _prev = atomic(this) // Node to the left (cannot be marked as removed)
    private val _removedRef = atomic<Removed?>(null) // lazily cach
}

于是ChildHandleNode 链表如下图:


image.png

这样子Job 之间就通过前驱/后驱指针联系起来了。
再结合实际的Demo来阐述Job 链构造过程。

    fun testJob2() {
        runBlocking {//父Job==rootJob
            //启动子Job
            var job1 = launch {
                println("job1")
            }
            //启动子Job
            var job2 = launch {
                println("job2")
            }
            cancel("")
        }
    }

第1步
runBlocking 创建一个协程,并构造Job,该Job为BlockingCoroutine,在创建Job的同时会尝试绑定父Job,而此时它作为根Job,没有父Job,因此parentHandle = NonDisposableHandle。
而这个时候,它还没创建子Job,因此state 里没有子Job。

image.png

第2步
创建第1个Job:Job1。
此时构造的Job为StandaloneCoroutine,在创建Job的同时会尝试绑定父Job,从父Context里取出父Job,即为BlockingCoroutine,找到后就开始进行关联绑定。
于是,现在的结构变为:

image.png

父Job 的state(指向链表头)此时就是个链表,该链表里的节点为ChildHandleNode,而ChildHandleNode 里存储了父Job与子Job。

第3步
创建第2个Job:Job2。
同样的,构造的Job 为StandaloneCoroutine,绑定父Job,最终的结构变为:

image.png

小结来说:

  1. 创建Job 时尝试关联其父Job。
  2. 若父Job 存在,则构造ChildHandleNode,该Node 存储了父Job以及子Job,并将ChildHandleNode 存储在父Job 的State里,同时子Job 的parentHandle 指向ChildHandleNode。
  3. 再次创建Job,继续尝试关联父Job,因为父Job 里已经关联了一个子Job,因此需要将新的子Job 挂到前一个子Job 后面,这样就形成了一个子Job链表。

简单Job 示意图:


image.png

如图,类似一个树结构。
当Job 链建立起来后,状态的传递就简单了。

  • 父Job 通过链表可以找到每个子Job。
  • 子Job 通过parentHandle 找到父Job。
  • 子Job 之间通过链表索引。

由于篇幅原因,协程的取消与异常将在下篇分析,敬请关注。

本文基于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阅读 213,558评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,002评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,024评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,144评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,255评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,295评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,068评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,478评论 1 305
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,789评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,965评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,649评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,267评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,982评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,223评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,800评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,847评论 2 351

推荐阅读更多精彩内容