一个 suspend 函数,为什么会变成一个普通函数?

一个 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 函数,变成状态机的?

相关推荐
苦瓜花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
唐青枫5 天前
别再把 Util 类堆成杂物间:Kotlin Extension 扩展函数与扩展属性实战详解
kotlin