【Kotlin 协程修仙录 · 炼气境 · 巅峰】 | 原理熔炉:挂起函数到底对线程做了什么?

0 阅读11分钟

image_11.png

引言

你已经学会了启动协程、取消协程、划定协程疆域。你写出了优雅的异步代码,UI 丝滑流畅,再也没有 ANR 的困扰。

但内心深处,有一个问题如鲠在喉:

delay(2000) 的那两秒钟,线程到底在干什么?编译器凭什么能让一段代码暂停、再恢复?我写的 suspend 关键字,最后变成了什么东西?

这并非钻牛角尖。不理解挂起的本质,你就无法真正信任协程。当堆栈追踪里出现陌生的 Continuation 时,你会手足无措;当同事问起“为什么挂起函数不阻塞线程”时,你只能含糊其辞。

今天,我们将踏入炼气境的终极关卡——原理熔炉。这不是 API 的浮光掠影,而是一场深入编译器和运行时底层的硬核内视。你将亲眼看到:

  • 你写的每一个 suspend fun,在编译后被切片成了什么模样。
  • 那台叫做 CPS(Continuation Passing Style) 的编译器机器是如何工作的。
  • 为什么 delay 可以让出线程,而 Thread.sleep 不行。

系好安全带。我们要潜入字节码层,直视挂起函数的“源代码”。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


先看现象:挂起时线程到底在做什么?

在深入原理之前,我们用一段可观测的代码,再次确认那个核心现象。

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("🧵 主线程:${Thread.currentThread().name}")

    val job = launch(Dispatchers.Default) {
        println("🚀 协程启动,线程:${Thread.currentThread().name}")
        delay(2000)
        println("✅ 协程恢复,线程:${Thread.currentThread().name}")
    }

    // 在 delay 期间,主线程并没有被阻塞,继续执行
    println("📢 主线程继续执行其他任务...")
    delay(500)
    println("📢 主线程还在跑...")
    delay(500)
    println("📢 主线程依然活跃...")

    job.join()
    println("🏁 全部完成")
}

运行这段代码,你会发现:

  • 协程启动时在某一线程(如 DefaultDispatcher-worker-1)。
  • delay(2000) 期间,主线程继续执行,打印了三行日志。
  • 2 秒后,协程恢复,可能运行在另一个线程上(也可能是同一个)。

关键事实:delay 期间,执行协程的那个线程被释放了。 它没有被阻塞在 delay 内部,而是返回了线程池,去执行其他任务。2 秒后,系统通过回调唤醒协程,调度器再为它分配一个线程继续执行。

sequenceDiagram
    participant Caller as 🧵 调用方线程
    participant Coroutine as ⚡ 协程
    participant Dispatcher as 🎛️ 调度器
    participant Timer as ⏲️ 系统定时器

    rect rgb(227, 242, 253)
        Caller->>Coroutine: 调用挂起函数
        Coroutine->>Dispatcher: 请求挂起
        Dispatcher->>Timer: 注册定时器
        Dispatcher-->>Caller: 立即返回 COROUTINE_SUSPENDED
        Note over Caller: 线程被释放,去执行其他任务
    end
    
    rect rgb(255, 243, 224)
        Timer-->>Dispatcher: 2秒后,回调触发
        Dispatcher->>Dispatcher: 将续体包装成任务
        Dispatcher->>Caller: 任务放入线程队列
        Caller->>Coroutine: 恢复执行,从挂起点之后继续
    end

现在,我们要追问:编译器是如何实现这种“暂停-恢复”能力的?


CPS 续体传球:用接力赛理解编译器的魔法

一个通俗的类比

想象你正在参加一场 4×100 米接力赛。

  • 传统函数调用:就像一个人跑完全程。他起跑、冲刺、到达终点。如果他中途需要喝水(阻塞),整个赛道就被他堵死了。
  • 挂起函数:就像一场接力赛。第一棒选手跑到中途某个点(挂起点),把接力棒(续体 Continuation)交给下一棒,然后自己退出赛道(释放线程)。第二棒选手接过棒子,从那个点继续跑。

