协程如何挂起

1 阅读15分钟

KOTLIN COROUTINES · 原理篇;现在发现AI很方便,可以把读书笔记,一股脑丢给他,然后,让他生成一个文章。这篇文章,就是根据我的读书笔记生成的。

协程如何挂起

从 suspendCoroutine、Continuation 到回调桥接与恢复机制

挂起不是让函数消失,也不是让线程停住;它是把协程的下一步保存为 Continuation,释放当前线程,并在结果到达后从原位置继续。

重新整理版 · 含源码拆解、执行流程与工程注意事项

本章目标

本章围绕一个核心问题展开:Kotlin 协程执行到挂起函数时,究竟保存了什么、释放了什么,又如何在稍后继续运行。读完后,你应当能够解释 suspend、suspendCoroutine 和 Continuation 的关系,并能把一个回调式 API 安全地改造成挂起函数。

  • 区分“挂起协程”和“阻塞线程”,避免把 suspend 理解为自动切线程。
  • 理解 suspendCoroutine 的 Lambda 为什么在挂起前执行,以及 Continuation 从哪里获得。
  • 理解 resume(value) 与 resumeWithException(error) 如何分别产生返回值和异常。
  • 掌握使用 suspendCancellableCoroutine 包装旧式回调 API 的基本结构。
  • 理解为什么挂起的是整个协程执行链,而不是某一个孤立的函数。

**一句话模型 **挂起 = 保存继续执行所需的状态 + 让出线程;恢复 = 把结果送回挂起点 + 继续执行后续代码。

1 先建立正确模型:挂起不等于阻塞

挂起函数是 Kotlin 协程最具代表性的能力。一个协程可以在函数执行到一半时暂停,并在将来的某个时刻从同一逻辑位置继续。暂停期间,执行它的线程可以转去处理其他任务。

可以把 Continuation 想成“续跑凭证”:它记录恢复之后应该从哪里继续,以及继续执行所需要的局部状态。不过这个类比只帮助理解控制流,Continuation 在实现上是一个对象,并非完整复制线程或整个进程。

对比项阻塞线程挂起协程
等待期间线程停在那里,不能执行别的任务协程保存状态并让出线程
资源占用持续占用线程栈和调度资源仅保留 Continuation 等少量状态
恢复位置仍在被阻塞的线程上继续可由调度器安排到同一或其他线程
典型 APIThread.sleep、阻塞式 I/Odelay、挂起式网络/数据库 API

**准确表述 **“协程挂起时不消耗资源”并不严谨。它不再占用线程和 CPU 执行时间,但 Continuation、局部变量及上下文仍会占用少量内存。

另一个常见误解是:suspend 会自动把代码切换到后台线程。事实并非如此。suspend 只表示函数具备挂起当前协程的能力;是否真的挂起、在哪个线程恢复,由具体实现和 CoroutineDispatcher 决定。

2 最小实验:只挂起,不恢复

先从最小程序观察挂起点。suspend main 让程序入口本身处于协程中,因此可以直接调用其他挂起函数。

示例 1 协程被挂起后没有恢复

import kotlin.coroutines.suspendCoroutine

suspend fun main() {
    println("Before")

    suspendCoroutine<Unit> { }

    println("After")
}

运行结果只有 Before。原因不是 println 失效,而是当前协程在 suspendCoroutine 处暂停后,再也没有人恢复它。因此 suspendCoroutine 后面的语句没有机会执行,main 也无法正常完成。

  1. main 协程打印 Before。
  2. 调用 suspendCoroutine<Unit>。
  3. 传入的 Lambda 在真正挂起前执行,但 Lambda 是空的,没有保存或恢复 Continuation。
  4. 当前协程停在挂起点;After 位于恢复路径上,因此不会打印。

**关键点 **挂起点之后的代码并没有丢失,而是暂时不可达;只有 Continuation 被恢复,程序才会继续。

3 suspendCoroutine 如何交出 Continuation

suspendCoroutine 接收一个 Lambda。这个 Lambda 会在协程挂起之前被调用,并获得当前挂起点对应的 Continuation。概念化的函数类型可以写成:

概念签名

suspend fun <T> suspendCoroutine(
    block: (Continuation<T>) -> Unit
): T

这里的 T 同时出现在三处:suspendCoroutine 的返回类型、Continuation<T> 的泛型参数,以及 resume 时必须提供的值类型。

