1. 挂起函数的工作原理

协程的内部实现使用了 Kotlin 编译器的一些编译技术,当挂起函数调用时,背后大致细节如下:

挂起函数或挂起 lambda 表达式调用时,都有一个隐式的参数额外传入,这个参数是Continuation类型,封装了协程恢复后的执行的代码逻辑。

用前文中的一个挂起函数为例:

suspend createPost(token, item): Post

实际上在 JVM 中更像下面这样:

Object createPost(Token token, Item item, Continuation<Post> con)

Continuation的定义如下,类似于一个通用的回调接口:

android kotlin 协程 返回 kotlin协程 原理_Kotlin

挂起函数转换为普通函数后会多加一个 Continuation 接口参数, 函数执行完成后回调 Continuation.

为了提高性能, 减少对象分配次数, 把多个回调的实现合并为一个, 即状态机对象的Continuation:

suspend fun requestToken(): Token { ... }   // 挂起函数
suspend fun createPost(token: Token, item: Item): Post { ... }  // 挂起函数
fun processPost(post: Post) { ... }

fun postItem(item: Item) {
    GlobalScope.launch {
        val token = requestToken()
        val post = createPost(token, item)
        processPost(post)
    }
}

然而,协程内部实现不是使用普通回调的形式,而是使用状态机来处理不同的挂起点,大致的 CPS(Continuation Passing Style) 代码为:

fun postItem(item: Item, cont: Continuation) {
 
   // 区分上层传过来的 Continuation 与我们自己的 Continuation
  val sm = cont as? ThisSM ?: object : ThisSM {
      fun resumeWith(...) {
          // 回调回来还是调用 postItem() 方法, 并且传入this, 重用回调对象, 并且可以保存状态机的状态.
          postItem(null, this)
      }
  }
 
  switch (sm.label) {
      case 0:
          sm.item = item
          sm.label = 1 // label 表示下一步的标记, 而不是当前步!
          requestToken(sm)
          break;
      case 1:
          val item = sm.item
          val token = sm.result as String;
          sm.label = 2
          createPost(token, item, sm)
          break;
      case 2:
          val post = sm.result as String;
          processPost(post)
          sm.cont.resumeWith(null)  // 最后一步执行完了, 要回调上层的 Continuation
          break;
  }
}

上面代码中每一个挂起点和初始挂起点对应的 Continuation 都会转化为一种状态,协程恢复只是跳转到下一种状态中。挂起函数将执行过程分为多个 Continuation 片段,并且利用状态机的方式保证各个片段是顺序执行的。

android kotlin 协程 返回 kotlin协程 原理_状态机_02

挂起函数使用 CPS style 的代码来挂起协程,保证挂起点后面的代码只能在挂起函数执行完后才能执行,所以挂起函数保证了协程内的顺序执行顺序。

在多个协程的情况下,挂起函数的作用更加明显:

fun postItem(item: Item) {
    GlobalScope.launch {
        // async { requestToken() } 新建一个协程,可能在另一个线程运行
        // 但是 await() 是挂起函数,当前协程执行逻辑卡在第一个分支,第一种状态,当 async 的协程执行完后恢复当前协程,才会切换到下一个分支
        val token = async { requestToken() }.await()
        // 在第二个分支状态中,又新建一个协程,使用 await 挂起函数将之后代码作为 Continuation 放倒下一个分支状态,直到 async 协程执行完
        val post = aync { createPost(token, item) }.await()
        // 最后一个分支状态,直接在当前协程处理
        processPost(post)
    }
}

上面的例子中,await()挂起函数挂起当前协程,直到异步协程完成执行,但是这里并没有阻塞线程,是使用状态机的控制逻辑来实现。而且挂起函数可以保证挂起点之后的代码一定在挂起点前代码执行完成后才会执行,挂起函数保证顺序执行,所以异步逻辑也可以用顺序的代码顺序来编写。

注意挂起函数不一定会挂起协程,如果相关调用的结果已经可用,库可以决定继续进行而不挂起,例如async { requestToken() }的返回值Deferred的结果已经可用时,await()挂起函数可以直接返回结果,不用再挂起协程。