协程调度器选型与并发模型
调度器(Dispatcher)决定协程在哪个线程池或线程上执行,直接影响并发能力与响应性。Kotlin 提供三种内置调度器,并支持自定义调度器来实现精细化控制。本章从原理、对比、实现、调试四个方面展开,帮助你在实际队列系统中做出最优选择。
4.1 内置调度器原理
| 调度器 | 线程模型 | 默认并行度 | 适用场景 |
|---|---|---|---|
Dispatchers.Default | CPU 密集型任务线程池(线程数 = max(2, 可用核心数)) | CPU 核心数 | 计算密集型、数据解析、排序 |
Dispatchers.IO | IO 密集型任务线程池(默认最多 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 调度器选型决策树
下图帮助你在实际项目中快速锁定调度器:
4.4 自定义调度器实现
当内置调度器无法满足需求(如限制并发数为 1、指定线程名称、固定线程数),可使用以下方式创建自定义调度器:
| 方法 | 说明 | 示例 |
|---|---|---|
newSingleThreadContext("name") | 创建一个仅含一个线程的调度器,适合单线程有序执行 | |
newFixedThreadPoolContext(n, "name") | 固定 n 个线程的调度器,适合控制严格并行度 | |
Dispatchers.IO.limitedParallelism(n) | 对 IO 调度器添加并发限制(Kotlin 1.7+) | |
Executors.newXxx().asCoroutineDispatcher() | 基于 Java ExecutorService 封装 | |
⚠️ 关闭资源:使用
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()。
选择正确的调度器是构建高性能后台队列的第一步。下一章将深入协程作用域与结构化并发,确保你可以安全地管理协程生命周期。