挂起表达式恢复调用表达式结果
suspendCoroutine<Unit>resume(Unit)Unit
suspendCoroutine<Int>resume(42)42
suspendCoroutine<String>resume("text")"text"
suspendCoroutine<User>resume(user)User 对象

Lambda 必须在挂起前获得 Continuation,因为一旦 suspendCoroutine 已经挂起,当前协程就不能继续执行后面的代码去寻找恢复方式。通常,Lambda 会把 Continuation 交给定时器、网络回调、系统监听器或其他异步组件。

4 立即恢复:看清 resume 的含义

示例 2 在 suspendCoroutine 内立即恢复

import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

suspend fun main() {
    println("Before")

    suspendCoroutine<Unit> { continuation ->
        continuation.resume(Unit)
    }

    println("After")
}

输出为 Before、After。resume(Unit) 把成功结果送回挂起点,因此 suspendCoroutine<Unit> 完成,控制流继续进入下一条语句。由于恢复发生得非常早,这个例子可能不会表现出可观察的长时间挂起,但它清楚展示了恢复通道。

resume 与 resumeWith 的关系

continuation.resume(value)

// 可以理解为调用统一入口:
continuation.resumeWith(Result.success(value))

现代 Kotlin 的 Continuation 核心入口是 resumeWith(Result<T>)。标准库提供的 resume(value) 和 resumeWithException(error) 是更易读的扩展函数,分别构造成功与失败的 Result。

5 延迟恢复:线程如何被释放和重新使用

为了观察真正的等待,可以让定时调度器在一秒后恢复 Continuation。重点不是 ScheduledExecutorService 本身,而是“登记未来动作后立即挂起”的模式。

示例 3 使用调度器延迟恢复

import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

private val scheduler =
    Executors.newSingleThreadScheduledExecutor { task ->
        Thread(task, "scheduler").apply {
            isDaemon = true
        }
    }

suspend fun waitOneSecond() {
    suspendCoroutine<Unit> { continuation ->
        scheduler.schedule(
            { continuation.resume(Unit) },
            1,
            TimeUnit.SECONDS
        )
    }
}

suspend fun main() {
    println("Before: ${Thread.currentThread().name}")
    waitOneSecond()
    println("After: ${Thread.currentThread().name}")
}

5.1 逐段解释

newSingleThreadScheduledExecutor 创建一个可复用的调度线程。它像“闹钟管理器”一样保存定时任务,而不是每次等待都新建一个线程。isDaemon = true 表示当 JVM 只剩守护线程时,调度线程不会单独阻止程序退出。

waitOneSecond 调用 suspendCoroutine 后,把 Continuation 捕获到 schedule 的 Lambda 中。登记完成后,当前协程挂起;一秒到期,scheduler 线程执行 resume(Unit),协程从 waitOneSecond 返回处继续。

输出:
Before: main

After: scheduler

5.1.1 为什么恢复之后的代码,执行在scheduler?是因为main所在的线程已经结束了吗?

不是因为 main 线程已经结束了,而是因为:

Continuation 在哪个线程上被调用 resume(),没有调度器介入时,后续代码通常就在哪个线程继续执行。

具体流程如下。

scheduler.schedule(
    { continuation.resume(Unit) },
    1,
    TimeUnit.SECONDS
)

ScheduledExecutorService 使用名为 "scheduler" 的线程执行这个 Lambda:

{
    continuation.resume(Unit)
}

因此,一秒后实际调用 resume() 的线程是:

scheduler

当前协程又没有配置 CoroutineDispatcher,所以 resume(Unit) 会直接驱动协程的状态机继续执行。执行顺序可以理解成:

main 线程
    │
    ├── println("Before")
    ├── 调用 waitOneSecond()
    ├── suspendCoroutine 挂起协程
    └── 等待整个 suspend main 完成

一秒后:

scheduler 线程
    │
    ├── continuation.resume(Unit)
    ├── waitOneSecond() 返回
    ├── 从 waitOneSecond() 后面继续
    └── println("After")

所以:

println("After: ${Thread.currentThread().name}")

输出:

After: scheduler
resume() 恢复的是协程,不是原线程

resume() 的含义不是:

找到原来的 main 线程,并叫醒它。

而是:

让协程从保存的挂起点继续执行。

协程的逻辑执行状态保存在 Continuation 中,并不绑定某个固定线程。因此它可以:

  1. 在 main 线程开始;
  2. 在某个位置挂起;
  3. 在 scheduler 线程恢复;
  4. 以后又被调度到其他线程。

线程发生了变化,但仍然是同一个协程的逻辑执行过程。

