协程(15) | 挂起函数原理解析

2,114 阅读12分钟

前言

在前面文章中,我们基本对协程的使用以及概念都有了大致理解和使用,但是这还远远不够,对于一些协程的原理,我们还是需要掌握的,只有这样,我们才可以知其然知其所以然,碰到协程问题或者报错时,可以看懂源码的报错信息。

本篇文章就拿我们最熟悉也最重要的挂起函数来说,关于挂起函数的介绍和使用,可以看文章:# 协程(4) | 挂起函数

在前面文章中,我们说过挂起函数的本质是Callback,它可以在挂起点挂起,然后等待一个合适的时机恢复,从而实现以同步的代码写出异步的效果。

正文

关于挂起函数的原理,如果要深入研究直接看源码,非常难以看懂,这里缺少一个合适的切入点,所以本篇文章我们从CPS转换开始,逐步分析其中的原理。

CPS转换

在前面那篇介绍挂起函数文章中,我们说过从挂起函数转换为CallBack形式的过程,叫做CPS转换(Continuation-Passing-Style Transformation),当然这里的CallbackContinuation,放个动图来加强回忆和理解:

fcf5b8eead81466bb7eacc017f0c1377_tplv-k3u1fbpfcp-zoom-in-crop-mark_1304_0_0_0.webp

在这个动图中,编译器把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 suspendString类型,这也是一种伪挂起。

所以这里我们也就明白了为什么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
    }
}

这部分代码理解起来有点困难,我们来一步一步分析走一遍:

  1. 第一次调用testCoroutine时,会给continuation赋值,为TestContinuation类型,默认情况下continuaiton中的label为0,所以会进入when的0分支。
  2. when的0分支中,会先检查异常,然后将continuaitonlabel设置为1,然后执行getUserInfo方法。由于该方法是真挂起函数,所以返回值suspendReturnsFlag一样,这时testCoroutine会直接被return,即完成运行。
  3. getUserInfo方法中,会模拟网络请求,当获取到网络请求数据后,会调用getUserInfocontinuation参数的invokeSuspend(user)方法,注意该方法的continuation和前一次调用testCoroutinecontinuation是同一个。
  4. 根据TestContinuation的定义,这时该continuation实例中的result就是获取到的user信息,然后label为1,然后开始第二次调用testCoroutine方法,同时参数依旧是这个continuation
  5. 第二次调用testCoroutine时,continuaiton不会再被创建,这时方法的result变量会保存user,会进入when的1分支里面。
  6. 在1分支里,方法的user变量为获取到的用户信息的String类型,然后将该结果保存到continuationmUser变量中。这时,我们getUserInfo方法的结果值就保存到了唯一continuation中,接着label设置为2,调用getFriendList方法,同样的该方法是挂起函数,这时第二次调用的testCoroutine方法被return掉。
  7. getFriendList方法中,一样的逻辑,当获取到好友列表后,会回调唯一continuaitoninvokeSuspend(friendList)方法,这时result为好友列表信息,同时开启第三次调用testCoroutine方法。
  8. 第三次调用testCoroutine时,会进入2分支,在该分支中,会给唯一continuationmFriendList赋值好友列表信息,然后label设置为3,调用getFeedList挂起函数,这时第三次testCoroutinereturn
  9. getFeedList中,回调唯一continuationinvokeSuspend(feedList)方法,这时result保存的是是动态信息,同时开始调用第四次testCoroutine方法。
  10. 在第四次调用testCoroutine方法中,会进入3分支,在这里我们可以获取用户信息(保存在continuationmUser字段中)、好友列表信息(保存在continuationmFriendList字段中)和动态信息(保存在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转换后的参数是哪来的,有什么特别之处。

这些问题,我们后面继续探究。