Kotlin 编译器做的事情,就是把你的挂起函数切分成若干段,每一段对应接力赛的一棒。这个“接力棒”,在字节码层面叫做 Continuation

什么是 CPS

CPS(Continuation Passing Style,续体传递风格) 是一种编程范式:函数不通过 return 返回值,而是将结果传递给一个回调函数(Continuation)。Kotlin 协程的挂起函数在编译后,会被自动转换为 CPS 风格。

在 CPS 的世界里,一个挂起函数 suspend fun foo(): String 编译后的签名大致等价于:

// 编译后的伪代码
Object foo(Continuation<? super String> completion);
  • 多出来的 Continuation 参数,就是那个“接力棒”。
  • 返回的 Object,要么是真正的结果 "Hello",要么是一个特殊的标记 COROUTINE_SUSPENDED,表示“我还没跑完,线程你先拿去用”。
flowchart LR
    subgraph 源码层["📝 你写的 Kotlin 代码"]
        SF["suspend fun fetchData(): String"]
    end
    
    subgraph 编译层["⚙️ 编译器转换 CPS"]
        CF1["Object fetchData(Continuation c)"]
        CF2["内部生成状态机"]
    end
    
    subgraph 运行时["🔄 运行时行为"]
        RT1["挂起时返回 COROUTINE_SUSPENDED"]
        RT2["恢复时调用 c.resumeWith(result)"]
    end
    
    SF --> CF1
    CF1 --> CF2
    CF2 --> RT1
    CF2 --> RT2
    
    style 源码层 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style 编译层 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style 运行时 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style SF fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style CF1 fill:#ffb74d,stroke:#e65100,stroke-width:2px
    style CF2 fill:#ffb74d,stroke:#e65100,stroke-width:2px
    style RT1 fill:#90caf9,stroke:#1565c0,stroke-width:2px
    style RT2 fill:#90caf9,stroke:#1565c0,stroke-width:2px

状态机:协程的“剧本”

CPS 只是改变了函数签名。真正实现“暂停-恢复”的,是编译器为每个挂起函数生成的状态机

极简挂起函数的反编译

我们准备一个最简单的挂起函数:

suspend fun simpleDelay(): String {
    delay(1000)
    return "Done"
}

编译后,再用工具反编译成 Java 字节码(或直接看 Kotlin 编译器生成的 IR),它的逻辑大致等价于以下伪代码:

// 编译器生成的状态机伪代码(简化版)
class SimpleDelayStateMachine(
    private val completion: Continuation<String>
) : Continuation<Unit> {
    
    // 状态标签:记录执行到哪一步了
    var label = 0
    
    // 保存局部变量(如果有的话)
    lateinit var result: String
    
    override fun resumeWith(data: Result<Unit>) {
        // 被唤醒时,继续执行状态机
        invokeSuspend()
    }
    
    fun invokeSuspend(): Any? {
        when (label) {
            0 -> {
                // 第一幕:初始化
                label = 1
                val delayResult = delay(1000, this)
                if (delayResult == COROUTINE_SUSPENDED) {
                    return COROUTINE_SUSPENDED // 挂起!线程释放
                }
            }
            1 -> {
                // 第二幕:从挂起点恢复,直接执行后面的代码
                // 注意:这里不会再次调用 delay
            }
        }
        // 最终结果
        return "Done"
    }
}

状态机执行流程

stateDiagram-v2
    [*] --> 首次调用
    首次调用 --> 执行初始化
    执行初始化 --> 设置label为1
    设置label为1 --> 调用delay函数
    调用delay函数 --> 挂起判断
    
    挂起判断 --> 返回Suspended : 返回COROUTINE_SUSPENDED
    返回Suspended --> 等待唤醒 : 线程释放
    
    挂起判断 --> 立即完成 : 不挂起直接返回
    
    等待唤醒 --> 系统回调
    系统回调 --> 恢复执行label1
    恢复执行label1 --> 跳过delay
    跳过delay --> 返回结果
    
    立即完成 --> 返回结果
    返回结果 --> [*]

