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?