Kotlin 编译器是如何把 suspend 函数变成状态机的?

Kotlin 编译器是如何把 suspend 函数变成状态机的?

上一篇我们知道了,一个 suspend 函数最终会被编译成一个普通函数:

java 复制代码
Object getUser(int id, Continuation<? super User> continuation)

但新的问题又来了。

如果一个函数中有多个挂起点,它恢复的时候为什么知道该从哪里继续执行?

例如下面这个函数:

kotlin 复制代码
suspend fun getUserInfo(id: Int): User {
    val user = getUser(id)
    val avatar = getAvatar(id)
    val token = getToken(id)

    return user.copy(
        avatar = avatar,
        token = token
    )
}

它一共有三个挂起点:

scss 复制代码
① getUser()

② getAvatar()

③ getToken()

假设第一次执行到 getAvatar() 时挂起了,那么一秒以后恢复时,它怎么知道:

"我已经执行完 getUser() 了,应该从 getAvatar() 后面继续,而不是重新从第一行开始执行。"

答案就是今天的主角------状态机(State Machine)


看看反编译后的代码

把上面的代码反编译以后(省略无关代码),会看到这样一段:

ini 复制代码
switch (continuation.label) {
    case 0:
        ...
        continuation.label = 1;
        if (getUser(id, continuation) == COROUTINE_SUSPENDED) {
            return COROUTINE_SUSPENDED;
        }

    case 1:
        ...
        continuation.label = 2;
        if (getAvatar(id, continuation) == COROUTINE_SUSPENDED) {
            return COROUTINE_SUSPENDED;
        }

    case 2:
        ...
        continuation.label = 3;
        if (getToken(id, continuation) == COROUTINE_SUSPENDED) {
            return COROUTINE_SUSPENDED;
        }

    case 3:
        ...
        return user.copy(...);
}

第一次看到这段代码的时候,我最大的疑问就是:

这不是一个 switch 吗?

后来才意识到:

整个 suspend 函数已经被编译器改造成了一台状态机。


什么叫状态机?

其实状态机没有那么神秘。

可以把上面的代码画成这样:

scss 复制代码
┌─────────┐
│ state 0 │
└────┬────┘
     │
     ▼
 getUser()
     │
     ▼
┌─────────┐
│ state 1 │
└────┬────┘
     │
     ▼
getAvatar()
     │
     ▼
┌─────────┐
│ state 2 │
└────┬────┘
     │
     ▼
 getToken()
     │
     ▼
┌─────────┐
│ state 3 │
└────┬────┘
     │
     ▼
 return

每经过一个挂起点,就进入下一个状态。

恢复的时候,只需要知道当前状态是多少,就知道该从哪里继续执行。


label 就是当前状态

反编译代码里有一个成员变量:

arduino 复制代码
int label;

它就是状态机当前所在的位置。

第一次进入函数时:

ini 复制代码
label = 0

于是:

scss 复制代码
switch(label)

进入:

arduino 复制代码
case 0

执行:

ini 复制代码
val user = getUser(id)

但是在调用之前,编译器会先偷偷做一件事:

ini 复制代码
continuation.label = 1;

也就是说:

如果这里发生挂起,那么下一次恢复时,就直接进入 case 1

所以真正的执行顺序其实变成了:

arduino 复制代码
case 0

↓

label = 1

↓

调用 getUser()

↓

如果挂起

↓

return COROUTINE_SUSPENDED

为什么要提前修改 label?

这里是我第一次看反编译代码时最疑惑的地方。

按正常思维,应该是:

ini 复制代码
getUser()

↓

执行成功

↓

label = 1

为什么 Kotlin 偏偏反过来?

原因很简单。

如果 getUser() 真的挂起了,那么:

ini 复制代码
label = 1

这一行将永远不会执行。

因为函数已经提前返回了:

kotlin 复制代码
return COROUTINE_SUSPENDED;

因此,编译器必须在调用 suspend 函数之前,就把下一步状态保存好。

这样恢复的时候,才能知道应该继续执行哪里。


为什么恢复以后不会重新执行 getUser()?

很多人第一次看状态机都会有这个疑问。

恢复时,整个函数不是重新调用了吗?

例如:

bash 复制代码
return getUserInfo(id, continuation);

如果重新进入函数,那岂不是又会执行:

ini 复制代码
val user = getUser(id)

实际上不会。

因为这时候:

ini 复制代码
label = 1

所以:

scss 复制代码
switch(label)

直接跳到了:

arduino 复制代码
case 1

于是:

scss 复制代码
getUser()

这一段代码根本不会再次执行。

恢复后继续执行的是:

ini 复制代码
val avatar = getAvatar(id)

这就是状态机真正发挥作用的地方。


那 user 去哪里了?

还有一个问题。

既然整个函数重新执行了一遍,那之前已经得到的:

ini 复制代码
val user = ...

去哪了?

答案是:

局部变量已经不再保存在栈上,而是保存在 Continuation 对象里面。

反编译代码中经常会看到这样的成员:

javascript 复制代码
Object L$0;
Object L$1;

int I$0;
int I$1;

它们分别表示:

  • L:引用类型(Object)
  • I:int 类型

例如:

ini 复制代码
val user = getUser(id)

恢复之前,编译器会先保存:

ini 复制代码
continuation.L$0 = user;

恢复以后:

ini 复制代码
user = (User) continuation.L$0;

于是整个函数看起来就像从来没有中断过一样。


小结

理解状态机以后,会发现协程其实没有想象中那么神秘。

编译器只是把一个顺序执行的函数,拆成了多个状态。

每遇到一个挂起点,就提前记录好下一步要执行的位置:

scss 复制代码
case 0

↓

getUser()

↓

case 1

↓

getAvatar()

↓

case 2

↓

getToken()

↓

case 3

↓

return

label,就是这台状态机的"当前位置"。

下一篇,我们继续讨论一个我当时研究反编译代码时困扰了很久的问题:

Continuation 到底是谁创建的?为什么每个 suspend 函数里都会出现一个 $continuation

相关推荐
Android打工仔1 小时前
一个 suspend 函数,为什么会变成一个普通函数?
kotlin
苦瓜花21 小时前
【Kotlin】初入门
android·开发语言·kotlin
Kapaseker1 天前
Rust 是如何处理异常的
rust·kotlin
冬日砥砺前行1 天前
Kotlin 协程:几个曾经被我误解的点
kotlin
plainGeekDev3 天前
libs 目录 → Gradle 依赖管理
android·java·kotlin
监督者修3 天前
从零构建 GIS 数据引擎:方案驱动架构的设计与实践
android·架构·kotlin
urkay-4 天前
Kotlin Flow分类
android·开发语言·kotlin
alexhilton5 天前
Kotlin DSL深度解析:从Gradle脚本到构建你自己的DSL
android·kotlin·android jetpack
Kapaseker5 天前
Android 开放这扇门,正在被 Google 从里面反锁
android·kotlin