一个 suspend 函数,为什么会变成一个普通函数?
最近在研究 Kotlin 协程源码的时候,我做了一件以前一直没有认真做过的事情------把一个 suspend 函数反编译成 Java。
结果发现,原来一个看起来很普通的 suspend 函数,经过 Kotlin 编译器之后,已经完全变成了另外一种样子。
例如下面这个函数:
kotlin
suspend fun getUser(id: Int): User {
delay(1000)
return User(
id = id,
name = "User$id",
email = "user$id@example.com"
)
}
如果反编译成 Java,可以看到函数签名已经变成了这样(省略了一些无关代码):
less
@Nullable
public static final Object getUser(
int id,
@NotNull Continuation<? super User> continuation
) {
...
}
看到这里,我的第一个疑问就是:
明明返回值是
User,为什么变成了Object?
Continuation又是什么?为什么多了一个参数?
后来把协程源码一路追下去,才发现这两个变化其实就是 Kotlin 协程实现的核心。
suspend 并不是 JVM 的能力
很多人刚接触协程的时候,会觉得 suspend 是 Kotlin 发明的一种新的函数类型。
实际上并不是。
JVM 根本不知道什么叫 suspend。
对于 JVM 来说,它认识的还是普通的方法,例如:
java
Object getUser(int id, Continuation continuation)
也就是说,所谓的 suspend,只是 Kotlin 编译器提供的一层语法糖。
真正运行的时候,所有 suspend 函数都会被编译器转换成普通函数。
为什么返回值变成了 Object?
最开始我一直觉得,既然函数返回的是 User,为什么不直接写成:
java
User getUser(int id, Continuation continuation)
后来想明白以后,发现其实不能这样设计。
因为 suspend 函数有两种完全不同的执行结果。
第一种,没有发生挂起。
例如:
kotlin
suspend fun getUser(id: Int): User {
return User(...)
}
这种情况下,函数可以直接返回 User。
但是另一种情况:
kotlin
suspend fun getUser(id: Int): User {
delay(1000)
return User(...)
}
执行到 delay() 的时候,函数必须立即退出,把线程让出来。
这时候它根本没有 User 可以返回。
于是 Kotlin 定义了一个特殊对象:
COROUTINE_SUSPENDED
它表示:
"我不是执行结束了,而是挂起了。"
因此,一个 suspend 函数实际上可能返回两种东西:
sql
User
或者
COROUTINE_SUSPENDED
由于 Java 没有联合类型(Union Type),编译器只能把返回值统一改成:
css
Object
这样既可以返回真正的结果,也可以返回 COROUTINE_SUSPENDED。
Continuation 是干什么的?
再来看第二个变化。
函数多了一个参数:
swift
Continuation<? super User> continuation
第一次看到这里的时候,我最大的疑问就是:
为什么一个普通函数需要传一个 Continuation?
后来才意识到,这个对象其实就是协程恢复执行所需要的一切信息。
可以把它理解成:
suspend 函数执行到一半时的"现场"。
例如:
kotlin
suspend fun getUser(id: Int): User {
val realId = id + 1
delay(1000)
return User(realId, ...)
}
如果 delay() 挂起了一秒钟,那么一秒以后恢复的时候,需要知道很多事情:
- 当前执行到了哪一行?
realId的值是多少?id的值是多少?- 后面应该继续执行哪段代码?
这些信息都保存在 Continuation 中。
后面我们会看到,Kotlin 编译器实际上会为每一个 suspend 函数生成一个对应的 ContinuationImpl,专门负责保存这些状态。
为什么普通函数不需要 Continuation?
普通函数只有一种执行方式:
kotlin
开始
↓
一直执行
↓
return
执行结束以后,调用栈自然就销毁了。
局部变量也跟着释放。
但是 suspend 函数不一样。
它可以变成:
kotlin
开始
↓
执行一半
↓
挂起
↓
几秒以后恢复
↓
继续执行
↓
return
问题来了。
函数早就已经退出了。
调用栈也没有了。
那恢复的时候怎么知道之前执行到哪里了?
答案就是:
编译器把原本保存在调用栈里的信息,全部搬到了 Continuation 对象里面。
也正因为如此,协程才能做到"挂起以后还能继续执行"。
为什么 Java 也能调用 suspend 函数?
知道 suspend 最终只是一个普通函数以后,就会发现:
理论上 Java 当然也可以调用。
例如:
scss
getUser(1, continuation);
只是这个 Continuation 需要自己实现。
还要处理恢复、异常、结果传递等一系列逻辑。
所以虽然可以调用,但基本没有人会这么写。
通常还是交给 Kotlin 编译器自动生成这些代码。
小结
很多文章都会说:
suspend 不会阻塞线程。
这句话没有错,但它并没有解释 suspend 是怎么做到的。
真正理解协程的第一步,其实应该是接受一个事实:
suspend 函数在 JVM 上并不是一种新的函数,它最终仍然会被编译成一个普通函数。
只不过编译器悄悄帮我们做了两件事情:
- 把返回值改成了
Object,用于同时表示"执行完成"和"协程挂起"。 - 增加了一个
Continuation参数,用来保存协程恢复所需要的全部状态。
理解了这两点,再去看后面的反编译代码、状态机、ContinuationImpl,很多之前看起来神秘的地方都会变得顺理成章。
下一篇,我们继续聊一个更有意思的话题:
Kotlin 编译器是如何把一个 suspend 函数,变成状态机的?