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)) |
微秒级(因为涉及线程调度) |
两个推论:
- 「不真正挂起」的 suspend 调用几乎不要钱(编译器会优化掉状态机)。
- 高频挂起会产生大量
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)
❺ 在热路径上频繁创建协程
创建协程虽然有开销,但如果每个请求创建几十个协程来做微小的工作(比如每个字段一个协程),调度开销会超过收益。
判断方法:分配火焰图 + 协程调度相关的火焰图帧。
七、本节小结
- 协程的价值在于「等待时不占线程」,而不是「让代码跑得更快」。
suspend编译成状态机 + Continuation;真正挂起才分配对象,成本是纳秒级。- 调度器有并行度上限:
Default≈ CPU 核数,IO默认 64。它们是上限,不是无限资源。 - 在
Default上做阻塞调用是头号杀手:占满 8 个线程后,所有协程一起卡住,而且常规指标看起来都正常(CPU 不高、线程数正常)。 runBlocking只允许出现在main、测试、和阻塞世界的边界。- 五个 Kotlin 陷阱:
synchronized跨越挂起点、GlobalScope、滥用Dispatchers.IO、无背压Flow、热路径上频繁创建协程。
八、自测
- 一个服务的某个接口偶发性卡顿,卡顿期间所有接口都变慢,但 CPU 只有 30%、线程数正常、GC 正常。请给出最可能的原因和验证方法。
- 「协程比线程便宜」——那么「把线程池从 200 改成 8,全部改用协程」会有什么风险?
- 为什么在
suspend函数里用synchronized很危险?如果确实需要互斥,应该用什么?两者的区别是什么?
- 最可能的原因:某个接口在
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。 - 风险:① 阻塞调用没有地方去了——如果代码里还有 JDBC、同步 HTTP 客户端、文件 IO,这些在协程里会占住
Default/IO的有限线程,导致排队甚至全局卡顿(就是上一题的现象);② 并行度上限变成了瓶颈——线程池 200 时能容忍 200 个并发阻塞操作,改成协程后IO默认 64,吞吐可能下降;③ 数据库连接池大小需要重新评估(Little’s Law);④ 如果代码里有ThreadLocal的使用,协程切换线程会让它失效。正确做法:改协程的同时必须把所有阻塞调用改成异步驱动或明确切到Dispatchers.IO,并用信号量限制对下游的并发。 - 因为
synchronized是线程级的 monitor 锁,而协程会在不同线程之间切换:① 持有 monitor 期间如果发生挂起,这个线程被阻塞性地占住(monitor 不会释放),其他协程无法进入临界区,可能造成长时间等待甚至死锁;② 协程恢复后可能运行在另一个线程上,synchronized的重入语义与协程的挂起/恢复语义不匹配,难以推理。应该用Mutex(kotlinx.coroutines.sync.Mutex):它是协程感知的锁——挂起时不会占住线程(withLock是 suspend 函数),锁的所有者是协程而不是线程。关键区别:synchronized的等待是「线程阻塞」,Mutex的等待是「协程挂起」——后者会释放线程去做别的事。