调度器与线程模型

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 线程切换背后的原理

线程切换是调度器在协程的 挂起—恢复循环 中插入的线程重新分派行为。理解这一过程需要看清三个关键环节:挂起拦截、调度器决策、线程恢复。

  1. 挂起点触发:协程执行到 delay()、withContext() 等挂起函数,调用 suspendCancellableCoroutine 进入挂起状态。
  2. 调度器拦截:框架调用当前调度器的 interceptContinuation() 方法,将后续的 Continuation 包装为调度器内部的 DispatchedContinuation。
  3. 线程释放:协程挂起,当前线程被释放回线程池或继续执行其他任务。
  4. 恢复触发:挂起条件满足(例如 delay 超时),调度器根据类型选择目标线程池或线程。
  5. 线程切换:调度器将 DispatchedContinuation 投递到目标线程的执行队列。
  6. 恢复执行:目标线程从队列中取出协程,调用 resumeWith() 继续执行后续代码。
协程调度器与线程分发模型 协程任务 调度器入口 Dispatchers.Main Dispatchers.IO Dispatchers.Default Dispatchers.Unconfined 主线程(UI) IO 线程池 CPU 线程池 当前线程 自定义调度器 调度器在协程的挂起—恢复循环中插入线程切换逻辑,决定后续执行的线程池或线程

上图完整展示了协程从创建到执行经过的调度链路:协程任务进入调度器入口后,根据调度器类型被分派到对应的线程池或线程;自定义调度器与内置调度器同级,可无缝接入同一分发体系。

核心洞察:线程切换的代价主要在“跨线程投递 + 缓存失效”。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 线程——这正是“非受限”的体现。