前言
时光飞逝,距离上次整理Kotlin面试题,一晃两年过去了,恍恍惚惚,已经隔世。这期间,依旧能看到不少同学阅读、收藏笔者之前整理的Kotlin面试题目,能够帮到大家还是挺开心的,授人以鱼不如授人以渔。转眼又快要进入金三银四了,不少人(当然包括笔者)也开始考虑换个环境,重新投入到面试准备中。已经2026年了,随着协程在实际项目中的广泛使用,面试中对Kotlin协程的考察也在不断升级——从最初的 API 使用,逐渐深入到运行机制、调度模型,以及异常与取消等边界问题。
很多时候,这些问题本身并不算多复杂,但一旦被追问“为什么”,答案就容易变得模糊。如今市场早已不缺能熟练调用API的工程师了,再加上 AI 的兴起,现在越发成熟,甚至于日常工作都离不开AI的帮助,可想而知技术门槛又被进一步抬高。生活不易,想要混口饭吃,把基础打牢的同时,还得兼顾当下技术的浪潮,避免一问三不知。所谓知其然,更要知其所以然。
因此,笔者在原有内容的基础上,结合这段时间的实践经验,并参考现阶段网络资料与 AI 的整理,梳理了 **三十多道 Kotlin 协程进阶问题。**由于整体篇幅解答起来实在太过冗长,所以分为了几个篇幅。这些问题尽量覆盖了面试中高频出现、却又容易被忽略的关键点,希望通过问题本身,帮助自己,也帮助大家,在协程相关的考察中,真正做到心中有数。
以下是笔者的Kotlin面试指南系列:
下面开始我们的第二篇章,是关于协程中调度器的几个问题
一、Dispatchers.Default 的并行度是如何计算的?为什么是这个策略?
快问快答
Dispatchers.Default的并行度默认等于 CPU 核心数,最少是 2。这个策略是为了让 CPU 密集型任务能"物尽其用",不多不少,刚好把所有核心都跑满。
娓娓道来
说白了,我们知道协程是基于线程池的,Dispatchers.Default其实就是相当于一个"计算型线程池"。它的设计初衷其实很简单:CPU 有几个核心,就开几个线程,让每个核心都有活干,但又不会因为线程太多而瞎折腾。
让我们来撇一眼源码,看看是怎么个事:
// kotlinx.coroutines源码简化
internal object DefaultScheduler : SchedulerCoroutineDispatcher(
CORE_POOL_SIZE, MAX_POOL_SIZE,
IDLE_WORKER_KEEP_ALIVE_NS, DEFAULT_SCHEDULER_NAME
) {
//实际计算并行度的地方
@ExperimentalCoroutinesApi
override fun limitedParallelism(parallelism: Int): CoroutineDispatcher {
//核心逻辑在这里
parallelism.checkParallelism()
if (parallelism >= CORE_POOL_SIZE) return this
return super.limitedParallelism(parallelism)
}
}
//最少两个,最多CPU核心数
@JvmField
internal val CORE_POOL_SIZE = systemProp(
"kotlinx.coroutines.scheduler.core.pool.size",
AVAILABLE_PROCESSORS.coerceAtLeast(2),
minValue = CoroutineScheduler.MIN_SUPPORTED_POOL_SIZE
)
有同学就有疑问了,为啥是 CPU 核心数?这就要说到一个老生常谈的概念了:CPU 密集型任务的黄金法则。
现在思考一下下,假设你的 CPU 有 8 个核心,如果你开了 16 个线程来跑纯计算任务,会发生什么?
当然是8 个线程在跑,另外 8 个在排队等着。等啥呢?等 CPU 时间片,到点就上班,天生牛马圣体。CPU 就那 8 个核,多出来的线程除了增加上下文切换开销,毛用没有。反过来,如果你只开了 4 个线程,那 CPU 就有 4 个核搁这儿摸鱼呢,资源就白白浪费掉了。
所以,核心数 = 线程数,这才是 CPU 密集型任务的完美平衡点。
OK,让我们来Looklook下面的代码:
fun main() = runBlocking {
println("CPU 核心数: ${Runtime.getRuntime().availableProcessors()}")
// 启动一堆协程,看看默认调度器怎么分配
repeat(20) { i ->
launch(Dispatchers.Default) {
println("协程 $i 运行在线程: ${Thread.currentThread().name}")
}
}
delay(1000)
}
// 输出(假设是 8 核 CPU):
// CPU 核心数: 8
// 协程 0 运行在线程: DefaultDispatcher-worker-1
// 协程 1 运行在线程: DefaultDispatcher-worker-2
// 协程 2 运行在线程: DefaultDispatcher-worker-3
// ...
// 协程 7 运行在线程: DefaultDispatcher-worker-8
// 协程 8 运行在线程: DefaultDispatcher-worker-1 // 又回到 1 了,说明只有 8 个工作线程
容易踩坑的地方
坑点一:觉得 Default 调度器线程越多越好
很多人看到任务堆积,第一反应是"加线程"。但Default调度器设计的就是 CPU 密集型场景,加线程只会让情况更糟——上下文切换变多,CPU cache命中率下降,性能不升反降。
坑点二:在 Default 调度器里搞网络请求
Default是给 CPU 吃饭用的,你非得让它去干IO的活儿,就像让程序员去送外卖做跑腿一样,不是说干不了,是白白浪费了人家的专业特长。网络请求还是老老实实用 Dispatchers.IO 吧。
坑点三:认为可以强行手动修改 Default 的并行度
当然,确实可以通过系统属性进行修改:kotlinx.coroutines.scheduler.core.pool.size,但除非你真的知道自己在干嘛,否则别动它。这是框架调优好的默认值,乱改就是给自己挖坑。
实际项目案例:推荐系统线程池配置事故
背景
我们客户端里面有个推荐服务,核心逻辑是这样的:接收用户请求,并行计算 20 个推荐策略,然后汇总排序返回。每个策略的计算量还挺大,涉及到特征提取、模型推理这些 CPU 密集操作。
现象
然而上线后,我们发现了一个诡异的现象:压测的时候 QPS 死活上不去,CPU 使用率只有 60% 左右,但响应时间却很长。照理说 CPU 还有余量,应该能扛更多请求才对。
原因分析
我 dump了线程栈,发现DefaultDispatcher的 8 个工作线程全都在RUNNABLE状态,而且堆栈显示它们都在执行策略计算。看起来没啥问题对吧?
但仔细一看,发现有些策略计算里面调用了外部服务(查用户画像缓存)。这个外部服务有时候会慢,虽然用了协程的异步客户端,但问题在于我们没指定调度器!我们都知道,没指定调度器的协程默认用 Default,结果就是:CPU计算任务和网络IO任务都混在同一个线程池里面了。网络IO一慢的话,线程就挂起等待,CPU 密集任务反而没线程跑了。
如何解决
可以看下面的代码实例,简单来说,就是CPU 活儿给 Default,IO 活儿给 IO,各司其职,别让它们抢饭碗。
// 之前:所有任务都在 Default 里
suspend fun calculateRecommendations(): List<Recommendation> = coroutineScope {
val deferreds = strategies.map { strategy ->
async {
// 这里面既有 CPU 计算,又有网络请求
strategy.calculate(userId)
}
}
deferreds.awaitAll()
}
// 之后:CPU 任务和网络请求分开
suspend fun calculateRecommendations(): List<Recommendation> = coroutineScope {
val deferreds = strategies.map { strategy ->
async {
// CPU 密集型任务:特征提取、模型推理
val features = withContext(Dispatchers.Default) {
extractFeatures(userId)
}
// IO 密集型任务:查缓存
val userProfile = withContext(Dispatchers.IO) {
cacheService.getUserProfile(userId)
}
// CPU 密集型任务:合并计算
withContext(Dispatchers.Default) {
computeScore(features, userProfile)
}
}
}
deferreds.awaitAll()
}
调度器不是装饰品,它是协程的"岗位分配表",如下图所示
小结
Default 调度器的并行度就是 CPU 核心数,这是专门为 CPU 密集型任务调优的。别想着改它,也别往里面塞 IO 任务,尊重它的设计哲学,它就不会轻易坑你。
二、Dispatchers.IO 在 2025 之后的实现有哪些关键变化?
快问快答
Dispatchers.IO 从以前的"无限制弹性线程池"变成了"有限制的弹性线程池",引入了任务限流机制,防止大量 IO 任务把系统资源撑爆。
娓娓道来
说起 Dispatchers.IO 的变化,得先聊聊它的"黑历史"。
早期版本的Dispatchers.IO有个挺彪悍的特性:弹性扩展。啥意思呢?就是线程数没有硬上限。来了 100 个 IO 任务,它就开 100 个线程;来了 10000 个,它就开 10000 个。
这听起来挺美好对吧?IO 任务嘛,大部分时间都在等待,线程多一点也没关系。但实际生产环境里,这个设计就是个定时炸弹。
想想这个场景:你的服务调用了一个第三方接口,突然这个接口开始疯狂超时。假设你的服务 QPS 是 1000,每个请求的超时时间是 5 秒。那么在 5 秒内,就会有 5000 个线程在等着超时。5000 个线程啊!每个线程默认栈大小 1MB,光线程栈就占用了 5GB 内存。
更糟糕的是,线程创建本身就有开销,大量线程还会争抢 CPU 资源,导致上下文切换成本飙升。系统还没来得及报OOM,就已经被拖垮了。
于是,从某个版本开始(没记错的话,应该是在20年左右发布的kotlinx.coroutines 1.4.0),官方开始对 IO 调度器"限流"了。
简单翻了下源码,现在的实现差不多长这样吧:
-
基础线程池:继承自Default调度器,共享底层资源
-
弹性扩展上限:最多 *max(64, CPU核心数)*个额外线程
-
任务排队机制:超过上限的任务会进入队列等待
-
空闲回收:线程空闲一段时间后会被回收
// kotlinx.coroutines 源码简化版,伪代码
internal class DefaultScheduler(
corePoolSize: Int = CORE_POOL_SIZE,
maxPoolSize: Int = MAX_POOL_SIZE
) : ExecutorCoroutineDispatcher() {
companion object {
// 基础线程数 = CPU 核心数
val CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors()
// 最大线程数 = 基础 + 弹性扩展
// 弹性扩展上限是 max(64, CPU核心数)
val MAX_POOL_SIZE = CORE_POOL_SIZE + max(64, CORE_POOL_SIZE)
}
}
我们简单用个代码示例体验一下
fun main() = runBlocking {
// 模拟大量 IO 任务
val jobs = List(200) { i ->
async(Dispatchers.IO) {
println("任务 $i 开始,线程: ${Thread.currentThread().name}")
delay(5000) // 模拟 IO 等待
println("任务 $i 结束")
}
}
jobs.awaitAll()
}
// 输出观察:
// - 前 64 个(或更多,取决于 CPU 核心数)任务会立即获得线程
// - 后面的任务会排队等待
// - 线程名称类似:DefaultDispatcher-worker-1, worker-2, ...
// - 注意:现在 IO 和 Default 共享同一个底层调度器,只是任务队列不同
容易踩坑的地方
坑点一:认为 IO 调度器可以无限创建线程
这是老黄历了。现在的 IO 调度器有上限,超过上限的任务会排队。如果你真的需要处理超大量并发 IO,需要考虑用其他方案(比如Netty的EventLoo)。
坑点二:觉得 IO 调度器和 Default 调度器是完全独立的
实际上,它们现在共享底层的线程池资源。这意味着你在Default里跑 CPU 密集任务,会影响 IO 调度器的性能,反之亦然。虽然它们有不同的任务队列和调度策略,但底层线程是共用的。
坑点三:不知道可以配置 IO 调度器的上限
虽然不推荐,但确实可以改:kotlinx.coroutines.io.parallelism。如果你真的需要更多 IO 线程,可以调高这个值,但要记得评估系统的承受能力。
实际项目案例:支付网关线程泄露事故
背景
我们有个支付网关服务,负责对接各种第三方支付渠道。每个渠道的接口响应时间差异很大:国内微信支付宝大概 50ms,但有些海外渠道能拖到 5 秒以上。
现象
某个周五下午,我们接入了几个新的海外支付渠道,想着周末可以观察下数据。刚上线还好,到了晚上高峰期,服务突然开始告警:内存使用率飙升,GC 频繁,最后直接 OOM 崩溃。那咋整,只能马上临时加班进行排查原因
原因分析
排查过程是这样的:
-
先看堆栈 dump,发现创建了大量线程(3000+),都是 DefaultDispatcher-worker-xxx
-
每个线程都在WAITING状态,等待HTTP响应
-
原来是新接入的海外渠道超时严重,每个请求都要等 5 秒
但问题是,按照我们对 IO 调度器的理解,它应该有上限才对啊?
后来发现,我们的 HTTP 客户端用的是同步阻塞式的 HttpURLConnection,而不是真正的异步客户端。在协程里用阻塞式 HTTP 客户端,会导致线程真正阻塞,而不是挂起。IO 调度器检测不到"挂起"状态,就以为线程还在忙碌,于是不断创建新线程。
如何解决
// 之前:同步阻塞式 HTTP 客户端
suspend fun callPaymentApi(url: String): String = withContext(Dispatchers.IO) {
// 这里的 connection.inputStream.read() 是阻塞操作!
val connection = URL(url).openConnection() as HttpURLConnection
connection.inputStream.bufferedReader().readText()
}
// 之后:真正的异步 HTTP 客户端
suspend fun callPaymentApi(url: String): String = withContext(Dispatchers.IO) {
// 使用 OkHttp 的异步能力,或者用 Ktor Client / Retrofit with Coroutines
// 这样线程在等待响应时会真正挂起,而不是阻塞
httpClient.get(url).bodyAsText()
}
// 或者更彻底的方案:给慢接口加单独的调度器
private val slowApiDispatcher = Executors.newFixedThreadPool(50)
.asCoroutineDispatcher()
suspend fun callSlowPaymentApi(url: String): String =
withContext(slowApiDispatcher) {
// 隔离慢接口,避免影响其他请求
blockingHttpClient.call(url)
}
IO 调度器的"弹性"不是万能药。如果你的代码里有真正的阻塞操作(而不是挂起),线程池还是会被撑爆。用异步客户端才是正道。
小结
IO 调度器不再是"无限线程池"了,它有了自己的天花板。这是个好变化,让系统的行为更加可预测。但请各位同学记住,真正的阻塞操作还是会坑你,用异步客户端才是王道。
三、withContext是否一定发生线程切换,它真正保证的是什么?
快问快答
首先明确的一点是,withContext不一定发生线程切换,它真正保证的是上下文的切换,包括调度器、异常处理器、名称等,线程切换只是其中一种可能的结果。
娓娓道来
相信很多人看到 *withContext(Dispatchers.IO)*的时候,就会下意识觉得"哦,这会切换线程啊"。但实际上,这事儿没那么简单。
我们先看一段代码:
fun main() = runBlocking {
println("当前线程: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
println("withContext 内部线程: ${Thread.currentThread().name}")
}
println("回到原线程: ${Thread.currentThread().name}")
}
运行一下,你会发现:
当前线程: main
withContext 内部线程: DefaultDispatcher-worker-1
回到原线程: main
看起来确实切换了线程对吧?但再看这个:
fun main() = runBlocking(Dispatchers.IO) {
println("当前线程: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
println("withContext 内部线程: ${Thread.currentThread().name}")
}
println("回到原线程: ${Thread.currentThread().name}")
}
输出可能长这样:
当前线程: DefaultDispatcher-worker-1
withContext 内部线程: DefaultDispatcher-worker-1 // 注意:没切换!
回到原线程: DefaultDispatcher-worker-1
坑就在这儿!当你已经在 IO 调度器里,再withContext(Dispatchers.IO),线程很可能不切换。
为啥? 因为 IO 调度器和 Default 调度器共享底层的线程池。当 withContext发现目标调度器和当前调度器是同一个(或者共享线程池),它就懒得切换了,直接在当前线程继续执行。
那有同学就会问了, withContext 到底保证了啥?
-
上下文切换的语义:代码块内的协程上下文(CoroutineContext)一定会变成你指定的那个
-
执行环境的隔离:即使没有线程切换,上下文的异常处理器、Job、名称等元素也会被正确替换
-
恢复的保证:代码块执行完毕后,一定恢复到原来的上下文
fun main() = runBlocking {
val exceptionHandler = CoroutineExceptionHandler { _, e ->
println("捕获异常: ${e.message}")
}
withContext(exceptionHandler) {
// 这里的上下文包含了异常处理器
throw RuntimeException("测试异常")
}
// 即使上面的代码抛异常,这里也会因为异常处理器而继续执行
// 但实际上 CoroutineExceptionHandler 只在根协程有效...
}
神马,不够清晰?我们再来看一个例子
fun main() = runBlocking {
// 情况1:调度器不同,一定会切换
println("=== 情况1: Default -> IO ===")
withContext(Dispatchers.Default) {
println("Default 线程: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
println("IO 线程: ${Thread.currentThread().name}") // 可能切换
}
}
// 情况2:调度器相同,可能不切换
println("\n=== 情况2: IO -> IO ===")
withContext(Dispatchers.IO) {
println("IO 线程1: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
println("IO 线程2: ${Thread.currentThread().name}") // 可能不切换
}
}
// 情况3:单线程调度器,一定切换到那个线程
println("\n=== 情况3: -> 单线程调度器 ===")
val singleThreadDispatcher = Executors.newSingleThreadExecutor()
.asCoroutineDispatcher()
withContext(singleThreadDispatcher) {
println("单线程调度器: ${Thread.currentThread().name}") // 一定切换
}
singleThreadDispatcher.close()
}
容易踩坑的地方
坑点一:认为 withContext 一定会切换线程
上面已经说了,不一定。如果目标调度器和当前调度器共享线程池,或者恰好分配到了同一个线程,就不会切换。
**坑点二:觉得withContext(Dispatchers.IO)比 withContext(Dispatchers.Default) 更"快"
调度器的选择取决于任务类型,不是速度。IO 调度器适合网络/磁盘 IO,Default 适合 CPU 计算。选错了反而更慢。
坑点三:认为嵌套 withContext会带来很大开销
其实协程调度器的切换成本很低(微秒级),远低于线程切换。只要你不是在循环里疯狂withContext,嵌套多几层是完全没问题滴。
实际项目案例:数据库事务上下文混乱事故
背景
我们有个订单系统,用 Exposed(Kotlin 的 SQL 框架)做数据库操作。Exposed 的事务需要绑定到当前线程,所以我们有个专门的调度器来管理数据库连接。
现象
有用户反馈说,有时候查询结果都不对的,张冠李戴,明明查的是用户 A 的订单,返回的却是用户 B 的。而且这个问题偶现,复现难度极大。
原因分析
排查过程堪称侦探破案:
-
首先排除了 SQL 写错的可能性,日志里的 SQL 语句是正确的
-
然后怀疑是缓存问题,关掉缓存后问题依旧
-
最后发现,出问题的请求都有一个共同点:执行过程中调度过其他协程
发现原来是这么回事:
// 问题代码
suspend fun getOrder(userId: String): Order = dbDispatcher.withLock {
// 在 dbDispatcher 里开启事务
transaction {
// 查询订单
val order = Orders.select { Orders.userId eq userId }.single()
// 然后调用了一个会切换调度器的函数
val userInfo = getUserInfo(userId) // 这个函数内部用了 Dispatchers.IO
// 回来之后,当前线程可能已经变了!
// 但事务还绑定在原来的线程上
// 这时候再操作数据库,可能拿到的是另一个连接
order
}
}
suspend fun getUserInfo(userId: String): UserInfo = withContext(Dispatchers.IO) {
// 调用远程服务
httpClient.get("/users/$userId")
}
问题在于:数据库事务绑定在特定线程上,但在事务执行过程中,协程切换了调度器(和线程)。当协程挂起后恢复,它可能被调度到另一个线程,但事务状态还停留在原线程,导致数据混乱。
如何解决
话不多说,直接上代码
// 方案1:不要在事务里切换调度器
suspend fun getOrder(userId: String): Order {
// 先把需要的数据都准备好
val userInfo = getUserInfo(userId)
// 然后在一个连续的执行块里操作数据库
return dbDispatcher.withLock {
transaction {
Orders.select { Orders.userId eq userId }.single()
}
}
}
// 方案2:用 newSuspendedTransaction(Exposed 提供的协程友好 API)
suspend fun getOrder(userId: String): Order {
val userInfo = getUserInfo(userId)
return newSuspendedTransaction(Dispatchers.IO, database) {
// 这个 API 会正确处理协程挂起和恢复
Orders.select { Orders.userId eq userId }.single()
}
}
withContext虽然方便,但在涉及线程绑定资源(如数据库事务、ThreadLocal)时要特别小心再小心。协程可能会在线程之间来回"跳跃",而有些资源是"认线程不认人"的。
小结
withContext 保证的是上下文切换,线程切换只是副产品。别太在意它会不会切换线程,关注你真正想要的——执行环境的隔离和恢复。
四、Unconfined 在真实项目中为什么被视为"危险调度器"?
快问快答
Unconfined调度器理论上是会让协程在"任何线程"上执行,完全放弃调度控制权,导致代码行为难以预测,测试无法复现,调试如同噩梦。
娓娓道来
Dispatchers.Unconfined这个东西,名字里就带着"无拘无束"的意思。它的行为规则简单得离谱:
- 协程在调用者线程启动
- 挂起后恢复时,在"恢复它的线程"上继续执行
听起来好像没啥问题?ok,来看个例子:
fun main() = runBlocking {
launch(Dispatchers.Unconfined) {
println("1. 启动线程: ${Thread.currentThread().name}") // main
delay(100)
println("2. delay 后的线程: ${Thread.currentThread().name}") // 可能是 DefaultDispatcher-worker-xxx
withContext(Dispatchers.IO) {
println("3. withContext 内: ${Thread.currentThread().name}")
}
println("4. withContext 后: ${Thread.currentThread().name}") // 可能又变了!
}
Thread.sleep(200) // 等协程跑完
}
输出可能是这样的:
1. 启动线程: main
2. delay 后的线程: DefaultDispatcher-worker-1
3. withContext 内: DefaultDispatcher-worker-2
4. withContext 后: 线程可能又变了!
看到没有?协程在不同的挂起点之间"跳跃",每次恢复都有可能跑到不同的线程上,这不确定性太大了。
为什么说它危险?
-
线程安全噩梦:你以为在单线程执行,结果协程在多线程之间乱窜,共享状态的线程安全问题全暴露了
-
测试无法复现:同样的测试代码,每次运行可能在不同线程执行,测试结果不稳定
-
调试困难:堆栈信息支离破碎,你根本不知道协程是怎么"跳"过来的
-
资源管理混乱:线程绑定资源(如ThreadLocal、数据库连接)可能在不经意间丢失或错乱
可以看下面的代码实例
// 一个典型的"危险"用法
fun main() = runBlocking {
var counter = 0 // 共享状态
launch(Dispatchers.Unconfined) {
repeat(1000) {
counter++ // 看起来没问题,实际上可能在多线程执行
delay(1) // 每次挂起后,恢复可能在不同线程
}
println("Counter: $counter") // 可能不是 1000!
}
launch(Dispatchers.Unconfined) {
repeat(1000) {
counter++ // 另一个协程也在改
delay(1)
}
}
delay(2000)
println("最终 Counter: $counter") // 结果完全不可预测
}
容易踩坑的地方
误区一:觉得 Unconfined 性能更好
其实事实恰恰相反。Unconfined虽然省去了调度开销,但它带来的线程安全问题可能导致更严重的性能问题(比如锁竞争)。而且现代调度器的开销本来就很低,大多时候没必要在乎这一点。
误区二:认为 Unconfined 适合"快速执行的小任务"
小任务更应该用合适的调度器。Unconfined的不可预测性不会因为任务小就消失。
误区三:在测试代码里用 Unconfined "加速执行"
这是最常见的坑。测试应该使用 runTest 和StandardTestDispatcher,而不是Unconfined。Unconfined会让测试的执行顺序变得不可预测,反而可能掩盖 bug。
实际使用案例:日志追踪系统线程混乱事故
背景
我们有个服务调用链追踪系统,用 MDC(Mapped Diagnostic Context)来传递traceId。MDC 是ThreadLocal实现的,每个线程维护自己的traceId。
现象
测试的时候反馈说,有时候查日志,发现同一个请求的日志里有多个不同的 traceId,导致追踪链断裂。而且出问题的请求都集中在某个特定的接口。
原因分析
排查发现,出问题的接口用了 Dispatchers.Unconfined:
// 问题代码
suspend fun processOrder(orderId: String): OrderResult {
MDC.put("traceId", generateTraceId())
return withContext(Dispatchers.Unconfined) { // 危险!
// 第一步:查库存
val inventory = checkInventory(orderId)
log.info("库存检查完成") // 这里的 traceId 可能已经丢了
// 第二步:扣款
val payment = processPayment(orderId)
log.info("扣款完成") // traceId 又变了
// 第三步:发货
shipOrder(orderId)
log.info("发货完成")
OrderResult(success = true)
}
}
问题在于:Dispatchers.Unconfined让协程在不同线程之间跳来跳去,而MDC是线程绑定的。每次协程"跳"到新线程,MDC的traceId就变成了新线程的值(可能是空的,也可能是另一个请求的)。
如何解决
话不多说,直接上代码
// 方案1:用 MDC 上下文传播库
// 使用 kotlinx-coroutines-slf4j 的 MDCContext
suspend fun processOrder(orderId: String): OrderResult {
return withContext(MDCContext() + Dispatchers.Default) {
MDC.put("traceId", generateTraceId())
// 使用 MDCContext 后,协程恢复时会自动恢复 MDC
val inventory = checkInventory(orderId)
log.info("库存检查完成")
val payment = processPayment(orderId)
log.info("扣款完成")
shipOrder(orderId)
log.info("发货完成")
OrderResult(success = true)
}
}
// 方案2:手动传递上下文
suspend fun processOrder(orderId: String): OrderResult {
val traceId = generateTraceId()
return withContext(Dispatchers.Default) {
withMdcContext(traceId) {
val inventory = checkInventory(orderId)
log.info("库存检查完成")
// ...
}
}
}
fun <T> withMdcContext(traceId: String, block: () -> T): T {
val oldTraceId = MDC.get("traceId")
try {
MDC.put("traceId", traceId)
return block()
} finally {
if (oldTraceId != null) {
MDC.put("traceId", oldTraceId)
} else {
MDC.remove("traceId")
}
}
}
Unconfined就像一匹野马,看着跑得快,但你根本控制不住它往哪儿跑。生产代码里,最好离它远点。
小结
一般时候用不到Unconfined的,Unconfined的唯一正当用途是写测试框架或者做性能调优分析。在业务代码里用它,就是在给自己埋雷。
五、协程调度与 CPU cache / 上下文切换有什么关系?
快问快答
协程调度通过减少线程上下文切换和提升CPU cache命中率来提高性能,但调度器选择不当会适得其反,让这些优势荡然无存。
娓娓道来
我们聊协程性能的时候,不能光看"少了线程开销",得深入到CPU层面中,看看真正省了啥。
一、上下文切换的成本
我们都知道,线程上下文切换是个昂贵操作:
- 保存当前线程状态:寄存器、程序计数器、栈指针,统统要保存
- 加载新线程状态:把新线程的状态恢复到 CPU
- 刷新 TLB:虚拟内存到物理内存的映射缓存可能要刷新
- 调度器开销:操作系统调度器要决定下一个跑谁
这个过程大概需要 1-10 微秒,听起来不多,但高并发场景下一秒钟可能发生成千上万次切换。
协程为啥能省?
协程的挂起和恢复,是用户态操作,不需要经过操作系统内核:
- 保存协程状态:只是保存几个对象引用(Continuation)
- 切换成本低:函数调用级别,纳秒级
- 没有内核参与:调度器完全在用户态
// 线程切换 vs 协程切换
fun threadSwitch() {
Thread.sleep(1) // 线程挂起,操作系统介入
// 恢复时:内核调度,上下文切换,约 1-10 微秒
}
suspend fun coroutineSwitch() {
delay(1) // 协程挂起,用户态操作
// 恢复时:调度器选择,约 100 纳秒
}
二、CPU Cache 的影响
现代 CPU 的性能瓶颈往往在内存访问上。CPU cache的命中率对性能影响巨大:
- L1 cache 命中:约 1 纳秒
- L2 cache 命中:约 4 纳秒
- L3 cache 命中:约 12 纳秒
- 主内存访问:约 100 纳秒
差了两个数量级!
线程切换会破坏 cache 局部性:
- 新线程的数据不在 cache 里:需要从主内存加载
- 旧线程的数据被挤出 cache:下次恢复又要重新加载
协程在这方面有天然优势:
- 轻量级切换:协程切换时,cache 热度影响小
- 数据局部性更好:相关任务可以在同一个线程连续执行
但是,特别注意!一旦调度器选错了,以上这些优势全部白搭:
- 如果协程在不同调度器之间频繁跳转(withContext乱用)
- 如果线程池配置不合理,导致线程频繁创建销毁
- 如果任务分配不均,导致某些线程过载
看看下面这个例子
// 反例:频繁切换调度器
suspend fun badExample() {
repeat(1000) {
withContext(Dispatchers.IO) { // 切!
// 做点小事
}
withContext(Dispatchers.Default) { // 又切!
// 又做点小事
}
}
// 上下文切换 2000 次,cache 热度全没了
}
// 正例:批量处理
suspend fun goodExample() {
withContext(Dispatchers.IO) {
repeat(1000) {
// 把 IO 操作都集中在这里
}
}
withContext(Dispatchers.Default) {
repeat(1000) {
// 把 CPU 操作都集中在这里
}
}
// 只有 2 次切换
}
容易踩坑的地方
误区一:认为协程切换完全没有成本
协程切换成本低,但不是零。频繁的挂起/恢复仍然有开销,尤其是在高并发场景下。
误区二:觉得只要用了协程,性能就一定好
协程只是工具,用不好照样有性能问题。调度器选择、任务划分、阻塞操作处理,都会影响最终效果。
误区三:忽略协程调度器本身的开销
调度器内部也有队列、锁、状态机。极端高并发下,调度器本身可能成为瓶颈。
实际应用案例:高频交易系统调度优化
背景
之前公司有个高频交易策略执行系统,每秒要处理上万笔订单。对延迟极其敏感,每毫秒都关乎盈亏。
现象
系统上线初期,平均延迟 5ms,P99 延迟 20ms。但偶尔会出现延迟飙升到 100ms+ 的情况,导致错过最佳交易时机。
原因分析
通过CPU profiling发现,延迟飙升时,系统在大量进行线程上下文切换。查看代码,发现问题出在调度策略上:
// 问题代码
suspend fun executeStrategy(strategy: Strategy) {
// 每个步骤都用不同的调度器
val signal = withContext(Dispatchers.IO) {
// 获取行情数据
marketDataFeed.getSignal()
}
val decision = withContext(Dispatchers.Default) {
// 策略计算
strategyEngine.decide(signal)
}
withContext(Dispatchers.IO) {
// 执行交易
orderExecutor.execute(decision)
}
}
每个策略执行都要切换 3 次调度器,每秒上万次执行,就是几万次上下文切换。而且Default和IO调度器共享线程池,线程之间的争抢更加剧了问题。
如何解决
话不多说,直接上代码
// 优化方案:使用专门的调度器
// 针对高频交易场景,创建一个独立的、线程数等于 CPU 核心数的调度器
private val tradingDispatcher = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
).asCoroutineDispatcher()
suspend fun executeStrategy(strategy: Strategy) {
// 所有操作都在同一个调度器里
// 减少上下文切换
withContext(tradingDispatcher) {
val signal = marketDataFeed.getSignal()
val decision = strategyEngine.decide(signal)
orderExecutor.execute(decision)
}
}
// 同时优化数据结构,提升 cache 命中率
// 使用连续内存布局的数据结构
class OrderBook private constructor(
private val bids: Array<OrderEntry>, // 连续内存
private val asks: Array<OrderEntry>
) {
// ...
}
经过优化后,平均延迟降到 2ms,P99 降到 8ms,延迟飙升的情况也基本消失。所以说,对于延迟敏感的系统,调度策略比语言特性更重要。理解 CPU 的工作方式,减少不必要的上下文切换,才能发挥协程的真正优势。
小结
协程的性能优势来自"少折腾"——少切换上下文,少破坏cache。但调度器用不好,这些优势就变成劣势了。记住:让相关的任务待在一起,别老搬家。
六、如何为 CPU 密集 + IO 密集混合场景设计 Dispatcher?
快问快答
混合场景需要隔离 CPU 和 IO 任务,可以用独立调度器或分层调度策略,关键是别让 CPU 任务饿死 IO 任务,也别让 IO 阻塞拖累 CPU 计算。
娓娓道来
现实世界里的服务往往不是纯粹的 CPU 密集或 IO 密集,而是混合的:
- 先查数据库(IO)
- 然后做计算(CPU)
- 再调外部接口(IO)
- 最后做数据分析(CPU)
如果只依赖Default和IO这两个默认调度器,可能会遇到下面几个问题:
- IO 和 Default 共享线程池:在新版本kotlinx.coroutines里,它们底层共享资源,可能互相影响
- 线程饥饿:CPU任务占满所有线程,IO任务只能排队
- 阻塞污染:IO 任务的阻塞操作拖垮整个线程池
设计原则:
- 隔离:不同类型的任务用不同的调度器
- 限流:控制每种任务的并发度
- 优先级:重要任务有更高的调度优先级
- 监控:观察各调度器的负载情况
OK,我们来看下面这个例子
// 方案1:独立调度器
object AppDispatchers {
// CPU 密集型任务专用
val CPU: CoroutineDispatcher = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
).asCoroutineDispatcher()
// IO 密集型任务专用
val IO: CoroutineDispatcher = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2
).asCoroutineDispatcher()
// 阻塞操作专用(隔离真正的阻塞调用)
val BLOCKING: CoroutineDispatcher = Executors.newCachedThreadPool()
.asCoroutineDispatcher()
// 后台任务(低优先级)
val BACKGROUND: CoroutineDispatcher = Executors.newFixedThreadPool(2)
.asCoroutineDispatcher()
}
// 使用示例
suspend fun processRequest(request: Request): Response {
// 1. 查数据库(IO)
val data = withContext(AppDispatchers.IO) {
database.query(request.id)
}
// 2. 计算(CPU)
val result = withContext(AppDispatchers.CPU) {
computeEngine.process(data)
}
// 3. 调外部服务(IO)
val external = withContext(AppDispatchers.IO) {
externalService.call(result)
}
// 4. 最终计算(CPU)
return withContext(AppDispatchers.CPU) {
buildResponse(result, external)
}
}
// 方案2:基于 Semaphore 的限流调度器
class LimitedDispatcher(
private val delegate: CoroutineDispatcher,
private val parallelism: Int
) : CoroutineDispatcher() {
private val semaphore = Semaphore(parallelism)
override fun dispatch(context: CoroutineContext, block: Runnable) {
delegate.dispatch(context) {
semaphore.acquire()
try {
block.run()
} finally {
semaphore.release()
}
}
}
}
// 使用示例
val limitedIO = LimitedDispatcher(Dispatchers.IO, parallelism = 50)
// 限制最多 50 个并发 IO 任务
// 方案3:分层调度策略
class TieredScheduler {
// 高优先级任务
val highPriority = Executors.newFixedThreadPool(4)
.asCoroutineDispatcher()
// 普通任务
val normal = Executors.newFixedThreadPool(8)
.asCoroutineDispatcher()
// 低优先级任务
val lowPriority = Executors.newFixedThreadPool(2)
.asCoroutineDispatcher()
inline fun <T> CoroutineScope.launchHigh(
crossinline block: suspend CoroutineScope.() -> T
) = launch(highPriority) { block() }
inline fun <T> CoroutineScope.launchLow(
crossinline block: suspend CoroutineScope.() -> T
) = launch(lowPriority) { block() }
}
容易踩坑的地方
误区一:为每种任务类型都创建独立调度器
调度器太多反而增加调度开销和线程数量。通常 2-4 个调度器就足够了。
误区二:忽略调度器之间的资源竞争
独立调度器意味着独立的线程池,线程总数会增加。需要考虑系统的总资源限制。
误区三:忘记关闭调度器
自定义的调度器需要显式关闭,否则会线程泄露。
实际应用案例:推荐系统混合调度优化
背景
我们有个实时推荐系统,处理流程大概是这样的:
- 接收用户请求
- 查询用户画像(Redis,IO)
- 查询商品特征(MySQL,IO)
- 模型推理(CPU)
- 结果排序过滤(CPU)
- 写入推荐日志(Kafka,IO)
- 返回结果
现象
高峰期服务响应时间波动剧烈,P99 有时候能到 500ms+。排查发现线程池经常"打满",但又不是 CPU 或者网络 IO 真正到了瓶颈。
原因分析
代码里是这样的:
// 问题代码:所有任务都在 IO 调度器
suspend fun recommend(userId: String): List<Item> = withContext(Dispatchers.IO) {
// 查用户画像
val profile = userProfileCache.get(userId)
// 查商品特征
val items = itemFeatureDao.getAll()
// 模型推理 - CPU 密集!
val scores = modelInference(profile, items)
// 排序 - CPU 密集!
val sorted = scores.sortedByDescending { it.score }
// 写日志
kafkaProducer.send("recommend_log", LogEntry(userId, sorted))
sorted.take(10)
}
问题很明显:CPU 密集的模型推理和排序放在了 IO 调度器里。当并发请求增多时,大量协程在执行 CPU 计算而不是等待 IO,导致 IO 调度器的线程被占满,新的 IO 请求反而无法处理。
如何解决
话不多说,直接上代码
// 优化后的调度策略
object RecommendDispatchers {
// IO 调度器:用于网络、数据库、缓存
val IO: CoroutineDispatcher = Executors.newFixedThreadPool(16)
.asCoroutineDispatcher()
// 计算调度器:用于模型推理、排序
val COMPUTE: CoroutineDispatcher = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
).asCoroutineDispatcher()
// 日志调度器:隔离日志写入,避免影响主流程
val LOG: CoroutineDispatcher = Executors.newSingleThreadExecutor()
.asCoroutineDispatcher()
}
suspend fun recommend(userId: String): List<Item> = coroutineScope {
// 并行查询(IO)
val profileDeferred = async(RecommendDispatchers.IO) {
userProfileCache.get(userId)
}
val itemsDeferred = async(RecommendDispatchers.IO) {
itemFeatureDao.getAll()
}
val profile = profileDeferred.await()
val items = itemsDeferred.await()
// 模型推理(CPU)
val scores = withContext(RecommendDispatchers.COMPUTE) {
modelInference(profile, items)
}
// 排序(CPU)
val sorted = withContext(RecommendDispatchers.COMPUTE) {
scores.sortedByDescending { it.score }
}
// 异步写日志,不阻塞返回
launch(RecommendDispatchers.LOG) {
kafkaProducer.send("recommend_log", LogEntry(userId, sorted))
}
sorted.take(10)
}
经过优化后,P99 延迟稳定在 80ms 以内,吞吐量提升了 40%。对于这种混合场景的调度设计,核心是"各司其职"。让 IO 的归 IO,计算的归计算,别让它们互相拖后腿。
小结
混合场景不需要花哨的设计,"分而治之"就够了。给不同类型的任务分配专属的资源池,让它们各自安好,互不打扰。
七、大量短生命周期协程是否会带来调度开销问题?
快问快答
会有开销,但协程的创建和销毁成本很低(纳秒级),真正需要关注的是调度器队列争抢和上下文切换频率,而不是协程数量本身。
娓娓道来
在这之前,我们先说一个数字:创建一个协程的成本大约是 100-200 纳秒。作为对比,创建一个线程的成本是 1-10 微秒,它们之间差了 50-100 倍。
所以,协程的创建销毁本身,真的不是问题。但"大量短生命周期协程"确实会带来一些隐患:
一、调度器队列压力
每个协程启动时,都要被放入调度器的任务队列。如果协程产生速度远超消费速度,队列会堆积:
// 危险示例
fun main() = runBlocking {
repeat(1_000_000) { i ->
launch(Dispatchers.Default) {
// 啥也不干,立即结束
}
}
// 会产生 100 万个协程任务,队列爆炸
}
二、调度器锁竞争
调度器内部的队列操作需要同步,高并发下会有锁竞争:
// kotlinx.coroutines 源码简化
internal class WorkQueue {
private val queue = ConcurrentLinkedQueue<Runnable>()
fun add(task: Runnable) {
queue.add(task) // 高并发下这里有竞争
}
}
三、内存压力
每个协程都有对应的 Continuation 对象,虽然很小,但积少成多:
// 每个协程的 Continuation 大约几百字节
// 100 万个协程 = 几百 MB
四、上下文切换频率
协程虽轻,但调度切换还是有开销的。每秒切换百万次,累积起来也很可观。
// 反例:无限制创建协程
suspend fun badExample(items: List<Item>) = coroutineScope {
items.forEach { item ->
launch {
processItem(item) // 每个元素一个协程
}
}
}
// 如果 items 有 100 万个元素,就创建 100 万个协程
// 正例:批量处理 + 并发控制
suspend fun goodExample(items: List<Item>) = coroutineScope {
val semaphore = Semaphore(100) // 限制并发度
items.map { item ->
async {
semaphore.withPermit {
processItem(item)
}
}
}.awaitAll()
}
// 或者使用 chunked 批量处理
suspend fun betterExample(items: List<Item>) = coroutineScope {
items.chunked(100).map { chunk ->
async {
chunk.forEach { item ->
processItem(item)
}
}
}.awaitAll()
}
容易踩坑的地方
误区一:认为协程数量没有限制
理论上协程数量可以很大,但实际受限于内存和调度器能力。百万级协程应该避免。
误区二:觉得每个小任务都应该启动一个协程
协程适合处理异步操作,如果任务本身很快(比如只是做个简单计算),直接同步执行更高效。
误区三:忽略协程的生命周期管理
大量协程如果没有正确的父协程结构,可能导致协程泄露或取消困难。
实际应用案例:消息处理系统协程爆炸事故
背景
之前公司有个消息处理服务,从Kafka消费消息后进行处理。每条消息的处理逻辑很简单,就是解析、验证、存储,大概几毫秒就能完成。
现象
某天流量突然增大,服务开始疯狂 GC,最后 OOM 崩溃。监控显示协程数量飙升到几十万。马上就被安排线上排查
原因分析
仔细排查下,问题代码差不多长这样:
// 问题代码
fun startConsumer() = GlobalScope.launch(Dispatchers.IO) {
kafkaConsumer.subscribe(listOf("messages"))
while (true) {
val records = kafkaConsumer.poll(Duration.ofMillis(100))
records.forEach { record ->
// 每条消息一个协程
launch {
processMessage(record.value())
}
}
}
}
suspend fun processMessage(message: String) {
// 解析
val data = parseMessage(message)
// 验证
validate(data)
// 存储
saveToDatabase(data)
}
它的问题在于:消息消费速度(poll)远快于处理速度,每条消息都启动一个协程,协程数量不断堆积。而且用了GlobalScope,这些协程没有父协程管理,无法统一取消。
解决方案
// 优化方案:使用 SupervisorJob + 并发控制
class MessageProcessor {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val semaphore = Semaphore(100) // 限制并发处理数
fun start() = scope.launch {
kafkaConsumer.subscribe(listOf("messages"))
while (isActive) { // 支持取消
val records = kafkaConsumer.poll(Duration.ofMillis(100))
records.map { record ->
async {
semaphore.withPermit {
processMessage(record.value())
}
}
}.awaitAll()
}
}
fun stop() {
scope.cancel() // 统一取消所有协程
}
private suspend fun processMessage(message: String) {
// ... 处理逻辑
}
}
大量短生命周期协程不可怕,可怕的是无限制地创建。加上并发控制和生命周期管理,问题就基本上解决了。
小结
协程很轻,但不是无限的。大量创建时,记得给它们加个"阀门"——并发控制是必须的,生命周期管理也不能少。
尾声
好了这个模块就到这里了,调度器就像公司的人力资源部,把人(协程)放到合适的岗位(线程)上。安排得好,效率翻倍;安排不好,全员摸鱼。
下次写代码前,先问问自己:这个任务该去哪个调度器报到?
我们下一篇见,祝大家早安午安晚安,祉猷并茂,顺遂无虞。