美文网首页Android开发Android开发Android技术知识
Kotlin 协程之取消与异常处理探索之旅(上)

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

作者: 小鱼人爱编程 | 来源:发表于2022-08-04 15:18 被阅读0次

    前言

    协程系列文章:

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

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

    相关文章

      网友评论

        本文标题:Kotlin 协程之取消与异常处理探索之旅(上)

        本文链接:https://www.haomeiwen.com/subject/dssjwrtx.html