Kotlin Coroutines(协程) 完全解析系列:
Kotlin Coroutines(协程) 完全解析(一),协程简介
Kotlin Coroutines(协程) 完全解析(二),深入理解协程的挂起、恢复与调度
Kotlin Coroutines(协程) 完全解析(三),封装异步回调、协程间关系及协程的取消
Kotlin Coroutines(协程) 完全解析(四),协程的异常处理
Kotlin Coroutines(协程) 完全解析(五),协程的并发
本文基于 Kotlin v1.3.0-rc-146,Kotlin-Coroutines v1.0.0-RC1
前面一篇文章协程简介,简单介绍了协程的一些基本概念以及其简化异步编程的优势,但是协程与线程有什么区别,协程的挂起与恢复是如何实现的,还有协程运行在哪个线程上,依然不是很清楚。这篇文章将分析协程的实现原理,一步步揭开协程的面纱。先来看看协程中最关键的挂起函数的实现原理:
1. 挂起函数的工作原理
协程的内部实现使用了 Kotlin 编译器的一些编译技术,当挂起函数调用时,背后大致细节如下:
挂起函数或挂起 lambda 表达式调用时,都有一个隐式的参数额外传入,这个参数是Continuation
类型,封装了协程恢复后的执行的代码逻辑。
用前文中的一个挂起函数为例:
|
|
实际上在 JVM 中更像下面这样:
|
|
Continuation
的定义如下,类似于一个通用的回调接口:
|
|
现在再看之前postItem
函数:
|
|
然而,协程内部实现不是使用普通回调的形式,而是使用状态机来处理不同的挂起点,大致的 CPS(Continuation Passing Style) 代码为:
|
|
上面代码中每一个挂起点和初始挂起点对应的 Continuation 都会转化为一种状态,协程恢复只是跳转到下一种状态中。挂起函数将执行过程分为多个 Continuation 片段,并且利用状态机的方式保证各个片段是顺序执行的。
1.1 挂起函数可能会挂起协程
挂起函数使用 CPS style 的代码来挂起协程,保证挂起点后面的代码只能在挂起函数执行完后才能执行,所以挂起函数保证了协程内的顺序执行顺序。
在多个协程的情况下,挂起函数的作用更加明显:
|
|
上面的例子中,await()
挂起函数挂起当前协程,直到异步协程完成执行,但是这里并没有阻塞线程,是使用状态机的控制逻辑来实现。而且挂起函数可以保证挂起点之后的代码一定在挂起点前代码执行完成后才会执行,挂起函数保证顺序执行,所以异步逻辑也可以用顺序的代码顺序来编写。
注意挂起函数不一定会挂起协程,如果相关调用的结果已经可用,库可以决定继续进行而不挂起,例如async { requestToken() }
的返回值Deferred
的结果已经可用时,await()
挂起函数可以直接返回结果,不用再挂起协程。
1.2 挂起函数不会阻塞线程
挂起函数挂起协程,并不会阻塞协程所在的线程,例如协程的delay()
挂起函数会暂停协程一定时间,并不会阻塞协程所在线程,但是Thread.sleep()
函数会阻塞线程。
看下面一个例子,两个协程运行在同一线程上:
|
|
运行结果为:
|
|
从上面结果可以看出,当协程 1 暂停 200 ms 时,线程并没有阻塞,而是执行协程 2 的代码,然后在 200 ms 时间到后,继续执行协程 1 的逻辑。所以挂起函数并不会阻塞线程,这样可以节省线程资源,协程挂起时,线程可以继续执行其他逻辑。
1.3 挂起函数恢复协程后运行在哪个线程
协程的所属的线程调度在前一篇文章《协程简介》中有提到过,主要是由协程的CoroutineDispatcher
控制,CoroutineDispatcher
可以指定协程运行在某一特定线程上、运作在线程池中或者不指定所运行的线程。所以协程调度器可以分为Confined dispatcher
和Unconfined dispatcher
,Dispatchers.Default
、Dispatchers.IO
和Dispatchers.Main
属于Confined dispatcher
,都指定了协程所运行的线程或线程池,挂起函数恢复后协程也是运行在指定的线程或线程池上的,而Dispatchers.Unconfined
属于Unconfined dispatcher
,协程启动并运行在 Caller Thread 上,但是只是在第一个挂起点之前是这样的,挂起恢复后运行在哪个线程完全由所调用的挂起函数决定。
|
|
输出如下:
|
|
上面第三行输出,经过delay
挂起函数后,使用Dispatchers.Unconfined
的协程挂起恢复后依然在delay
函数使用的DefaultExecutor
上。
2. 协程深入解析
上面更多地是通过 demo 的方式说明挂起函数函数的一些特性,但是协程的创建、启动、恢复、线程调度、协程切换是如何实现的呢,还是不清楚,下面结合源码详细地解析协程。
2.1 协程的创建与启动
先从新建一个协程开始分析协程的创建,最常见的协程创建方式为CoroutineScope.launch {}
,关键源码如下:
|
|
coroutine.start(start, coroutine, block)
默认情况下会走到startCoroutineCancellable
,最终会调用到createCoroutineUnintercepted
。
|
|
重点注意该方法的注释,创建一个协程,创建了一个新的可挂起计算,通过调用resume(Unit)
启动该协程。而且返回值为Continuation
,Continuation
提供了resumeWith
恢复协程的接口,用以实现协程恢复,Continuation
封装了协程的代码运行逻辑和恢复接口。
再看之前协程代码编译生成的内部类final class postItem$1 extends SuspendLambda ...
,协程的计算逻辑封装在invokeSuspend
方法中,而SuspendLambda
的继承关系为 SuspendLambda -> ContinuationImpl -> BaseContinuationImpl -> Continuation,其中BaseContinuationImpl
部分关键源码如下:
|
|
而这部分与之前的分析也是吻合的,启动协程流程是resume(Unit)
->resumeWith()
->invokeSuspend()
,协程的挂起通过suspend
挂起函数实现,协程的恢复通过Continuation.resumeWith
实现。
2.2 协程的线程调度
协程的线程调度是通过拦截器实现的,前面提到了协程启动调用到了startCoroutineCancellable
,该方法实现为:
|
|
再看intercepted()
的具体实现:
|
|
所以intercepted()
最终会使用协程的CoroutineDispatcher
的interceptContinuation
方法包装原来的 Continuation,拦截所有的协程运行操作。
DispatchedContinuation
拦截了协程的启动和恢复,分别是resumeCancellable(Unit)
和重写的resumeWith(Result)
:
|
|
继续跟踪newSingleThreadContext()
、Dispatchers.IO
等dispatch
方法的实现,发现其实都调用了Executor.execute(Runnable)
方法,而Dispatchers.Unconfined
的实现更简单,关键在于isDispatchNeeded()
返回为false
。
2.3 协程的挂起和恢复
Kotlin 编译器会生成继承自SuspendLambda
的子类,协程的真正运算逻辑都在invokeSuspend
中。但是协程挂起的具体实现是如何呢?先看下面示例代码:
|
|
其中 launch 协程编译生成的 SuspendLambda 子类的invokeSuspend
方法如下:
|
|
上面代码中 launch 协程挂起的关键在于async$default.await(this) == coroutine_suspended
,如果此时 async 线程未执行完成,await()
返回为IntrinsicsKt.getCOROUTINE_SUSPENDED()
,就会 return,launch 协程的invokeSuspend
方法执行完成,协程所在线程继续往下运行,此时 launch 协程处于挂起状态。所以协程挂起就是协程挂起点之前逻辑执行完成,协程的运算关键方法resumeWith()
执行完成,线程继续执行往下执行其他逻辑。
协程挂起有三点需要注意的:
启动其他协程并不会挂起当前协程,所以
launch
和async
启动线程时,除非新协程运行在当前线程,则当前协程只能在新协程运行完成后继续执行,否则当前协程都会马上继续运行。协程挂起并不会阻塞线程,因为协程挂起时相当于执行完协程的方法,线程继续执行其他之后的逻辑。
挂起函数并一定都会挂起协程,例如
await()
挂起函数如果返回值不等于IntrinsicsKt.getCOROUTINE_SUSPENDED()
,则协程继续执行挂起点之后逻辑。
下面继续分析await()
的实现原理,它的实现中关键是调用了JobSupport.awaitSuspend()
方法:
|
|
上面源码中ResumeAwaitOnCompletion
的invoke
方法的逻辑就是调用continuation.resume(state as T)
恢复协程。invokeOnCompletion
函数里面是如何实现 async 协程完成后自动恢复之前协程的呢,源码实现有些复杂,因为很多边界情况处理就不全部展开,其中最关键的逻辑如下:
|
|
接下来我断点调试 launch 协程恢复的过程,从 async 协程的SuspendLambda
的子类的completion.resumeWith(outcome)
-> AbstractCoroutine.resumeWith(result)
..-> JobSupport.tryFinalizeSimpleState()
-> JobSupport.completeStateFinalization()
-> state.list?.notifyCompletion(cause)
-> node.invoke
,最后 handler 节点里面通过调用resume(result)
恢复协程。
所以await()
挂起函数恢复协程的原理是,将 launch 协程封装为 ResumeAwaitOnCompletion 作为 handler 节点添加到 aynsc 协程的 state.list,然后在 async 协程完成时会通知 handler 节点调用 launch 协程的 resume(result) 方法将结果传给 launch 协程,并恢复 launch 协程继续执行 await 挂起点之后的逻辑。
而这过程中有两个final
的resumeWith
方法,一个是SuspendLambda
的父类BaseContinuationImpl
的,我们再来详细分析一篇:
|
|
接下来再来看另外一类 Continuation,AbstractCoroutine 的resumeWith
实现,里面封装了协程状态的更新和恢复之前挂起的协程逻辑等:
|
|
所以其中一类 Continuation BaseContinuationImpl
的resumeWith
封装了协程的运算逻辑,用以协程的启动和恢复;而另一类 Continuation AbstractCoroutine
,主要是负责维护协程的状态和管理,它的resumeWith
主要作用是修改协程状态,将结果返回给调用者协程,并恢复调用者协程。
2.4 协程的三层包装
通过一步步的分析,慢慢发现协程其实有三层包装。常用的launch
和async
返回的Job
、Deferred
,里面封装了协程状态,提供了取消协程接口,而它们的实例都是继承自AbstractCoroutine
,它是协程的第一层包装。第二层包装是编译器生成的SuspendLambda
的子类,封装了协程的真正运算逻辑,继承自BaseContinuationImpl
,其中completion
属性就是协程的第一层包装。第三层包装是前面分析协程的线程调度时提到的DispatchedContinuation
,封装了线程调度逻辑,包含了协程的第二层包装。三层包装都实现了Continuation
接口,通过代理模式将协程的各层包装组合在一起,每层负责不同的功能。
下面是协程运行的流程图:
3. 小结
经过以上解析之后,再来看协程就是一段可以挂起和恢复执行的运算逻辑,而协程的挂起是通过挂起函数实现的,挂起函数用状态机的方式用挂起点将协程的运算逻辑拆分为不同的片段,每次运行协程执行的不同的逻辑片段。所以协程有两个很大的好处:一是简化异步编程,支持异步返回;而是挂起不阻塞线程,提供线程利用率。