这张图揭示了挂起函数最核心的秘密:delay 的调用实际上被分成了两次。第一次调用发生在 label=0 时,它启动了定时器并返回 COROUTINE_SUSPENDED。第二次“调用”(恢复)直接从 label=1 开始,跳过了 delay 的函数体,直接执行后续的 return "Done"

这就是为什么协程能在挂起点“暂停”,然后在同一位置“恢复”——状态机的 label 变量记住了进度。


字节码爆破:直视 Continuation 的真身

如果你还觉得不够过瘾,我们来看一段真实的 Java 字节码反编译结果。

准备工作

将以下 Kotlin 代码编译成 class 文件:

// Test.kt
suspend fun fetchUser(): String {
    delay(1000)
    return "User"
}

使用 javap -c -p TestKt.class 查看字节码,你会看到类似以下的输出(已简化并添加注释):

public final class TestKt {
    public static final java.lang.Object fetchUser(
        kotlin.coroutines.Continuation<? super java.lang.String> completion
    );
    Code:
        // 检查是否已经有状态机,如果没有则创建
        // ...
        
        // 状态机的 switch 分支
        tableswitch { // switch on label
            0: L0
            1: L1
            default: ...
        }
        
    L0:
        // label=0 时的代码
        // 设置 label = 1
        // 调用 delay(1000, this)
        // 检查返回值是否为 COROUTINE_SUSPENDED
        
    L1:
        // label=1 时的代码
        // 直接跳到返回 "User"
}

Continuation 接口

Continuation 是 Kotlin 标准库中定义的接口,它是整个挂起机制的核心:

public interface Continuation<in T> {
    // 恢复执行所需的上下文
    public val context: CoroutineContext
    
    // 恢复协程,传入结果(成功或异常)
    public fun resumeWith(result: Result<T>)
}

当你调用 delay(1000) 时,编译器实际上调用了 delay(1000, this),其中 this 就是当前协程的状态机对象(它实现了 Continuation)。delay 函数内部会:

  1. 向系统注册一个定时器,回调时调用 continuation.resumeWith(Result.success(Unit))
  2. 返回 COROUTINE_SUSPENDED

这样,协程的控制权就归还给了调度器,线程得以释放。

deepseek_mermaid_20260425_3a5d9b.png

为何挂起不阻塞线程:调度器的角色

CPS 和状态机解释了“暂停-恢复”的逻辑,但还有一个问题:挂起期间,协程的状态保存在哪里?谁来负责唤醒它?