main 线程发生了什么?

对于 suspend fun main(),Kotlin 会生成一个普通 JVM 入口,用于启动这个挂起函数并等待它完成。

当协程挂起时,入口线程通常还没有真正结束,而是在等待整个 suspend main 完成。等 scheduler 线程恢复协程并执行完 "After" 后,根 Continuation 才会收到完成通知,程序随后退出。

所以真正的原因不是:

main 线程已经结束,所以只能使用 scheduler

而是:

scheduler 调用了 resume
+
协程上下文中没有 Dispatcher 把恢复操作切回 main
使用 runBlocking 会怎样?

可以使用 runBlocking 提供绑定到当前线程的事件循环:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("Before: ${Thread.currentThread().name}")

    waitOneSecond()

    println("After: ${Thread.currentThread().name}")
}

通常输出:

Before: main
After: main

此时,虽然 continuation.resume(Unit) 仍由 scheduler 线程调用,但 runBlocking 的协程上下文包含调度机制,恢复操作会被投递回 main 线程的事件循环。

大致过程是:

scheduler 调用 resume
        ↓
发现 Continuation 带有调度器
        ↓
不直接执行后续代码
        ↓
把恢复任务投递给 main 的事件循环
        ↓
main 线程继续执行
Android 中的情况

如果协程运行在:

viewModelScope.launch(Dispatchers.Main) {
    println(Thread.currentThread().name)

    waitOneSecond()

    println(Thread.currentThread().name)
}

通常前后都会在 Android 主线程:

main
main

因为 Dispatchers.Main 会拦截恢复操作。即使网络线程或定时器线程调用 resume(),后续代码也会被重新调度回主线程。

最终结论:

没有 CoroutineDispatcher 时,调用 resume() 的线程通常直接执行挂起点之后的代码;存在调度器时,恢复会按照协程上下文重新调度。resume() 恢复的是协程,不是某个固定线程。

**线程提示 **在这个低层示例中,恢复后的代码可能直接运行在 scheduler 线程。真实的 kotlinx.coroutines 会结合 ContinuationInterceptor / CoroutineDispatcher,把恢复安排到合适的线程。

5.2 为什么实际代码直接使用 delay

推荐写法

import kotlinx.coroutines.delay

suspend fun main() {
    println("Before")
    delay(1_000)
    println("After")
}

delay 已经提供非阻塞等待,并与协程取消、调度器和测试时间控制集成。手写 ScheduledExecutorService 的价值主要在于理解原理,而不是替代协程库的成熟实现。

6 使用一个值恢复协程

协程经常因为缺少某个结果而挂起。当结果到达时,resume 不只是发出“继续”信号,还可以把数据作为挂起表达式的返回值送回来。

示例 4 恢复时提供返回值

import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

suspend fun main() {
    val number: Int = suspendCoroutine { cont ->
        cont.resume(42)
    }

    val text: String = suspendCoroutine { cont ->
        cont.resume("Some text")
    }

    println(number) // 42
    println(text)   // Some text
}

以 number 为例:编译器从左侧类型和 resume(42) 推断出 T 为 Int。resume(42) 之后,整个 suspendCoroutine 表达式的结果就是 42,所以它可以像普通函数返回值一样赋给 number。

**类型闭环 **suspendCoroutine<T>、Continuation<T>、resume(T) 与最终表达式结果 T 必须保持一致。

7 把回调 API 改造成挂起函数

回调式 API 往往把结果嵌套在 Lambda 中。通过 suspendCancellableCoroutine,可以把“结果到达后执行回调”改写为“在这里挂起,结果到达后从这里返回”,从而得到更线性的业务代码。

示例 5 最基本的回调桥接

data class User(val name: String)

// 旧式回调 API
fun requestUser(callback: (User) -> Unit) {
    // 异步请求完成后:callback(user)
}

// 包装后的挂起 API
suspend fun awaitUser(): User =
    suspendCoroutine { continuation ->
        requestUser { user ->
            continuation.resume(user)
        }
    }

suspend fun main() {
    println("Before")
    val user = awaitUser()
    println(user)
    println("After")
}

7.1 执行链拆解

  1. main 调用 awaitUser,业务代码看起来仍然是顺序结构。
  2. awaitUser 发起异步请求,并把 Continuation 间接交给回调。
  3. 等待网络期间,awaitUser 所在协程挂起,底层线程可以执行其他任务。
  4. 请求成功后,回调获得 User,并调用 continuation.resume(user)。
  5. suspendCoroutine 表达式产生 User,awaitUser 返回,main 从 val user = awaitUser() 之后继续。

