卷 II · CH 06 · 深度 06/18

suspend 不是后台线程

它只表示函数可以暂停并在稍后恢复;到底在哪个线程执行,取决于协程上下文和函数内部。

CONTINUATIONDISPATCHERMAIN-SAFE
▷ 先答一下

下面函数从 viewModelScope.launch 调用,会不会自动离开主线程?

suspend fun hash(bytes: ByteArray): String =
    sha256(bytes) // 同步 CPU 计算
  1. 会,所有 suspend 函数都在后台
  2. 会,因为返回值稍后才得到
  3. 不会;没有挂起点,也没有切换 Dispatcher
  4. 取决于函数名

暂停的是计算,不是线程

编译器会把 suspend 函数改写成一个能保存「执行到哪里、局部变量是什么」的状态机,并额外接收 Continuation。遇到真正挂起的调用,它先把状态保存后返回,让当前线程去做别的;结果到达时,再从下一个状态恢复。

因此 delay 不占着线程睡觉,而 Thread.sleep 会。一个 suspend 函数也可以从头到尾不挂起,照样把调用线程卡住。suspend 的承诺是「可暂停」,不是「并行」「后台」或「更快」。

可以带走的判断:先问调用会不会阻塞线程,再问它是不是 suspend;这两个问题正交。

Dispatcher 是执行政策

Dispatchers.Main 面向 UI;IO 面向会阻塞线程的网络、文件与数据库调用;Default 面向吃 CPU 的计算。它们不是性能咒语:现代 Retrofit 的 suspend 适配与 Room suspend DAO 已经是主线程安全的,没必要在调用处再套一层 withContext(IO)

更好的责任划分是:底层函数对自己的阻塞负责。Repository 暴露主线程安全的 suspend API;ViewModel 直接调用,不必知道底下是同步 SDK、Room 还是 Retrofit。若某个旧 SDK 会阻塞,由包装它的 data source 切 Dispatcher。

class AttachmentHasher(
    private val cpu: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun hash(bytes: ByteArray): String = withContext(cpu) {
        sha256(bytes)
    }
}

注入 Dispatcher 的价值主要是测试:生产用真实线程池,测试用受控调度器。不要把 Dispatchers.IO 散落在每个调用者里,否则线程政策既重复又难替换。

launch 与 async 不是「要不要并发」

launch 返回 Job,适合「只关心完成或取消」的工作;async 返回 Deferred<T>,适合有结果、而且确实要与别的工作并发。顺序执行两个 suspend 函数时不需要 async。并发也只在任务独立时成立,第二步依赖第一步结果就不能硬并。

追问准备「网络请求放 IO 吗?」先看库的合同:同步阻塞调用需要 IO;Retrofit 的 suspend 调用内部异步,不需调用者切 IO。回答责任边界,比统一说「网络都放 IO」更准确。
⌨ 自己跑一遍

同一条线程先经历非阻塞延迟,再经历阻塞睡眠;输出顺序相同,但后者占住线程:

A before delay
B while A is suspended
A after delay
C before sleep
C after sleep
D could not run during sleep
import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        println("A before delay")
        delay(50)
        println("A after delay")
    }
    launch { println("B while A is suspended") }
    delay(80)

    launch {
        println("C before sleep")
        Thread.sleep(50)
        println("C after sleep")
    }
    launch { println("D could not run during sleep") }
}

在 Android Studio Kotlin/JVM scratch 中运行,并加入 kotlinx-coroutines-core。再给第二组 launch(Dispatchers.Default),观察「阻塞线程」和「阻塞整个单线程上下文」的区别。

▸ 在现实里

给临床照片算哈希、压缩或做图像处理属于 CPU 工作;读取内容 URI 可能阻塞;上传若用现代异步客户端则可挂起而不占线程。三者都叫「附件处理」,调度策略却不同。用职责封装它们,UI 只看到一个主线程安全的用例。

✗ 这个直觉是错的

「函数标了 suspend,就不可能造成 ANR。」同步循环、阻塞 I/O、锁等待都能在 suspend 函数里卡住主线程。

suspend 描述控制流能力;Dispatcher 描述执行位置;库的实现决定是否阻塞。三者分别判断。

◇ 面试收口

答案是第 3 项。sha256 是同步 CPU 计算,函数没有挂起点,也没有切到 Default,所以会在调用它的主线程执行。正确修复在拥有这段阻塞工作的底层封装里切换 Dispatcher,并让上层 API 保持主线程安全。

这一章的一句话

suspend 决定能不能停下来,Dispatcher 决定在哪里继续,二者都不保证代码本身不阻塞。

下一章把单个协程变成一棵树:一个附件上传失败时,另外两个要不要取消?两种答案只差一个 supervisorScope,业务语义却完全相反。