前言
在前面文章中,我们基本对协程的使用以及概念都有了大致理解和使用,但是这还远远不够,对于一些协程的原理,我们还是需要掌握的,只有这样,我们才可以知其然知其所以然,碰到协程问题或者报错时,可以看懂源码的报错信息。
本篇文章就拿我们最熟悉也最重要的挂起函数来说,关于挂起函数的介绍和使用,可以看文章:# 协程(4) | 挂起函数
在前面文章中,我们说过挂起函数的本质是Callback
,它可以在挂起点挂起,然后等待一个合适的时机恢复,从而实现以同步的代码写出异步的效果。
正文
关于挂起函数的原理,如果要深入研究直接看源码,非常难以看懂,这里缺少一个合适的切入点,所以本篇文章我们从CPS
转换开始,逐步分析其中的原理。
CPS
转换
在前面那篇介绍挂起函数文章中,我们说过从挂起函数转换为CallBack
形式的过程,叫做CPS
转换(Continuation-Passing-Style Transformation
),当然这里的Callback
是Continuation
,放个动图来加强回忆和理解:
在这个动图中,编译器把suspend
关键字给解析了,在解析前后,我们重点关注一下函数类型的变化。
还是suspend
挂起函数时:
/**
* 获取用户信息
* */
suspend fun getUserInfo(): String{
withContext(Dispatchers.IO){
delay(1000)
}
return "Coder"
}
该方法的函数类型是suspend () -> String
,当经过CPS
转换后,变成了(Continuation) -> Any?
类型,这里参数和返回值都发生了变化,我们就从这个变化来研究。
CPS
参数变化
首先就是多了一个Continuation
对象,我们以下面代码为例:
/**
* 挂起函数,这里由于获取信息是后面依赖于前面,
* 所以使用挂起函数来解决Callback
* */
suspend fun testCoroutine(){
val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(user,friendList)
println(feedList)
}
/**
* 获取用户信息
* */
suspend fun getUserInfo(): String{
withContext(Dispatchers.IO){
delay(1000)
}
return "Coder"
}
/**
* 获取好友列表
* */
suspend fun getFriendList(user: String): String{
withContext(Dispatchers.IO){
delay(1000)
}
return "Tom,Jack"
}
/**
* 获取和好友的动态列表
* */
suspend fun getFeedList(user: String, list: String): String{
withContext(Dispatchers.IO){
delay(1000)
}
return "[FeedList...]"
}
上面我们模拟了一个业务场景:先获取用户信息,再获取好友列表,最后获取好友动态列表。上面代码如果以Java的角度来看,testCoroutine
方法的调用情况会是下面这样:
// 变化在这里
// ↓
fun testCoroutine(continuation: Continuation): Any? {
// 变化在这里
// ↓
val user = getUserInfo(continuation)
// 变化在这里
// ↓
val friendList = getFriendList(user, continuation)
// 变化在这里
// ↓
val feedList = getFeedList(friendList, continuation)
log(feedList)
}
这里会发现testCoroutine
的参数为continuation
,同时在方法内部调用其他挂起函数时,会把这个coroutine
当做最后一个参数传递给其他挂起函数。
这也就说明了为什么挂起函数需要被另一个挂起函数所调用的原因,假如这里testCoroutine
是一个普通函数,则它就不会有这个continuation
参数来传递给其他挂起函数。
同时,以我们之前的理解,挂起函数的本质就是Callback
来看的话,testCoroutine
调用了好几个挂起函数,应该会有好几个匿名内部类实例回调来支持,但是实际情况是只会有一个continuation
实例被传递,这就是一种非常好的设计。
CPS
返回值变化
接着我们来看一下返回值的变化:
suspend fun getUserInfo(): String {}
// 变化在这里
// ↓
fun getUserInfo(cont: Continuation): Any? {}
从这里我们发现,原来getUserInfo()
的返回值类型是String
,但是解析完suspend
关键字的函数返回值是Any?
,那原来表示函数返回值类型的String
去哪了呢?
这里当然不会消失,这里是换了一种形式存在,这个String
保存在了Continuation<T>
的泛型参数中,即:
suspend fun getUserInfo(): String {}
// 变化在这里
// ↓
fun getUserInfo(cont: Continuation<String>): Any? {}
知道这个变化后,那返回类型Any?
代表什么意思呢?
这里可得注意了,经过CPS
转换后的方法的返回值,有一个重要的作用,就是:标志该挂起函数有没有被挂起。
这里听着有点奇怪,挂起函数不就是要挂起吗 ?其实不然,当挂起函数内没有调用其他挂起函数或者实现挂起函数时,它就不需要挂起。
比如下面代码:
/**
* 获取用户信息
* */
suspend fun getUserInfo(): String{
withContext(Dispatchers.IO){
delay(1000)
}
return "Coder"
}
在getUserInfo
方法中,当执行到withContext
时,该方法就会被挂起,这时就会返回CoroutineSingletons.COROUTINE_SUSPENDED
,用来标志getUserInfo
被挂起了。
但是比如下面代码:
/**
* 函数内部没有挂起
* */
suspend fun noSuspendGetUserInfo(): String{
return "zyh"
}
在这种情况下,该函数就和普通函数一样,不会被挂起,同时IDE会提醒你这个suspend
关键字是多余的。但是,IDE遇到suspend
关键字时,就会发生CPS
转换,所以上面方法经过CPS
转换后,返回类型是no suspend
的String
类型,这也是一种伪挂起。
所以这里我们也就明白了为什么CPS
后的挂起函数返回值类型是Any?
了。
挂起函数CPS
后的执行流程
理解了挂起函数在经过CPS
转换后的类型变化,以及参数和返回值类型所代表的含义,利用这种特性,就可以设计出我们熟知的挂起函数了。
首先,这里不会直接给出反编译后的代码,而是给出大致等价的代码,改善了可读性。其次,先通过这些简化代码学习原理,最后再通过反编译来验证我们的想法。
我们直接以下面代码为例:
/**
* 挂起函数,这里由于获取信息是后面依赖于前面,
* 所以使用挂起函数来解决Callback
* */
suspend fun testCoroutine(){
val user = getUserInfo()
val friendList = getFriendList(user)
val feedList = getFeedList(user,friendList)
println(feedList)
}
这个方法比较复杂,涉及到了3个挂起函数调用。在经过CPS
转换后,首先在testCoroutine
方法中会多出一个ContinuationImpl
的子类,它是原理实现的核心,大致代码如下:
/**
* CPS后的等价代码
* @param completion [Continuation]类型的参数
* */
fun testCoroutine(completion: Continuation<Any?>): Any? {
/**
* 本质是匿名内部类,这里给取了一个名字[TestContinuation], 构造
* 参数还是传递一个[Continuation]类型的字段
* */
class TestContinuation(completion: Continuation<Any?>) : ContinuationImpl(completion){
//表示状态机的状态
var label: Int = 0
//当前挂起函数执行的结果
var result: Any? = null
//用于保存挂起计算的结果,中间值
var mUser: Any? = null
var mFriendList: Any? = null
/**
* 状态机入口,该类是ContinuationImpl中的抽象方法,同时ContinuationImpl
* 又是继承至[Continuation],所以[Continuation]中的resumeWith方法会回调
* 该invokeSuspend方法。
*
* 即每当调用挂起函数返回时,该方法都会被调用,在方法内部,先通过result记录
* 挂起函数的执行结果,再切换labeal,最后再调用testCoroutine方法
* */
override fun invokeSuspend(_result: Result<Any?>): Any?{
result = _result
label = label or Int.Companion.MIN_VALUE
return testCoroutine(this)
}
}
}
在方法中,会生成一个匿名内部类,这里为了阅读方便,我们起了一个名字为TestContinuation
,它是Continuation
的子类,里面各种字段的作用可以直接查看注释,里面的核心方法就是invokeSuspend
方法,它是进入状态机的入口。
然后,就要判断testCoroutine
方法是否是第一次运行:
/**
* CPS后的等价代码
* @param completion [Continuation]类型的参数
* */
fun testCoroutine(completion: Continuation<Any?>): Any? {
...
val continuation = if (completion is TestContinuation){
completion
}else{
//作为参数
TestContinuation(completion)
}
}
在这里我们发现,当testCoroutine
是第一次调用时,使用前面的匿名内部类创建实例,并且把参数传递进去;当不是第一次调用时,就可以复用同一个continuation
,这也就验证了它不像Callback
那样,需要创建多个接口实例。
接着是几个变量:
//3个变量,对应原函数的3个变量
lateinit var user: String
lateinit var friendList: String
lateinit var feedList: String
//result接收挂起函数的运行结果
var result = continuation.result
//suspendReturn表示乖巧函数的返回值
var suspendReturn: Any? = null
//该flag表示当前函数被挂起了
val sFlag = CoroutineSingles.CORTOUINE_SUSPEND
分别表示原函数中临时变量、挂起函数执行的结果,以及是否被挂起的标志。
这里需要记住前面的知识,即当一个挂起函数是真的挂起函数时,它会返回sFlag
,这个在判断流程中非常重要。
在原来的testCoroutine
方法中,我们调用了3次挂起函数,记住我们挂起函数的本质是挂起什么来着?挂起的是后面执行的代码,所以在CPS
后,状态机逻辑会被分为4个部分:
when(continuation.label){
0 -> {
//检测异常
throwOnFailure(result)
//将label设置为1,准备进入下一个状态
continuation.label = 1
//执行getUserInfo
suspendReturn = getUserInfo(continuation)
//判断是否挂起
if (suspendReturn == sFlag){
return suspendReturn
}else{
result = suspendReturn
}
}
1 -> {
throwOnFailure(result)
// 获取 user 值
user = result as String
// 将协程结果存到 continuation 里
continuation.mUser = user
// 准备进入下一个状态
continuation.label = 2
// 执行 getFriendList
suspendReturn = getFriendList(user, continuation)
// 判断是否挂起
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
}
}
2 -> {
throwOnFailure(result)
user = continuation.mUser as String
//获取friendList的值
friendList = result as String
//将挂起函数结果保存到continuation中
continuation.mUser = user
continuation.mFriendList = friendList
//准备进入下一个阶段
continuation.label = 3
//执行获取feedList
suspendReturn = getFeedList(user,friendList,continuation)
//判断是否挂起
if (suspendReturn == sFlag){
return suspendReturn
}else{
result = suspendReturn
}
}
3 -> {
throwOnFailure(result)
user = continuation.mUser as String
friendList = continuation.mFriendList as String
feedList = continuation.result as String
loop = false
}
}
这部分代码理解起来有点困难,我们来一步一步分析走一遍:
- 第一次调用
testCoroutine
时,会给continuation
赋值,为TestContinuation
类型,默认情况下continuaiton
中的label
为0,所以会进入when
的0分支。 - 在
when
的0分支中,会先检查异常,然后将continuaiton
的label
设置为1,然后执行getUserInfo
方法。由于该方法是真挂起函数,所以返回值suspendReturn
和sFlag
一样,这时testCoroutine
会直接被return
,即完成运行。 - 在
getUserInfo
方法中,会模拟网络请求,当获取到网络请求数据后,会调用getUserInfo
的continuation
参数的invokeSuspend(user)
方法,注意该方法的continuation
和前一次调用testCoroutine
的continuation
是同一个。 - 根据
TestContinuation
的定义,这时该continuation
实例中的result
就是获取到的user
信息,然后label
为1,然后开始第二次调用testCoroutine
方法,同时参数依旧是这个continuation
。 - 第二次调用
testCoroutine
时,continuaiton
不会再被创建,这时方法的result
变量会保存user
,会进入when
的1分支里面。 - 在1分支里,方法的
user
变量为获取到的用户信息的String类型,然后将该结果保存到continuation
的mUser
变量中。这时,我们getUserInfo
方法的结果值就保存到了唯一continuation
中,接着label
设置为2,调用getFriendList
方法,同样的该方法是挂起函数,这时第二次调用的testCoroutine
方法被return
掉。 - 在
getFriendList
方法中,一样的逻辑,当获取到好友列表后,会回调唯一continuaiton
的invokeSuspend(friendList)
方法,这时result
为好友列表信息,同时开启第三次调用testCoroutine
方法。 - 第三次调用
testCoroutine
时,会进入2分支,在该分支中,会给唯一continuation
的mFriendList
赋值好友列表信息,然后label
设置为3,调用getFeedList
挂起函数,这时第三次testCoroutine
被return
。 - 在
getFeedList
中,回调唯一continuation
的invokeSuspend(feedList)
方法,这时result
保存的是是动态信息,同时开始调用第四次testCoroutine
方法。 - 在第四次调用
testCoroutine
方法中,会进入3分支,在这里我们可以获取用户信息(保存在continuation
的mUser
字段中)、好友列表信息(保存在continuation
的mFriendList
字段中)和动态信息(保存在result
)字段中。
到这里,一个调用3个挂起函数的挂起函数就分析完了,不得不说,设计的非常巧妙。利用唯一的continuation
来保存挂起函数执行的值,通过多次调用函数自己来分割开来挂起函数。
伪挂起函数CPS
后的执行流程
上面流程如果仔细思考的话,还是有个问题,那就是判断是否是挂起函数,如果是挂起函数我们在continuation
中调用invokeSuspend()
方法可以再次进入testCoroutine()
函数,但是不是挂起函数时,这个如何进入呢?
其实当不是挂起函数时,并不会再次调用testCoroutine()
函数,而是会直接进入when
语句代码,这里其实就是利用goto
语句,而goto
语句在Kotlin已经没有了,所以在Java中类似下面代码来实现跳转:
...
label: whenStart
when (continuation.label) {
0 -> {
...
}
1 -> {
...
suspendReturn = noSuspendFriendList(user, continuation)
if (suspendReturn == sFlag) {
return suspendReturn
} else {
result = suspendReturn
// 让程序跳转到 label 标记的地方
// 从而再执行一次 when 表达式
goto: whenStart
}
}
2 -> {
...
}
3 -> {
...
}
}
所以在反编译后的代码也会有许多label
,就是为了实现这种跳转。
总结
本篇文章介绍了挂起函数的原理,其实通过CPS
转换后,可以发现本质就是一个状态机。通过将调用不同挂起函数的情况,转变为状态机中的不同分支,方法本身不保存挂起函数的结果,而是通过唯一的一个continuation
实例来保存。
通过多次调用方法,进入不同的分支,对于伪挂起函数,进行直接goto
跳转到状态机。
这篇文章学习后,我相信你肯定会有下面疑惑:
- 为什么挂起函数返回的是固定值,如何实现一个挂起函数。
continuation
这个变量是从哪来的,就比如本例中的testCoroutine
经过CPS
转换后的参数是哪来的,有什么特别之处。
这些问题,我们后面继续探究。