**命名建议 **如果回调函数也叫 requestUser,建议把挂起包装命名为 awaitUser、getUser 或 requestUserSuspend,以减少重载阅读成本。

8 生产级桥接:成功、失败与取消

真实 API 不只会成功,也可能失败或被取消。工程代码通常应优先使用 suspendCancellableCoroutine,让协程取消能向下传递到底层请求。

示例 6 可取消的回调桥接

import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

suspend fun awaitUser(): User =
    suspendCancellableCoroutine { continuation ->
        val call = api.requestUser(
            onSuccess = { user ->
                if (continuation.isActive) {
                    continuation.resume(user)
                }
            },
            onError = { error ->
                if (continuation.isActive) {
                    continuation.resumeWithException(error)
                }
            }
        )

        continuation.invokeOnCancellation {
            call.cancel()
        }
    }

8.1 每一部分负责什么

  • onSuccess:用 User 正常恢复,挂起函数返回 User。
  • onError:用异常恢复,调用 awaitUser 的位置表现得像直接抛出了异常。
  • isActive:避免协程已经取消后仍尝试恢复;但它不是所有竞态条件的万能锁。
  • invokeOnCancellation:在上层取消协程时,同步取消底层网络请求,避免无意义工作。

最重要的不变量是:同一个 Continuation 只能完成一次。底层 API 若可能重复回调,包装层必须保证只接受第一个终态,否则重复 resume 可能抛出 IllegalStateException。对于复杂竞态,应遵循所用库的官方桥接方式,而不是仅依赖一次 isActive 检查。

**工程优先级 **Retrofit、Room 等库已经原生支持挂起函数时,应直接使用其 suspend API。只有面对遗留回调、监听器或第三方 SDK 时,才需要手动桥接。

9 使用异常恢复

普通函数可能返回值,也可能抛出异常;挂起表达式保持同样的调用体验。resumeWithException(error) 会让异常从原挂起点重新出现,因此调用者可以用常规 try/catch 处理。

示例 7 异常从挂起点重新抛出

import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

class MyException : RuntimeException("Just an exception")

suspend fun main() {
    try {
        suspendCoroutine<Unit> { cont ->
            cont.resumeWithException(MyException())
        }
    } catch (e: MyException) {
        println("Caught!")
    }
}

这并不是另一个线程直接跳进当前调用栈抛异常。更准确地说,恢复时传入 Result.failure(error),协程状态机在恢复挂起点时检查结果,并在该逻辑位置重新抛出异常。因此 try/catch 可以像处理普通函数异常一样工作。

失败恢复的统一入口

continuation.resumeWithException(error)

// 概念上等价于:
continuation.resumeWith(Result.failure(error))

10 挂起的是协程,不是函数

挂起函数只是一个能够挂起当前协程的函数。真正暂停的是包含完整调用链的协程。如果把 Continuation 保存起来,却把 resume 写在同一个挂起点之后,那么恢复代码永远没有机会执行。

示例 8 同一协程无法执行挂起点之后的自救代码

// 错误示例:不要这样保存全局 Continuation
var saved: Continuation<Unit>? = null

suspend fun suspendAndSave() {
    suspendCoroutine<Unit> { cont ->
        saved = cont
    }
}

suspend fun main() {
    println("Before")
    suspendAndSave()
    saved?.resume(Unit) // 永远执行不到
    println("After")
}

程序只打印 Before。它形成了一个循环依赖:协程必须先恢复,才能执行 saved?.resume(Unit);但这行代码正是恢复协程所需要的动作。

**风险 **把 Continuation 放在全局变量中还可能造成内存泄漏、重复恢复和并发竞态。示例只用于说明控制流,不应作为业务实现。

恢复必须来自挂起协程之外,例如网络回调、定时器、系统事件,或者另一个仍能运行的协程。下面的代码能够运行,但仍只是教学演示:

示例 9 由另一个协程触发恢复(仅作原理演示)

var saved: Continuation<Unit>? = null

suspend fun suspendAndSave() {
    suspendCoroutine<Unit> { cont -> saved = cont }
}

suspend fun main() = coroutineScope {
    println("Before")

    launch {
        delay(1_000)
        saved?.resume(Unit)
        saved = null
    }

    suspendAndSave()
    println("After")
}

11 从底层看:状态机与 Continuation

