文档目录

2.7 协程的真实开销模型

上一节:2.6 并发原语成本与伪共享 | 下一节:2.8 JMH 与 kotlinx-benchmark 配套代码:02-jvm-performance-basics/07-coroutines-cost-model


一句话结论

协程把「线程」和「并发任务」解耦了,但它不是免费的、也不是无限的。 Dispatchers.Default 的并行度约等于 CPU 核数——一旦被阻塞调用占满,所有使用它的协程会一起卡住。这是 Kotlin 后端最高频、也最难排查的性能故障。


一、用餐厅服务理解协程

一家餐厅有 8 个服务员(= CPU 核数):

线程模型(传统)

一个服务员负责一桌客人:点单 → 等厨房做菜 → 上菜 → 结账,全程守着这桌。等菜的时候他什么都不干,也不能服务别的桌。

结果:8 个服务员最多同时服务 8 桌。第 9 桌客人要等有人结账。

协程模型

一个服务员可以这样干:给 1 号桌点单 → 把单子交给厨房 → 在等菜的时间里,去 2 号桌点单 → 再去 3 号桌 → 菜好了就上菜。

结果:8 个服务员可以同时服务几百桌,因为他们不「死等」。

这就是协程的核心价值:在等待 IO 的时候,让出线程去处理别的任务。

但协程有一个致命限制

假设某个服务员被派去做一件必须亲自守着的事——比如「站在菜市场等摊主称菜」(这就是阻塞调用)。

他离开了餐厅。现在只剩 7 个服务员在店里。如果 8 个人都被派去菜市场了呢?

餐厅里一个服务员都没有了。所有客人的请求全部卡住。

这就是「阻塞调用污染调度器」,也是本节最重要的一件事。


二、协程本身的成本

2.1 suspend 是怎么实现的

suspend fun fetchUser(id: String): User {
    val cached = cache.get(id)        // 可能挂起
    return cached ?: api.get(id)      // 可能挂起
}

编译器会把上面这个函数改造成一个状态机:

第 1 步:查缓存
  ├─ 命中 → 直接返回
  └─ 未命中 → 保存状态("我在第 1 步"),挂起,返回 COROUTINE_SUSPENDED
第 2 步:被恢复后,从"第 1 步"继续,调 API
  └─ 返回结果

每次挂起都要保存「我在哪一步」,这就是 Continuation 对象。

2.2 成本量级

操作 量级
调用一个不挂起的 suspend 函数 接近普通函数调用(只是多了个参数)
真正挂起 + 恢复 纳秒到数百纳秒
切换线程(withContext(Default → IO)) 微秒级(因为涉及线程调度)

两个推论:

  1. 「不真正挂起」的 suspend 调用几乎不要钱(编译器会优化掉状态机)。
  2. 高频挂起会产生大量 Continuation 对象——这是真实的 GC 压力来源。用分配火焰图可以看到。

不要因为「协程有分配」就不用协程。 协程省下的线程切换成本(微秒级)远大于它带来的分配成本(纳秒级)。要关注的是极端高频挂起的场景。


三、调度器的并行度上限(这是最容易踩的坑)

调度器 并行度 用途
Dispatchers.Default max(2, CPU 核数) CPU 密集任务(计算、序列化)
Dispatchers.IO max(64, CPU 核数) 阻塞 IO(JDBC、文件、同步 HTTP 客户端)
Dispatchers.Main 1(UI 线程) 桌面/Android UI
limitedParallelism(n) 你指定的 n 限制对下游的并发(第 7 章会用到)

关键理解:这两个数字都是「上限」,不是「无限资源」。

// 场景:10 万并发请求,每个都要查数据库
launch(Dispatchers.IO) { jdbcQuery() }   // ← 只有 64 个能真正执行,其余在排队

10 万个协程里,只有 64 个能同时执行 JDBC 查询,其余 99936 个在排队。

