Kotlin 协程源码解析(四)Continuation 的链式关系 —— 一个 suspend 函数如何把结果交还给调用者?

75 阅读6分钟

# Kotlin 协程源码解析(四)Continuation 的链式关系——一个 suspend 函数如何把结果交给调用者?

Kotlin 协程源码解析系列目录

在上一篇文章中,我们已经知道了 suspend 函数为什么能够挂起。

编译器会把 suspend 函数转换成状态机,并通过 Continuation 保存暂停之后继续执行所需要的信息。

例如:

suspend fun loadData() {
    val user = getUser()
    println(user)
}

我们可以把它粗略理解成:

fun loadData(continuation: Continuation<...>)

于是一个新的问题出现了:

如果一个 suspend 函数调用了另一个 suspend 函数,那么这两个函数之间的 Continuation 到底是什么关系?

例如:

suspend fun loadData() {
    val user = getUser()
    println(user)
}

loadData() 有自己的 Continuation。

getUser() 也会接收到一个 Continuation。

那么:

  • getUser() 的 Continuation 是谁?
  • getUser() 完成以后结果交给谁?
  • loadData() 又是怎么继续执行的?
  • 这些 Continuation 是不是彼此独立的?

理解这些问题,我们才能真正理解协程是如何“从一个挂起点继续执行”的。


一、suspend 函数不是协程

首先需要澄清一个非常容易产生的误解。

看到:

suspend fun getUser()

我们很容易认为:

getUser() 是一个协程。

实际上并不是。

suspend 函数只是一个可以挂起的函数

真正创建协程的是:

launch {
    ...
}

或者:

async {
    ...
}

例如:

launch {

    loadData()

}

这里才创建了一个协程。

之后的执行关系更接近:

launch 创建协程
        |
        v
    loadData()
        |
        v
     getUser()

loadData()getUser() 并没有分别创建两个独立的协程。

它们只是同一个协程执行过程中的不同函数。

这一点非常重要。

如果把每个 suspend 函数都理解成一个独立协程,后面理解 Continuation 时就很容易走偏。


二、Continuation 不只是“保存暂停位置”

我们之前已经知道:

Continuation 用来保存协程挂起之后继续执行所需要的信息。

这个理解是正确的,但还不够完整。

Continuation 还有一个非常重要的属性:

它知道当前执行完成以后应该通知谁。

这个“谁”,就是 completion

Kotlin 协程内部的 BaseContinuationImpl 中有一个非常重要的字段:

private val completion: Continuation<Any?>

于是可以把一个 Continuation 粗略理解成:

Continuation
    |
    +-- 当前状态
    |
    +-- 当前执行上下文
    |
    +-- completion
            |
            v
        上一级 Continuation

这意味着:

Continuation 之间不是孤立存在的,它们可以连接起来。


三、一个 suspend 调用会形成 Continuation 链

来看一个简单的例子:

suspend fun loadData() {

    val user = getUser()

    println(user)
}

假设:

suspend fun getUser(): User {
    return requestUser()
}

执行 loadData() 时,我们可以粗略理解为:

loadData 状态机
        |
        v
loadData Continuation

loadData() 调用 getUser() 时,需要有一个 Continuation 来描述 getUser() 当前的执行状态。

于是形成:

getUser Continuation
        |
        | completion
        v
loadData Continuation

注意这里的方向。

getUser 的 Continuation 并不是一个孤立的对象。

它知道:

“当我完成以后,我应该把结果交给 loadData 的 Continuation。”

这就是 completion 的意义。


四、为什么 getUser 完成以后,结果能够回到 loadData?

这是整个问题最核心的地方。

假设:

suspend fun loadData() {

    val user = getUser()

    println(user)
}

执行到:

val user = getUser()

时,loadData 暂时无法继续,因为 getUser() 还没有产生结果。

于是可以形成:

loadData Continuation
        ^
        |
   completion
        |
        |
getUser Continuation

getUser 可能因为网络请求而挂起。

未来网络请求完成以后:

getUser Continuation
        |
        v
   resumeWith(result)

于是 getUser 的状态机继续执行。

如果 getUser 最终执行完成,它就需要把自己的结果交给:

completion

也就是:

getUser Continuation
        |
        | completion
        v
loadData Continuation

于是整个过程可以表示成:

getUser.resumeWith(result)
        |
        v
getUser 状态机继续执行
        |
        v
