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

3,702 阅读9分钟

前言

协程系列文章:

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

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