一次讲清楚 Kotlin 的 suspend 关键字到底做了什么?

1,222 阅读5分钟

作为一名写了多年 ExecutorService 和 Handler的老兵,我第一次理解 suspend 的原理时,感觉是豁然开朗。

简单来说:

suspend 关键字是一个编译器指令。它本身不会创建线程或挂起线程。它的唯一作用是告诉编译器:“这个函数是一个‘可挂起函数’,请你用**状态机(State Machine)**的方式来改写它的字节码。”

协程的“挂起”是非阻塞式挂起,它挂起的是协程本身(一个对象) ,而不是线程(Thread)


1. suspend 关键字到底做了什么?

当你在一个函数前加上 suspend,编译器会在编译期对这个函数做两件主要的事情:

  1. 修改函数签名:  在函数的最后一个参数位置,增加一个隐藏的 Continuation<T> 类型的参数
  2. 修改函数体(CPS 转换):  将函数的代码体转换成一个状态机。

什么是 Continuation

你可以把 Continuation 理解为一个“回调接口”,它代表了“挂起点之后要继续执行的代码”。

它的核心定义(简化后)如下:

Kotlin

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}
  • resumeWith(result): 这就是“恢复”协程的入口。当挂起的计算完成后(无论成功还是失败),就会调用这个方法。

所以,一个你写的 suspend 函数:

Kotlin

suspend fun getUserProfile(id: String): Profile

在编译器处理后,它的签名在 JVM 字节码层面“看起来”是这样的:

Java

// 这是 JVM 字节码层面的样子
Object getUserProfile(String id, Continuation<Profile> continuation)

注意,返回值变成了 Object。这是因为:

  • 如果函数没有挂起(比如数据在缓存中,直接返回了),它就直接返回 Profile 对象。
  • 如果函数需要挂起(比如发起网络请求),它会返回一个特殊的标记值:COROUTINE_SUSPENDED

2. 编译器(CPS 转换)如何实现挂起和恢复?

这就是最精妙的状态机(State Machine)转换,也叫Continuation-Passing Style (CPS) 转换

编译器会把你的 suspend 函数体,变成一个实现了 Continuation 接口的类的 invokeSuspend 方法(通常是一个 SuspendLambda 子类)。这个类会保存函数执行所需的所有状态。

我们用一个例子来看:

假设你写了这样的代码,在某个项目中请求网络信息并更新 UI:

Kotlin

// 你的 KOTLIN 代码
suspend fun fetchStationAndShow(id: String) {
    // 挂起点 1
    val station = api.fetchStationDetails(id) 
    
    // 更新 UI(假设在 Main 线程)
    view.showStation(station) 
    
    // 挂起点 2
    val status = api.fetchStationStatus(id) 
    
    // 更新 UI
    view.showStatus(status)
}

编译器生成的“状态机”(伪代码):

编译器会生成一个类似下面这样的类来“执行”这个函数体:

Java

class FetchStationAndShowContinuation(Continuation<Unit> completion) 
    extends SuspendLambda 
    implements Continuation<Unit> {

    // === 状态机需要保存的“局部变量” ===
    int label = 0; // "label" 就是状态机的“状态”
    String id;     // 保存参数
    Object result; // 保存上一步的恢复结果
    Object station; // 保存局部变量

    // 构造函数
    FetchStationAndShowContinuation(String id, Continuation<Unit> completion) {
        this.id = id;
        this.completion = completion;
        // ...
    }

    // === "恢复"的入口 ===
    // 所有的逻辑都在这里
    @Override
    public final Object invokeSuspend(Object result) {
        this.result = result; // 接收 resumeWith 传来的结果
        Object coroutine_suspended = COROUTINE_SUSPENDED;

        // 使用 "goto" 风格的循环和 switch 来模拟状态跳转
        while (true) {
            switch (label) {
                case 0:
                    // === 函数开始执行 ===
                    // 检查异常 (this.result.getOrThrow()) ...

                    // 准备调用第一个挂起点
                    this.label = 1; // 设置下一次恢复的状态为 1

                    // "this" (Continuation) 作为回调传入
                    Object stationResult = api.fetchStationDetails(id, this); 
                    
                    if (stationResult == coroutine_suspended) {
                        return coroutine_suspended; // <<<<<< 1. 真正的挂起
                    }
                    // 如果没挂起,就带着结果继续执行
                    this.result = stationResult;
                    // (goto case 1)

                case 1:
                    // === 从第一个挂起点恢复 ===
                    this.station = this.result; // 保存结果
                    
                    // 执行非挂起代码
                    view.showStation((Station) this.station); 
                    
                    // 准备调用第二个挂起点
                    this.label = 2; // 设置下一次恢复的状态为 2

                    Object statusResult = api.fetchStationStatus(id, this);
                    
                    if (statusResult == coroutine_suspended) {
                        return coroutine_suspended; // <<<<<< 2. 再次挂起
                    }
                    // 如果没挂起,就带着结果继续执行
                    this.result = statusResult;
                    // (goto case 2)

                case 2:
                    // === 从第二个挂起点恢复 ===
                    Status status = (Status) this.result;
                    
                    // 执行非挂起代码
                    view.showStatus(status);
                    
                    // === 函数执行完毕 ===
                    return Unit.INSTANCE; // 正常返回
            }
        }
    }
}

