调度器与线程模型
2.1 调度器的核心作用
协程调度器(CoroutineDispatcher)是协程框架中决定协程在哪个线程或线程池上执行的核心组件。每个协程都必须关联一个调度器,即使不显式指定,也会绑定默认调度器。调度器的本质是 执行上下文的线程绑定器,它在协程的挂起与恢复点插入线程切换逻辑。
- 线程分派:将协程的执行上下文绑定到特定线程或线程池。
- 并发控制:管理线程池大小、任务队列和执行策略。
- 挂起恢复拦截:在协程挂起和恢复时插入线程重新分派。
- 资源隔离:通过不同的调度器分离 IO 密集型与 CPU 密集型任务。
设计启示:调度器将“做什么”(协程逻辑)与“在哪做”(线程)彻底解耦。编写协程代码时只需关注业务语义,线程归属由调度器透明处理。
2.2 四大内置调度器
Kotlin 协程库提供了四种开箱即用的调度器,覆盖绝大部分应用场景。下表从线程模型、并发上限和适用场景三个维度进行对比:
| 调度器 | 底层线程池 | 最大并发数 | 典型场景 | 关键特征 |
|---|---|---|---|---|
Dispatchers.Main |
主线程(单线程) | 1 | UI 操作、界面更新 | 单线程串行,Android/JavaFX 专用 |
Dispatchers.IO |
缓存型线程池 | 64(默认上限) | 网络请求、文件读写、数据库 | 动态扩缩,空闲线程可回收 |
Dispatchers.Default |
CPU 密集型线程池 | CPU 核心数(最小 2) | 数据计算、排序、加密 | 有界并发,避免 CPU 过度竞争 |
Dispatchers.Unconfined |
当前调用线程 | N/A | 单元测试、快速验证 | 不切换线程,无调度开销 |
Dispatchers.Unconfined 是一个特殊的“非受限”调度器:协程启动时在调用者线程执行,遇到第一个挂起点后,恢复时可能跑在任意线程上。适合对线程归属不敏感的临时场景。
生产环境避坑:
Dispatchers.Unconfined 不保证线程一致性,切勿用于 UI 操作或需要线程绑定的资源访问。在 Android 上使用 Dispatchers.Main 以外的调度器更新 View 会直接崩溃。
2.3 线程切换背后的原理
线程切换是调度器在协程的 挂起—恢复循环 中插入的线程重新分派行为。理解这一过程需要看清三个关键环节:挂起拦截、调度器决策、线程恢复。
- 挂起点触发:协程执行到
delay()、withContext()等挂起函数,调用suspendCancellableCoroutine进入挂起状态。 - 调度器拦截:框架调用当前调度器的
interceptContinuation()方法,将后续的Continuation包装为调度器内部的DispatchedContinuation。 - 线程释放:协程挂起,当前线程被释放回线程池或继续执行其他任务。
- 恢复触发:挂起条件满足(例如 delay 超时),调度器根据类型选择目标线程池或线程。
- 线程切换:调度器将
DispatchedContinuation投递到目标线程的执行队列。 - 恢复执行:目标线程从队列中取出协程,调用
resumeWith()继续执行后续代码。
上图完整展示了协程从创建到执行经过的调度链路:协程任务进入调度器入口后,根据调度器类型被分派到对应的线程池或线程;自定义调度器与内置调度器同级,可无缝接入同一分发体系。
核心洞察:线程切换的代价主要在“跨线程投递 + 缓存失效”。
Dispatchers.Default 与 Dispatchers.IO 共享同一个线程池,但使用不同的并发限制器,避免相互阻塞。源码中两个调度器的 CoroutineDispatcher 实现都基于 ExperimentalCoroutineDispatcher。
2.4 自定义协程调度器
当内置调度器无法满足业务需求(如限速、隔离、优先级队列)时,可以通过两种方式自定义调度器。
方式一:基于 Java 线程池转换
import kotlinx.coroutines.*
import java.util.concurrent.*
// 创建固定大小为 4 的线程池,并转换为协程调度器
val myDispatcher = Executors.newFixedThreadPool(4) { runnable ->
Thread(runnable, "MyPool-${Thread.activeCount()}").also { it.isDaemon = true }
}.asCoroutineDispatcher()
// 使用自定义调度器
fun main() = runBlocking {
repeat(8) { index ->
launch(myDispatcher) {
println("Task $index on ${Thread.currentThread().name}")
delay(200)
}
}
}
方式二:直接继承 CoroutineDispatcher
import kotlinx.coroutines.*
import kotlin.coroutines.*
class BoundedDispatcher(private val maxConcurrency: Int) : CoroutineDispatcher() {
private val semaphore = java.util.concurrent.Semaphore(maxConcurrency)
override fun dispatch(context: CoroutineContext, block: Runnable) {
semaphore.acquire()
threadPool.execute {
try {
block.run()
} finally {
semaphore.release()
}
}
}
companion object {
private val threadPool = Executors.newCachedThreadPool { r ->
Thread(r, "Bounded-${Thread.activeCount()}").also { it.isDaemon = true }
}
}
}
fun main() = runBlocking {
val bounded = BoundedDispatcher(2)
repeat(6) {
launch(bounded) {
println("Bounded task on ${Thread.currentThread().name}")
delay(300)
}
}
}
自定义调度器陷阱:
- 务必设置线程为守护线程(
isDaemon = true),否则 JVM 不会退出。 - 自定义调度器不继承内置调度器的协作式取消特性,需自行检查
isActive。 - 避免在
dispatch()中执行耗时逻辑,该方法在调用者线程同步执行。
2.5 调试与验证
确认协程所在线程是诊断并发问题的基本功。以下模板可在任意协程中打印当前线程信息:
import kotlinx.coroutines.*
fun main() = runBlocking<Unit> {
println("[${Thread.currentThread().name}] runBlocking 启动")
launch(Dispatchers.Default) {
println("[${Thread.currentThread().name}] Default 执行计算")
delay(50)
withContext(Dispatchers.IO) {
println("[${Thread.currentThread().name}] IO 读写文件")
}
println("[${Thread.currentThread().name}] 回到 Default")
}
launch(Dispatchers.Unconfined) {
println("[${Thread.currentThread().name}] Unconfined 首次执行")
delay(20)
println("[${Thread.currentThread().name}] Unconfined 恢复执行")
}
delay(200)
println("[${Thread.currentThread().name}] 主协程结束")
}
典型输出(线程名因环境而异):
[main @coroutine#1] runBlocking 启动
[DefaultDispatcher-worker-1 @coroutine#2] Default 执行计算
[main @coroutine#4] Unconfined 首次执行
[DefaultDispatcher-worker-2 @coroutine#4] Unconfined 恢复执行
[DefaultDispatcher-worker-1 @coroutine#2] IO 读写文件
[DefaultDispatcher-worker-1 @coroutine#2] 回到 Default
[main @coroutine#1] 主协程结束
调试技巧:在 IntelliJ IDEA 的“Coroutines”面板中可直接查看每个协程的调度器、状态和挂起栈帧;配合
-Dkotlinx.coroutines.debug 启动参数,线程名会自动追加协程标签(如 @coroutine#2),无需手动打印。
通过线程名可以清晰验证:Dispatchers.Default 运行在 DefaultDispatcher 工作线程;Dispatchers.IO 可能复用同一线程池但标记不同;Dispatchers.Unconfined 首次执行在 main 线程,恢复后跑在 worker 线程——这正是“非受限”的体现。