协程调度器选型与并发模型

调度器(Dispatcher)决定协程在哪个线程池或线程上执行,直接影响并发能力与响应性。Kotlin 提供三种内置调度器,并支持自定义调度器来实现精细化控制。本章从原理、对比、实现、调试四个方面展开,帮助你在实际队列系统中做出最优选择。

4.1 内置调度器原理

调度器线程模型默认并行度适用场景
Dispatchers.DefaultCPU 密集型任务线程池(线程数 = max(2, 可用核心数))CPU 核心数计算密集型、数据解析、排序
Dispatchers.IOIO 密集型任务线程池(默认最多 64 线程,可调整)64文件读写、网络请求、数据库操作
Dispatchers.Unconfined不固定线程:协程在调用线程启动,挂起后由恢复线程执行不适用测试、调试、或对线程无依赖的极轻量任务
核心理念:Dispatchers.Default 和 Dispatchers.IO 共享同一个后台线程池,但通过不同的任务队列隔离 CPU 与 IO 任务。IO 调度器在必要时会临时创建额外线程,避免阻塞 Default 的线程。

4.2 代码示例:三种调度器的行为差异

以下示例分别使用三种调度器启动协程,观察线程名称变化:

// 1. Dispatchers.Default
launch(Dispatchers.Default) {
    println("Default: running on ${Thread.currentThread().name}")
}

// 2. Dispatchers.IO
launch(Dispatchers.IO) {
    println("IO: running on ${Thread.currentThread().name}")
}

// 3. Dispatchers.Unconfined
launch(Dispatchers.Unconfined) {
    println("Unconfined (start): ${Thread.currentThread().name}")
    delay(10)
    println("Unconfined (after delay): ${Thread.currentThread().name}")
}

输出示例(不同机器线程名略有差异):

Default: running on DefaultDispatcher-worker-1
IO: running on DefaultDispatcher-worker-2
Unconfined (start): main
Unconfined (after delay): kotlinx.coroutines.DefaultExecutor

可见 Unconfined 在挂起恢复后线程发生变化,而 Default 和 IO 始终在各自的 worker 线程上执行。

4.3 调度器选型决策树

下图帮助你在实际项目中快速锁定调度器:

调度器选型决策流程 任务类型分析 CPU 密集型? Dispatchers.Default IO 阻塞型? Dispatchers.IO 需要固定线程/限制并发? 自定义调度器 Dispatchers.Unconfined 💡 决策要点: • CPU 密集 → Default(避免 IO 线程池干扰) • IO 阻塞 → IO(支持大量挂起线程) • 特殊控制 → 自定义调度器 • 测试/调试 → Unconfined

4.4 自定义调度器实现

当内置调度器无法满足需求(如限制并发数为 1、指定线程名称、固定线程数),可使用以下方式创建自定义调度器:

方法说明示例
newSingleThreadContext("name")创建一个仅含一个线程的调度器,适合单线程有序执行
val single = newSingleThreadContext("MySingle")
launch(single) { /* 串行 */ }
newFixedThreadPoolContext(n, "name")固定 n 个线程的调度器,适合控制严格并行度
val fixed = newFixedThreadPoolContext(3, "MyPool")
launch(fixed) { /* 最多3个线程 */ }
Dispatchers.IO.limitedParallelism(n)对 IO 调度器添加并发限制(Kotlin 1.7+)
val limited = Dispatchers.IO.limitedParallelism(2)
launch(limited) { /* 最多2个IO任务同时运行 */ }
Executors.newXxx().asCoroutineDispatcher()基于 Java ExecutorService 封装
val executor = Executors.newCachedThreadPool()
val dispatcher = executor.asCoroutineDispatcher()
launch(dispatcher) { /* 使用CachedThreadPool */ }
⚠️ 关闭资源:使用 newSingleThreadContext 或 newFixedThreadPoolContext 创建的自定义调度器不会自动关闭,必须显式调用 close() 或在协程作用域中使用 use 块,否则将导致线程泄漏。

4.5 并发模型对比与选择

  • Default + IO 隔离:推荐在后台队列系统中将 CPU 任务与 IO 任务分离,分别使用 withContext(Dispatchers.Default) 和 withContext(Dispatchers.IO) 包装,避免 IO 阻塞耗尽 Default 线程。
  • 限制并发数:对数据库写入、第三方 API 调用等需限制吞吐量的场景,使用 limitedParallelism() 或 Semaphore 包裹协程。
  • 单线程串行:需要严格顺序执行(如日志写入、状态机更新)使用 newSingleThreadContext。
  • 避免 Unconfined 在生产环境使用:因为线程不可控,可能导致意外竞争或阻塞 UI 线程。

4.6 调试与性能优化

调试技巧:

  • 启动 JVM 参数 -Dkotlinx.coroutines.debug 会在协程创建和恢复时打印线程名与协程名称。
  • 使用 CoroutineName("MyCoroutine") 上下文元素为协程命名,便于日志追踪。
  • 自定义调度器可通过重写 toString() 或设置线程工厂的 Thread.name 标识线程来源。

性能优化建议:

  • 优先使用 Dispatchers.Default 和 Dispatchers.IO 内置共享池,避免自建线程池带来额外开销。
  • 对于高频小任务,使用 Dispatchers.Default 而不额外切线程;对于长时间阻塞任务,务必切到 Dispatchers.IO。
  • 自定义线程池设置 keepAliveTime 为较短值(如 5 秒),避免空闲线程常驻。

4.7 常见错误与避坑

  • 在 Dispatchers.IO 中执行 CPU 密集任务:导致 IO 线程池被计算任务占满,阻塞其他 IO 请求。应使用 withContext(Dispatchers.Default) 包裹。
  • 忘记关闭自定义调度器:线程泄漏,应用内存持续增长。始终使用 use 或 close()。
  • 过度使用 Unconfined:在挂起后线程切换不可预测,可能破坏线程局部变量(如数据库连接绑定线程)。
  • 误以为 IO 调度器无限线程:默认最大 64 线程,超出会挂起等待;可通过 limitedParallelism 显式控制。
  • 在协程内部调用 Thread.sleep():会阻塞底层线程,破坏协程的非阻塞特性。应改用 delay()。

选择正确的调度器是构建高性能后台队列的第一步。下一章将深入协程作用域与结构化并发,确保你可以安全地管理协程生命周期。