挂起 (Suspend) 的过程:

  1. 代码执行到 case 0,调用 api.fetchStationDetails(id, this)this 就是那个 Continuation 对象。
  2. fetchStationDetails 内部(比如在 withContext(Dispatchers.IO) 中)发起网络请求。
  3. fetchStationDetails 立即返回 COROUTINE_SUSPENDED
  4. invokeSuspend 方法看到这个标记,也立即 return COROUTINE_SUSPENDED
  5. 此时,fetchStationAndShow 这个调用栈就返回了,执行它的线程(比如 Main 线程)被释放,可以去干别的事(比如刷新 UI)。
  6. 那个 FetchStationAndShowContinuation 对象(label=1)被 Dispatchers.IO 持有,等待网络结果。

恢复 (Resume) 的过程:

  1. 几百毫秒后,网络请求在 IO 线程上返回了 station 数据。
  2. Dispatchers.IO(或 OkHttp 的回调)会调用 continuation.resumeWith(Result.success(station))
  3. 这个 continuation 对象被交回给它“应该”在的 Dispatcher(比如 Dispatchers.Main)。
  4. Main 线程的 Looper 最终会执行 continuation.invokeSuspend(station)
  5. invokeSuspend 被调用,label 此时是 1this.result 是 station 数据。
  6. switch 语句直接跳到 case 1:,代码从上次离开的地方无缝地继续执行

3. 它和线程池是什么关系?

suspend 关键字(CPS 转换)和线程池没有直接关系

  • suspend 是一种编译器技术,用于生成状态机。
  • 线程池(Thread Pool)  是一种运行时资源,用于执行代码。

CoroutineDispatcher 才是它俩的“粘合剂”

Dispatcher(调度器)的核心职责就是:决定 Continuation 的 resumeWith 方法在哪个线程(或线程池)上被调用

  • Dispatchers.IO:它内部持有一个线程池。当你 resume 一个被 IO 调度器挂起的协程时,它会从池子里拿一个线程来执行 continuation.invokeSuspend()

  • Dispatchers.Main:它内部持有主线程的 Handler。当你 resume 一个被 Main 调度器挂起的协程时,它会 post 一个 Runnable 到主线程的 Looper 队列,这个 Runnable 会去调用 continuation.invokeSuspend()

  • Dispatchers.Default:它内部持有

    一个计算密集型的线程池(通常与 CPU 核心数相同)。

总结:

在我早期的广告 SDK 项目中,为了优化广告加载,我们大量使用了 ExecutorService 来管理线程,并通过 Handler 或 EventBus 将结果切回主线程。这个过程非常繁琐,需要手动管理回调、生命周期和线程安全。

协程(suspend + Dispatcher)把这一切自动化了:

  1. suspend:编译器把我们的代码逻辑“切片”成一个个“状态”(case 0case 1case 2...)。
  2. Dispatcher:在不同“状态”切换时,Dispatcher 负责把这些“代码片”扔到正确的线程池(IODefault)或主线程(Main)上去执行。

这就是协程“轻量”的原因:我们只创建了一个很小的 Continuation 对象(状态机)来排队,而不是创建或阻塞一个昂贵的 Thread