getUser 执行完成
        |
        v
completion.resumeWith(result)
        |
        v
loadData 状态机继续执行
        |
        v
println(user)

所以:

suspend 函数之间不是通过普通的 return 把结果一层层返回,而是通过 Continuation 的 completion 链,把恢复结果交给调用者。


五、Continuation 链其实很像被搬到堆上的调用栈

普通函数调用:

fun A() {
    B()
}

fun B() {
    C()
}

fun C() {
}

执行过程中,线程调用栈大概是:

A()
└── B()
    └── C()

C() 执行完成以后,线程栈天然知道:

C → B → A

因为调用者就在栈上。

但是协程不能依赖这种机制。

假设:

suspend fun A() {
    B()
}

执行到 B() 时挂起。

这时候线程可能直接返回线程池。

原来的线程栈就没有了。

未来恢复时甚至可能已经换了一个线程。

因此协程不能依赖:

线程调用栈

来保存:

“我是被谁调用的?”

它必须自己保存。

于是出现了:

Continuation A
        ^
        |
        |
Continuation B
        ^
        |
        |
Continuation C

这可以理解成:

Continuation 在堆上构建了一条类似调用栈的关系。

区别在于:

普通函数依赖:

线程栈

协程依赖:

Continuation 对象之间的关系

这也是协程能够挂起以后释放线程的重要基础。


六、为什么这不是三个协程?

现在再回头看:

Continuation A
Continuation B
Continuation C

很容易产生另一个误解:

既然有三个 Continuation,是不是有三个协程?

不是。

它们只是同一个协程中,不同 suspend 调用层级对应的状态机对象。

例如:

一个协程
    |
    +-- A 状态机
    |
    +-- B 状态机
    |
    +-- C 状态机

它们通过 completion 建立关系:

C
|
completion
v
B
|
completion
v
A

所以:

Continuation 的数量和协程的数量并不是一回事。

一个协程执行过程中完全可能存在多个 Continuation。


七、结果是如何一级一级传回去的?

假设有:

suspend fun A() {
    B()
}

suspend fun B() {
    C()
}

suspend fun C(): String {
    return "Hello"
}

可以抽象成:

C Continuation
        |
        | completion
        v
B Continuation
        |
        | completion
        v
A Continuation

C 完成:

C.resumeWith("Hello")

然后:

C 状态机执行完成
        |
        v
C.completion.resumeWith("Hello")
        |
        v
B 状态机继续

如果 B 也完成:

B.completion.resumeWith("Hello")
        |
        v
A 状态机继续

于是:

C
↓
B
↓
A

结果就这样沿着 Continuation 链向上传递。


八、这也解释了为什么状态机可以恢复到正确的位置

假设:

suspend fun A() {

    val result = B()

    println(result)
}

第一次执行:

A 状态机
    |
    v
调用 B()
    |
    v
保存状态
    |
    v
挂起

编译器生成的状态机可以粗略理解成:

label = 0

调用 B()

保存必要的局部变量
label = 1

挂起

未来恢复:

label = 1

拿到 B 的结果

继续执行:

println(result)

所以恢复并不是:

CPU 回到了原来暂停的那条指令。

而是:

重新执行状态机,根据保存的状态决定下一步应该执行什么。

Continuation 负责保存“怎么继续”。

状态机负责真正执行“继续做什么”。


九、现在可以回答最开始的问题了

我们最开始的问题是:

为什么一个 suspend 函数创建了自己的 Continuation,却还能把结果返回给调用它的 suspend 函数?

答案是:

因为这些 Continuation 并不是互相独立的。

它们通过 completion 形成了一条链。

例如:

getUser Continuation
        |
        | completion
        v
loadData Continuation

getUser 完成以后:

getUser Continuation
        |
        v
completion.resumeWith(result)
        |
        v
loadData Continuation
        |
        v
loadData 状态机继续执行

所以真正发生的不是传统意义上的:

return user

而是:

子状态机完成
    ↓
通知 completion
    ↓
上一级状态机恢复
    ↓
继续执行

这就是 Continuation 链。


十、但是还有一个问题没有解决

到这里,我们终于理解了:

Continuation 如何把结果交给调用者。

但是一个更加基础的问题仍然没有回答:

Kotlin 协程运行时究竟是怎么遍历这条 Continuation 链的?

下一篇文章我们将深入源码探索这个问题。