答案是:调度器(Dispatcher续体(Continuation

当你调用 delay 时:

  1. delay 函数捕获当前的 Continuation(即状态机对象)。
  2. 它向底层定时器注册一个回调。
  3. 它向调度器返回 COROUTINE_SUSPENDED
  4. 调度器收到这个特殊标记后,知道协程暂时不需要线程了,于是把它从当前线程的执行队列中移除,转而去执行其他等待中的协程。
  5. 2 秒后,定时器回调触发,调度器将那个 Continuation 重新放入某个线程的执行队列。
  6. 当线程轮到这个任务时,调用 Continuation.resumeWith(Result.success(Unit)),状态机从 label=1 继续执行。

整个过程中,线程从未被“卡住”。它一直在调度器的指挥下,马不停蹄地处理各个协程的任务片段。

sequenceDiagram
    participant Thread as 🧵 工作线程
    participant Dispatcher as 🎛️ 调度器
    participant StateMachine as 📟 状态机(Continuation)
    participant Delay as ⏲️ delay函数

    rect rgb(232, 245, 233)
        Thread->>StateMachine: 执行 invokeSuspend (label=0)
        StateMachine->>Delay: delay(1000, this)
        Delay->>Delay: 注册系统定时器
        Delay-->>StateMachine: 返回 COROUTINE_SUSPENDED
        StateMachine-->>Dispatcher: 返回 COROUTINE_SUSPENDED
        Dispatcher->>Thread: 线程释放,去执行其他协程
    end
    
    Note over Thread: 线程处理其他任务...
    
    rect rgb(255, 243, 224)
        Delay-->>Dispatcher: 定时器回调
        Dispatcher->>Dispatcher: 将 Continuation 放入队列
        Dispatcher->>Thread: 分配 Continuation 任务
        Thread->>StateMachine: resumeWith(Result.success)
        StateMachine->>StateMachine: invokeSuspend (label=1)
        StateMachine->>Thread: 返回 "Done"
    end

传统方案对比:协程的轻量从何而来?

理解了挂起的原理,我们就能真正理解为什么协程比线程“轻量”。

对比维度线程 (Thread)协程 (Coroutine)
挂起/阻塞机制Thread.sleep 让线程进入休眠状态,操作系统层面阻塞,线程无法做任何事。delay 返回 COROUTINE_SUSPENDED线程被释放去执行其他协程。
状态保存线程栈(通常 1MB 左右)。状态机对象(通常几十到几百字节)。
上下文切换操作系统内核态切换,成本高。用户态调度,由调度器在 JVM 层面完成,成本极低。
并发数量受限于内存和 OS 限制,数千个线程已是极限。可以轻松创建数十万个协程。

这就是为什么协程被称为“轻量级线程”。它不是真正的线程,而是跑在线程上的任务片段。挂起机制让它能够在线程上协作式地让出执行权,而不是被操作系统抢占式地剥夺

graph TD
    subgraph ThreadModel["🐌 传统线程模型"]
        T1[线程1 - 1MB 栈]
        T2[线程2 - 1MB 栈]
        T3[线程3 - 1MB 栈]
    end
    
    ThreadModel -->|操作系统调度<br>上下文切换昂贵| OS[操作系统内核]
    
    style ThreadModel fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style T1 fill:#ef9a9a,stroke:#c62828
    style T2 fill:#ef9a9a,stroke:#c62828
    style T3 fill:#ef9a9a,stroke:#c62828
    style OS fill:#ffb74d,stroke:#e65100
graph TD
    subgraph CoroutineModel["🚀 协程模型"]
        C1[协程1 - 几百字节]
        C2[协程2 - 几百字节]
        C3[协程3 - 几百字节]
        C4[协程4 - 几百字节]
        C5[协程5 - 几百字节]
        Thread[底层线程池 2-4 个线程]
    end
    
    CoroutineModel -->|用户态调度<br>上下文切换极轻| JVM[JVM 用户空间]
    
    style CoroutineModel fill:#c8e6c9,stroke:#1b5e20,stroke-width:2px
    style C1 fill:#a5d6a7,stroke:#2e7d32
    style C2 fill:#a5d6a7,stroke:#2e7d32
    style C3 fill:#a5d6a7,stroke:#2e7d32
    style C4 fill:#a5d6a7,stroke:#2e7d32
    style C5 fill:#a5d6a7,stroke:#2e7d32
    style Thread fill:#81c784,stroke:#1b5e20,stroke-width:2px
    style JVM fill:#64b5f6,stroke:#1565c0

常见误区与避坑指南

误区 1:认为 suspend 关键字本身就能让函数异步

// ❌ 这个函数虽然是 suspend,但它没有挂起点,不会释放线程
suspend fun calculate(): Int {
    var result = 0
    for (i in 1..1000000) {
        result += i
    }
    return result
}

suspend 只是允许函数被挂起,但真正的挂起发生在调用另一个挂起函数(如 delay)并且它返回 COROUTINE_SUSPENDED 时。纯计算的 suspend 函数会一直占用线程直到完成。

误区 2:认为挂起函数一定运行在子线程

// ✅ 这段代码运行在主线程,但不会阻塞主线程
lifecycleScope.launch {
    delay(1000)      // 挂起,主线程被释放
    updateUI()       // 恢复后仍在主线程执行
}

挂起函数本身不决定运行在哪个线程。调度器(Dispatcher 决定协程运行在哪个线程。delay 只是暂停协程,恢复时调度器会将它放回原来的线程(或指定的线程)。

误区 3:混淆挂起与阻塞

很多初学者以为 delayThread.sleep 的替代品,功能一样只是更“高级”。但两者的本质完全不同:

  • Thread.sleep阻塞,线程进入休眠,CPU 时间片被让出,但线程依然被占用。
  • delay挂起,协程状态机保存现场,线程归还线程池,可以立刻处理其他任务。

最佳实践:写出可被取消的挂起函数

基于对原理的理解,编写自定义挂起函数时有两个黄金法则:

  1. 在循环或长计算中,主动检查 isActive 或调用 yield()

    suspend fun longComputation(): Int {
        var result = 0
        for (i in 1..1000000) {
            // 每 1000 次迭代检查一次取消状态
            if (i % 1000 == 0) yield()
            result += i
        }
        return result
    }
    
  2. 使用 suspendCancellableCoroutine 将回调式 API 转换为挂起函数

    suspend fun LocationManager.awaitCurrentLocation(): Location =
        suspendCancellableCoroutine { continuation ->
            val listener = LocationListener { location ->
                continuation.resume(location)
            }
            requestLocationUpdates(provider, 0, 0f, listener)
            
            continuation.invokeOnCancellation {
                removeUpdates(listener) // 取消时清理资源
            }
        }
    

总结与下回预告

恭喜,你成功渡过了炼气境的原理天劫

本讲核心收获

  • 挂起函数的本质是编译器自动生成的状态机,通过 label 变量记录执行进度。
  • CPS 续体传递是编译器使用的变换技术,每个挂起函数都被加了一个 Continuation 参数。
  • 挂起时,协程向调度器返回 COROUTINE_SUSPENDED,线程被释放去执行其他协程;恢复时,调度器通过回调调用 Continuation.resumeWith
  • 协程的轻量级来源于用户态调度极小状态保存

现在,你已经拥有了看懂任何协程堆栈的理论基础。那些曾经让你困惑的 ContinuationinvokeSuspend,再也不会成为拦路虎。

但还有一个巨大的问题悬而未决:

当我们在 viewModelScope 中启动多个协程,其中一个子协程发生了未捕获的异常,会发生什么?是只有它自己崩溃,还是整个 Scope 都会跟着遭殃?supervisorScope 又是如何隔离子协程异常的?

这正是协程的异常处理与监督机制,也是我们下一境——筑基境·初阶——要啃下的硬骨头。我们将深入 Job 的父子树结构,彻底理解结构化并发的取消传播异常传播双法则。

准备好踏入筑基境了吗?


【当前境界修为面板】

  • 当前境界[炼气境 · 巅峰]炼气境大圆满
  • 下一突破[筑基境 · 初阶] (需领悟:结构化并发的根本大法——父子 Job 树、取消传播、异常传播、supervisorScope)
  • 修炼进度[████████░░░░░░░░░░░░] 44%
  • 本讲获得法器状态机内视术CPS 续体真解调度器底层窥探镜

【本讲思考题】

1、表象题:以下挂起函数编译后,状态机有几个 label 分支?

suspend fun example() {
    delay(100)
    delay(200)
    delay(300)
}

2、场景题:你需要在协程中调用一个不支持取消的阻塞式第三方 SDK 方法(如 Socket.read())。如何让这个协程能够响应外部的 cancel() 调用?

3、原理题COROUTINE_SUSPENDED 是一个单例对象。为什么协程调度器能通过判断返回值是否等于它,来决定是否释放线程?如果挂起函数返回了其他值,意味着什么?


道友,炼气境已全部通关。你在协程的世界里已经不再是懵懂稚童,而是能够内视丹田、感知挂起的修炼者。下一讲,我们将正式踏入筑基境,去领悟那道让协程“形散神聚”的根本大法——结构化并发

我们筑基境见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论