表现是什么?

  • CPU 利用率不高(因为都在等)
  • 延迟很高(排队)
  • 线程数看起来正常(64 个左右)
  • 线程 dump 看起来很「闲」 ← 这是最难排查的地方

排查手段:协程快照(DebugProbes.dumpCoroutines())能看到有多少协程挂在调度器上;或者用自定义指标记录队列长度。


四、头号杀手:在错误的调度器上做阻塞调用

// ❌ 灾难:在 Default(只有 8 个线程)上做阻塞调用
suspend fun getOrder(id: String): Order {
    return jdbcTemplate.queryForObject("select ... where id = ?", id)   // 阻塞!
}

// 调用方
launch(Dispatchers.Default) {      // ← 8 个线程
    val order = getOrder(id)       // ← 每个调用占住一个线程,直到查询返回
}

后果:只要有 8 个并发请求同时查数据库,Dispatchers.Default 的 8 个线程就全部被占住。此时:

  • 任何其他使用 Default 的协程(包括 CPU 计算、JSON 序列化、甚至是其他接口的处理)全部排队等待。
  • 表现为偶发性的全局卡顿:所有接口一起变慢,包括那些根本不查数据库的接口。
  • CPU 不高、线程数正常——所有常规指标看起来都正常。

修法:

// ✅ 正确:切到 IO 调度器(允许阻塞的地方)
suspend fun getOrder(id: String): Order = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject("select ... where id = ?", id)
}

// ✅ 更好:用真正的异步驱动(R2DBC 等),根本不阻塞
suspend fun getOrder(id: String): Order = r2dbcClient.query(id).await()

并且还要限制并发(第 7 章):

private val dbLimiter = Semaphore(64)

suspend fun getOrder(id: String): Order = dbLimiter.withPermit {
    withContext(Dispatchers.IO) { jdbcTemplate.queryForObject(...) }
}

五、runBlocking 的边界

// ❌ 在请求处理路径里用 runBlocking
fun handleRequest(): Response = runBlocking {      // 阻塞当前线程!
    fetchData()
}

runBlocking 会阻塞调用它的线程,直到内部协程完成。

允许出现的地方:

  • main 函数
  • 单元测试
  • 与阻塞世界衔接的边界(例如框架回调里必须同步返回的地方,且该回调本身在独立线程池里)

绝不允许:在请求处理路径、协程内部、或任何被高频调用的地方。

替代方案:

// ✅ 如果外层只能是阻塞的,用 runBlocking 但确保它在专门的线程池里
// ✅ 更好:把整个调用链改成 suspend,一路到底

六、Kotlin 特有的五个陷阱

❶ 在 suspend 函数里用 synchronized

// ❌ 锁跨越挂起点:线程被占用,且可能死锁
suspend fun update() {
    synchronized(lock) {
        val data = loadFromDb()      // ← 挂起时仍然持锁!
        updateCache(data)
    }
}

// ✅ 用 Mutex(协程感知的锁)
private val mutex = Mutex()

suspend fun update() = mutex.withLock {
    val data = loadFromDb()
    updateCache(data)
}

为什么危险:synchronized 是线程级的锁,而协程会在不同线程间切换。挂起时持有 monitor,其他协程(可能在别的线程上)无法获取;而且恢复后可能已经换了一个线程,语义混乱。

❷ GlobalScope / 未结构化并发

// ❌ 请求已经返回/超时,这个协程还在跑
GlobalScope.launch { heavyWork() }

// ✅ 绑定到请求的生命周期
suspend fun handle() = coroutineScope {
    launch { heavyWork() }     // 父作用域取消时,它也会被取消
}

后果:资源泄漏(连接未归还、内存持续增长),压测时表现为「QPS 下降但 CPU 不高、内存缓涨」。

❸ Dispatchers.IO 当成无限池用

它有上限(默认 64)。高并发 IO 全部丢进去,超出部分排队——表现为 CPU 不高但延迟猛涨。

❹ Flow 没有背压

