谱写Kotlin协程面试进行曲-进阶篇(第二乐章)

0 阅读25分钟

前言

时光飞逝,距离上次整理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()
}

调度器不是装饰品,它是协程的"岗位分配表",如下图所示

drawio.png

小结

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,需要考虑用其他方案(比如NettyEventLoo)。

坑点二:觉得 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调度器.png

小结

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)时要特别小心再小心。协程可能会在线程之间来回"跳跃",而有些资源是"认线程不认人"的。

A326BA5D-211C-4D56-AEC2-449AEE785C7A.png

小结

withContext 保证的是上下文切换,线程切换只是副产品。别太在意它会不会切换线程,关注你真正想要的——执行环境的隔离和恢复。

四、Unconfined 在真实项目中为什么被视为"危险调度器"?

快问快答

Unconfined调度器理论上是会让协程在"任何线程"上执行,完全放弃调度控制权,导致代码行为难以预测,测试无法复现,调试如同噩梦。

娓娓道来

Dispatchers.Unconfined这个东西,名字里就带着"无拘无束"的意思。它的行为规则简单得离谱:

  1. 协程在调用者线程启动
  2. 挂起后恢复时,在"恢复它的线程"上继续执行

听起来好像没啥问题?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 "加速执行"

这是最常见的坑。测试应该使用 runTestStandardTestDispatcher,而不是UnconfinedUnconfined会让测试的执行顺序变得不可预测,反而可能掩盖 bug。

实际使用案例:日志追踪系统线程混乱事故

背景

我们有个服务调用链追踪系统,用 MDC(Mapped Diagnostic Context)来传递traceIdMDCThreadLocal实现的,每个线程维护自己的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是线程绑定的。每次协程"跳"到新线程,MDCtraceId就变成了新线程的值(可能是空的,也可能是另一个请求的)。

如何解决

话不多说,直接上代码

// 方案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.png

小结

一般时候用不到Unconfined的,Unconfined的唯一正当用途是写测试框架或者做性能调优分析。在业务代码里用它,就是在给自己埋雷。

五、协程调度与 CPU cache / 上下文切换有什么关系?

快问快答

协程调度通过减少线程上下文切换和提升CPU cache命中率来提高性能,但调度器选择不当会适得其反,让这些优势荡然无存。

娓娓道来

我们聊协程性能的时候,不能光看"少了线程开销",得深入到CPU层面中,看看真正省了啥。

一、上下文切换的成本

我们都知道,线程上下文切换是个昂贵操作:

  1. 保存当前线程状态:寄存器、程序计数器、栈指针,统统要保存
  2. 加载新线程状态:把新线程的状态恢复到 CPU
  3. 刷新 TLB:虚拟内存到物理内存的映射缓存可能要刷新
  4. 调度器开销:操作系统调度器要决定下一个跑谁

这个过程大概需要 1-10 微秒,听起来不多,但高并发场景下一秒钟可能发生成千上万次切换。

协程为啥能省?

协程的挂起和恢复,是用户态操作,不需要经过操作系统内核:

  1. 保存协程状态:只是保存几个对象引用(Continuation)
  2. 切换成本低:函数调用级别,纳秒级
  3. 没有内核参与:调度器完全在用户态
// 线程切换 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 局部性:

  1. 新线程的数据不在 cache 里:需要从主内存加载
  2. 旧线程的数据被挤出 cache:下次恢复又要重新加载

协程在这方面有天然优势:

  1. 轻量级切换:协程切换时,cache 热度影响小
  2. 数据局部性更好:相关任务可以在同一个线程连续执行

但是,特别注意!一旦调度器选错了,以上这些优势全部白搭

  • 如果协程在不同调度器之间频繁跳转(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 次调度器,每秒上万次执行,就是几万次上下文切换。而且DefaultIO调度器共享线程池,线程之间的争抢更加剧了问题。

如何解决

话不多说,直接上代码

// 优化方案:使用专门的调度器
// 针对高频交易场景,创建一个独立的、线程数等于 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 的工作方式,减少不必要的上下文切换,才能发挥协程的真正优势。

协程调度.png

小结

协程的性能优势来自"少折腾"——少切换上下文,少破坏cache。但调度器用不好,这些优势就变成劣势了。记住:让相关的任务待在一起,别老搬家。

六、如何为 CPU 密集 + IO 密集混合场景设计 Dispatcher?

快问快答

混合场景需要隔离 CPU 和 IO 任务,可以用独立调度器或分层调度策略,关键是别让 CPU 任务饿死 IO 任务,也别让 IO 阻塞拖累 CPU 计算。

娓娓道来

现实世界里的服务往往不是纯粹的 CPU 密集或 IO 密集,而是混合的:

  • 先查数据库(IO)
  • 然后做计算(CPU)
  • 再调外部接口(IO)
  • 最后做数据分析(CPU)

如果只依赖DefaultIO这两个默认调度器,可能会遇到下面几个问题:

  1. IO 和 Default 共享线程池:在新版本kotlinx.coroutines里,它们底层共享资源,可能互相影响
  2. 线程饥饿CPU任务占满所有线程,IO任务只能排队
  3. 阻塞污染:IO 任务的阻塞操作拖垮整个线程池

设计原则:

  1. 隔离:不同类型的任务用不同的调度器
  2. 限流:控制每种任务的并发度
  3. 优先级:重要任务有更高的调度优先级
  4. 监控:观察各调度器的负载情况

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 个调度器就足够了。

误区二:忽略调度器之间的资源竞争

独立调度器意味着独立的线程池,线程总数会增加。需要考虑系统的总资源限制。

误区三:忘记关闭调度器

自定义的调度器需要显式关闭,否则会线程泄露。

实际应用案例:推荐系统混合调度优化

背景

我们有个实时推荐系统,处理流程大概是这样的:

  1. 接收用户请求
  2. 查询用户画像(Redis,IO)
  3. 查询商品特征(MySQL,IO)
  4. 模型推理(CPU)
  5. 结果排序过滤(CPU)
  6. 写入推荐日志(Kafka,IO)
  7. 返回结果

现象

高峰期服务响应时间波动剧烈,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,计算的归计算,别让它们互相拖后腿。

混合场景.png

小结

混合场景不需要花哨的设计,"分而治之"就够了。给不同类型的任务分配专属的资源池,让它们各自安好,互不打扰。

七、大量短生命周期协程是否会带来调度开销问题?

快问快答

会有开销,但协程的创建和销毁成本很低(纳秒级),真正需要关注的是调度器队列争抢和上下文切换频率,而不是协程数量本身。

娓娓道来

在这之前,我们先说一个数字:创建一个协程的成本大约是 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) {
        // ... 处理逻辑
    }
}

大量短生命周期协程不可怕,可怕的是无限制地创建。加上并发控制和生命周期管理,问题就基本上解决了。

短周期.png

小结

协程很轻,但不是无限的。大量创建时,记得给它们加个"阀门"——并发控制是必须的,生命周期管理也不能少。

尾声

好了这个模块就到这里了,调度器就像公司的人力资源部,把人(协程)放到合适的岗位(线程)上。安排得好,效率翻倍;安排不好,全员摸鱼。

下次写代码前,先问问自己:这个任务该去哪个调度器报到?

我们下一篇见,祝大家早安午安晚安,祉猷并茂,顺遂无虞。