前言
挂起函数提供了切换调用栈后自动切回来的能力。那么
- 这对挂起函数异常的捕获有何影响?
- 当协程发生异常时,协程内如何传递异常?
- 协程间如何传播异常?
本文是我对《深入理解Kotlin协程》的读书笔记,记录我对协程异常处理的理解。
协程内的异常捕获
在实践中我们可以使用try-catch语句包裹需要捕获异常的代码,但如果被包裹的代码涉及到调用栈的切换,则捕获不了:
1 | fun crashAtEventLoop() { |
我们知道挂起函数内部有可能切换调用栈。如果挂起函数内部切换了调用栈,并且在另一个调用栈发生了异常,会如何表现?
1 | fun testCrash() { |
调用testCrash
后必会抛出未被捕获的异常而崩溃,崩溃点在第13行。这是合理的,因为崩溃发生在挂起函数切换调用栈后,切回调用栈前,是协程框架所干预不到的地方。
考虑到Continuation
的resumeWith
方法接受的是一个kotlin.Result
参数,我们不妨使用kotlin.runCatching
包裹住另一个调用栈,发生异常时返回Result.Failure
:
1 | private suspend fun suspendFunc() = suspendCoroutine<Int> { cont -> |
改造完毕后,再执行一遍,发现异常在testCrash
方法中被捕获了。因此我们可以得出一个结论,在另一个调用栈的异常需要通过Continuation.resumeWith
方法传递,才能在恢复时挂起点处抛出并捕获。
协程内的异常传递
那么原因是什么呢?通过上篇文章浅谈Kotlin协程(3)-非阻塞式挂起与恢复,我们知道suspendCoroutine
所暴露出来的Continuation
是这么一个结构:
SafeContinuation
的作用是保证suspendCoroutine
所暴露的Continuation
只调用一次;
DispatchedContinuation
的作用是切线程环境;
那么焦点就来到了BaseContinuationImpl
:
1 | class BaseContinuationImpl { |
BaseContinuationImpl.resumeWith
捕获了invokeSuspend
中主动抛出的异常,将异常传递给了StandaloneCoroutine
。
查看StandaloneCoroutine
的代码,发现它只重写了handleJobException
方法。从这个方法的名字看来,就是它处理BaseContinuationImpl
传递过来的异常。跟进这个方法:
1 | private open class StandaloneCoroutine( |
捕获的异常会按顺序传递给以下处理器处理。如果对应的处理器能够处理,则直接返回,否则就会传递给下一个处理器继续处理:
- 注册在协程上下文中的
CoroutineExceptionHandler
; - 注册在
resources/META-INF/services
目录下,通过服务发现机制获取的CoroutineExceptionHandler
; Thread.UncaughtExceptionHandler
由于此时我们并没有注册CoroutineExceptionHandler
,所以最终异常会交给Thread.UncaughtExceptionHandler
处理,其结果就是抛出异常。而后在try-catch语句中再次被捕获,并打印出来。
协程的结构
父子协程关系构建
要想了解异常如何在协程间传播,我们需要了解协程之间的结构关系。协程是一个抽象的概念,在Kotlin中的具象化表达为Job
,并且Kotlin协程标准库提供了使用CoroutineScope
为receiver的启动方法。我们可以这么启动子协程:
1 | val job0 = coroutineScope.launch { |
从直觉来看,很自然的就能知道三个job之间的关系,job1、job2是job0的子协程:
事实上在标准库中的实现也确实是这个结构。父子协程关系的建立被标准库抽象成了Job.attachChild
方法,在启动一个Job
(即协程)时会与父Job
建立父子关系。
以CoroutineScope.launch
启动的协程为例,内部新建了一个协程AbstractCoroutine
,并在其构造方法中构建父子协程关系:
1 | class AbstractCoroutine : JobSupport(...) { |
attachChild
的真正实现在JobSupport
内,它返回的是一个双向链表的节点;而这个双向链表的作用,是维护父Job下所有子协程的Job
取消回调和Job
完成回调。由于此时是子Job
调用父Job
的attachChild
方法,所以我们把视角切换到父Job
,跟进代码:
1 | open class JobSupport : Job, ChildJob, ParentJob { |
由于协程标准库使用无锁算法实现双向链表,所以在代码实现中充斥着大量的自旋操作。这里简化了部分代码,只保留关键部分。
首先,attachChild
方法其实是将传入的childJob
包装成为了ChildHandleNode
后,又去调用了invokeOnComplete
方法。所谓的ChildHandleNode
,我认为其实是父Job
与子Job
建立双向关系的连接点,具体其实现便能明白:
1 | internal class ChildHandleNode( |
其次,在invokeOnComplete
方法内会构建一个双向链表的节点,然后根据当前Job
的状态决定是否插入到双向链表中和返回该节点。
1 | open class JobSupport : Job, ChildJob, ParentJob { |
- 如果当前
Job
处于Empty
状态(代表未完成,且没有任何回调,链表为空),就以创建的回调节点为链表头,并返回创建的节点; - 如果当前的
Job
处于Incomplete
状态(代表未完成,但有回调,链表不为空),就调用addLastAtomic
将新建的回调节点插入到维护在状态state
内的双向链表中,并返回新建的节点; - 否则,当前
Job
已经完成了,则返回一个NonDisposableHandle
,代表不会再对任何操作做出响应。
此时Job
父子关系已经构建完毕,其关键在于Job
内部维护的状态_state
。状态内部存储着一个双向链表,每个链表节点代表着父子Job
沟通的桥梁ChildHandleNode
,内部同时存储了父Job
和子Job
。
光阅读代码略显抽象,不妨以上文的job0
,job1
和job2
为例,使用GlobalScope.launch
启动job0
时,由于job0
并没有父Job
,所以Job
构建父子关系的过程相对较为简单,其结果为:
由于job0
的上下文CoroutineContext
中没有Job
,因此其parentHandle
被初始化为NonDisposableHandle
,然后直接返回。而_state
则使用默认值Empty
,代表没有任何的取消、完成回调。
在job0
运行的时候,启动了job1
。由于启动job1
的协程作用域CoroutineScope
中存在Job
(即job0
),所以会执行父子关系构建的逻辑。parentHandle
被设置为ChildHandle
,内部指向了双向链表表头ChildHandleNode
——维护job0
与job1
关系的桥梁。
由于job1
并没有子Job
,所以其parentHandle
被初始化为NonDisposableHandle
,其_state
为默认值,即Empty
。
因此,在job1
启动后,协程间的关系为:
job1
启动完毕后,job2
的启动过程也类似,我们可以如法炮制。当涉及到双向链表的插入操作时,job0
与job2
的桥梁——ChildHandleNode
会被插入到表头之后。最终,在job2
启动后,协程间的关系为:
小结
协程在启动时,会去检查传入的协程上下文CoroutineContext
中是否有Job
的存在。如果存在Job
,则认为是父Job
,进而去初始化父子Job
的关系;反之则跳过;
在初始化父子Job
关系时,子Job
会调用父Job
的attachChild
,主动让父Job
来attach自己。在attachChild
的过程中,父Job
会构建一个双向链表,每个节点为ChildHandleNode
,存储父Job
自己和对应的子Job
,使得父Job
和子Job
能够双向通信。
最终,父Job
会将这个双向链表包装成ChildHandle
,保存在parentHandle
中。
异常传播
了解完协程间的结构后,或许我们就能轻松理解异常在协程间传播的逻辑。在一个协程内,异常的传递类似于剥洋葱的方式,在被包了多层Continuation
的洋葱结构内进行传播,最终被捕获的异常传递到了AbstractCoroutine
的resumeWith
方法中:
1 | class AbstractCoroutine { |
异常传播的关键全在makeCompletingOnce
方法中,也是异常传播、协程取消等庞大逻辑的入口函数。其中的Result.toState
方法,会将成功和失败包装成对应的类,方便后续对结果成功和结果失败的判断。我们进一步追踪makeCompletingOnce
方法看看:
1 | class JobSupport { |
贴出的代码省略了各种中间状态,只保留主流程;其调用流程如下:
可以看到,在tryMakeCompleting
方法内,会对当前Job
的状态进行一次判断,以决定走快路径还是走慢路径。所谓快路径和慢路径,区别在于慢路径需要处理一些中间状态,例如多个异常合并为一个异常、挂起等待子Job
完成等状态,但结果都是殊途同归,和快路径一样,最终会扭转Job
自身的状态,并通知子Job
和父Job
。
先来看看慢路径,慢路径主要处理了多个异常合并为一个异常、挂起等待子Job
完成等逻辑。总体流程如下:
这里有几个比较关键的方法,nofityCancelling
、tryWaitForChild
和cancelParent
nofityCancelling(list, cause)
如其命名以及参数列表,这个方法最主要的职责是将异常通知给当前Job
的子Job
。查看代码:
1 | class JobSupprot { |