编译器会把包含挂起点的函数转换为近似状态机。函数开始位置是一个状态,每个可能挂起的调用之后又是一个状态。Continuation 对象保存当前状态编号,以及恢复后仍然需要的局部变量。

源代码视角

suspend fun loadAndShow() {
    val user = awaitUser()      // 挂起点 1
    val news = awaitNews(user)  // 挂起点 2
    show(news)
}

状态机视角(概念化)

// 极度简化的概念伪代码,并非真实反编译结果
fun loadAndShow(cont: LoadAndShowContinuation): Any? {
    when (cont.label) {
        0 -> {
            cont.label = 1
            val result = awaitUser(cont)
            if (result === COROUTINE_SUSPENDED) {
                return COROUTINE_SUSPENDED
            }
            cont.user = result as User
        }

        1 -> {
            cont.user = cont.result.getOrThrow() as User
        }
    }

    cont.label = 2
    val news = awaitNews(cont.user, cont)
    if (news === COROUTINE_SUSPENDED) {
        return COROUTINE_SUSPENDED
    }

    show(news as List<News>)
    return Unit
}

11.1 为什么返回类型看起来像 Any?

底层函数既可能立即得到真实结果,也可能返回内部标记 COROUTINE_SUSPENDED。因此概念上的 JVM 形式需要同时容纳业务结果和挂起标记,常被解释为返回 Any?。日常 Kotlin 源码仍然保持精确的声明类型。

11.2 Continuation 如何形成调用链

被调用挂起函数获得调用者的 Continuation,并在完成时把结果向上恢复。多个 Continuation 因而形成一种可恢复的调用链,承担了传统线程调用栈在挂起期间无法保留的那部分职责。恢复时,结果沿这条链返回;某一层完成后,再继续恢复上一层。

**不要过度类比 **Continuation 不是线程栈的完整序列化副本。它保存的是编译器判定在挂起后仍然需要的状态。协程可以换线程恢复,正是因为逻辑执行状态不再绑死在线程栈上。

12 常见误区与排查清单

  • 误区:suspend 函数一定会挂起。事实:它只是允许挂起,也可能立即返回。
  • 误区:suspend 自动切到后台线程。事实:线程选择由调度器和具体 API 决定。
  • 误区:挂起后完全不占内存。事实:Continuation 和必要状态仍需保留。
  • 误区:只要调用 resume 就安全。事实:同一个 Continuation 只能完成一次。
  • 误区:取消协程会自动取消所有第三方请求。事实:桥接时通常要显式调用底层 cancel。
  • 误区:把 Continuation 存成全局变量即可稍后恢复。事实:这容易泄漏、竞态并破坏结构化并发。

包装回调 API 前的检查

  • 回调是否保证只调用一次?如果不是,如何原子地选择唯一终态?
  • 成功、失败和取消三条路径是否都能完成 Continuation?
  • 协程取消后,能否取消底层网络、数据库或监听任务?
  • 回调可能在哪个线程触发?恢复后是否需要特定 Dispatcher?
  • 底层库是否已经提供官方 suspend 版本?如果有,应优先使用。

13 完整心智模型

把整个过程连起来,可以得到下面这条主线:

  1. 协程在某个挂起函数中需要等待外部结果。
  2. 挂起函数通过 Continuation 表示“结果回来后从哪里继续”。
  3. 它登记回调、定时器或其他异步动作,然后返回挂起标记。
  4. 当前线程不再为这次等待停留,可以执行其他任务。
  5. 外部结果到达后,异步组件调用 resume(value) 或 resumeWithException(error)。
  6. 协程状态机读取保存的状态和局部数据,从挂起点后继续。
  7. 如果有 CoroutineDispatcher,恢复会按照上下文重新调度到合适线程。

**最终结论 **挂起机制把“等待”从占住线程的行为,变成保存少量状态、稍后继续的控制流。这正是协程能够以顺序代码表达异步任务,同时保持高并发效率的基础。

附录 核心 API 速查

核心调用关系

// 不支持取消的底层桥接原语
suspendCoroutine<T> { continuation ->
    // 完成时:continuation.resume(value)
}

// 支持协程取消的常用桥接原语
suspendCancellableCoroutine<T> { continuation ->
    continuation.invokeOnCancellation {
        // 取消底层任务
    }
}

// 成功恢复
continuation.resume(value)

// 失败恢复
continuation.resumeWithException(error)

// 统一底层入口
continuation.resumeWith(Result.success(value))
continuation.resumeWith(Result.failure(error))