// ❌ 无界缓冲:生产者比消费者快时,内存持续增长
flow.buffer(Channel.UNLIMITED)

// ✅ 有界 + 明确的溢出策略
flow.buffer(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)

❺ 在热路径上频繁创建协程

创建协程虽然有开销,但如果每个请求创建几十个协程来做微小的工作(比如每个字段一个协程),调度开销会超过收益。

判断方法:分配火焰图 + 协程调度相关的火焰图帧。


七、本节小结

  1. 协程的价值在于「等待时不占线程」,而不是「让代码跑得更快」。
  2. suspend 编译成状态机 + Continuation;真正挂起才分配对象,成本是纳秒级。
  3. 调度器有并行度上限:Default ≈ CPU 核数,IO 默认 64。它们是上限,不是无限资源。
  4. 在 Default 上做阻塞调用是头号杀手:占满 8 个线程后,所有协程一起卡住,而且常规指标看起来都正常(CPU 不高、线程数正常)。
  5. runBlocking 只允许出现在 main、测试、和阻塞世界的边界。
  6. 五个 Kotlin 陷阱:synchronized 跨越挂起点、GlobalScope、滥用 Dispatchers.IO、无背压 Flow、热路径上频繁创建协程。

八、自测

  1. 一个服务的某个接口偶发性卡顿,卡顿期间所有接口都变慢,但 CPU 只有 30%、线程数正常、GC 正常。请给出最可能的原因和验证方法。
  2. 「协程比线程便宜」——那么「把线程池从 200 改成 8,全部改用协程」会有什么风险?
  3. 为什么在 suspend 函数里用 synchronized 很危险?如果确实需要互斥,应该用什么?两者的区别是什么?
  1. 最可能的原因:某个接口在 Dispatchers.Default 上做了阻塞调用,把默认调度器的线程(≈ CPU 核数,比如 8 个)全部占住,导致所有使用 Default 的协程排队。这完美解释了「CPU 不高、线程数正常、但全局卡顿」。验证方法:① 采集 wall-clock 火焰图(asprof -e wall),会看到大量线程停在阻塞调用栈上(socketRead、jdbc、Thread.sleep);② 用 DebugProbes.dumpCoroutines() 看有多少协程挂在同一个调用点;③ 连续取几份线程 dump,看 Default 的 worker 线程栈顶是否长期停在 IO 调用上;④ 检查代码里有没有 launch(Dispatchers.Default) 或直接在 Default 上下文里执行 JDBC/同步 HTTP。
  2. 风险:① 阻塞调用没有地方去了——如果代码里还有 JDBC、同步 HTTP 客户端、文件 IO,这些在协程里会占住 Default/IO 的有限线程,导致排队甚至全局卡顿(就是上一题的现象);② 并行度上限变成了瓶颈——线程池 200 时能容忍 200 个并发阻塞操作,改成协程后 IO 默认 64,吞吐可能下降;③ 数据库连接池大小需要重新评估(Little’s Law);④ 如果代码里有 ThreadLocal 的使用,协程切换线程会让它失效。正确做法:改协程的同时必须把所有阻塞调用改成异步驱动或明确切到 Dispatchers.IO,并用信号量限制对下游的并发。
  3. 因为 synchronized 是线程级的 monitor 锁,而协程会在不同线程之间切换:① 持有 monitor 期间如果发生挂起,这个线程被阻塞性地占住(monitor 不会释放),其他协程无法进入临界区,可能造成长时间等待甚至死锁;② 协程恢复后可能运行在另一个线程上,synchronized 的重入语义与协程的挂起/恢复语义不匹配,难以推理。应该用 Mutex(kotlinx.coroutines.sync.Mutex):它是协程感知的锁——挂起时不会占住线程(withLock 是 suspend 函数),锁的所有者是协程而不是线程。关键区别:synchronized 的等待是「线程阻塞」,Mutex 的等待是「协程挂起」——后者会释放线程去做别的事。