协程中的Dispatcher

0 阅读4分钟

1) Dispatcher 是什么

CoroutineDispatcher 实质是 ContinuationInterceptor:

  • 决定协程在哪个线程(池)执行与恢复

  • 调度策略(立即/投递、队列、合并等);

  • 与挂起函数配合:挂起点之后的恢复也受它控制

常见切换方式:launch(context) / async(context) / withContext(context);Flow 用 flowOn(context) 切上游。


2) 内置调度器与适用场景

Dispatchers.Main(Android)

  • 绑定 主线程 Looper(来自 kotlinx-coroutines-android)。

  • 用于 UI 更新、生命周期回调、轻量逻辑

  • Main.immediate:若已在主线程,直接(不经消息队列)执行,减少一次切换;否则还是投递到主线程。

    • 典型用法:viewModelScope.launch(Dispatchers.Main.immediate) { … },避免在快速连续回调里反复切主线程导致闪动/抖动。

Dispatchers.Default(CPU 密集)

  • 面向 CPU 计算(解析、排序、diff、压缩、加密、JSON 转换…)。

  • 线程数≈CPU 核心数(至少 2),共享池,公平调度。

  • 不要做阻塞 I/O(会占满计算位点);如需阻塞,改用 IO。

Dispatchers.IO(阻塞 I/O)

  • 目标:文件/网络/数据库等“可能阻塞线程”的操作

  • 与 Default 共享底层池,但 允许更高并行度 以“吸收”阻塞任务(避免拖慢 CPU 计算)。

  • 若你调用的是真正挂起的非阻塞 I/O(如 Retrofit + 挂起),放 IO 或 Default 都行;但一旦可能阻塞(老库、JNI、系统调用)就放 IO

Dispatchers.Unconfined(不限定)

  • 启动时在当前线程执行首次挂起后挂起函数的恢复线程决定去哪。
  • 易造成线程漂移不可预测时序不要用于 UI/生产代码;仅用于特定测试或底层框架代码。

3) Android 细节与常用搭配

  • lifecycleScope.launch(Main):启动 UI 协程;耗时用 withContext(Default/IO) 切出去再回来。
  • viewModelScope 默认在 Main;在 VM 内进行短任务:withContext(Default) 做计算;withContext(IO) 读写本地/网络。
  • 避免在 Main 中阻塞(如 Thread.sleep / 同步 I/O / 大循环),否则掉帧和 ANR。
  • Main.immediate 用于去抖/UI原地更新,例如快速合流的 StateFlow 收集场景可减少一次切换。

4) 上下文切换语义

  • withContext(X):在同一协程内切到 X,并在块结束后切回原 Dispatcher
  • launch/async(X):新协程继承父上下文,除非你显式传入 X(覆盖)。
  • 嵌套切换可读性差且有开销;把“在哪里执行”的决策放在边界层(仓库/UseCase/数据源)。

5) 并发控制 & 自定义

limitedParallelism

  • 给任何 dispatcher 限流
val singleIO = Dispatchers.IO.limitedParallelism(1) // 串行化一个资源
withContext(singleIO) { writeFileAThenB() }
  • 比 newSingleThreadContext 更推荐(后者容易泄漏,需手动 close)。

与 Executor 互转

val dispatcher = myExecutor.asCoroutineDispatcher()
// or
val dispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher()

仍建议优先用 Default/IO + limitedParallelism,除非你需要绑定特定线程或已有线程池生态。


6) Flow 与 Dispatcher

  • flowOn(context):改变上游(生产/变换)的执行上下文;下游收集仍在调用者的上下文。
flow { emit(loadDb()) }          // 默认跟随 collector
  .map { heavyCompute(it) }      // 也会被切到 Default
  .flowOn(Dispatchers.Default)   // 上游生产+map 在 Default
  .collect { renderOnUi(it) }    // 收集仍在 Main
  • launchIn(scope):收集发生在 scope 的上下文。

  • 高频 UI 列表:在 ViewModel 用 flowOn(Default) 做变换、buffer() 平滑背压,UI 层 collectLatest。


7) 性能与正确性常见坑

  1. 在 Main 做阻塞 → 掉帧/ANR。用 withContext(IO/Default)。
  2. 在 Default 做阻塞 I/O → 抢占 CPU 池,拖慢计算任务。用 IO。
  3. 滥用 Unconfined → 非确定性线程切换/竞态。
  4. 创建过多专用线程(newSingleThreadContext)→ 资源泄漏。改 limitedParallelism(1)。
  5. 过度切换(层层 withContext)→ 可读性差 + 调度开销。把切换收敛到边界。
  6. 误解 Flow 线程:flowOn 只影响上游;UI 更新仍需在 Main。
  7. 阻塞式同步库放 Default:应包一层 withContext(IO);若库可回调/异步,优先改造为挂起。

8) 调试与测试

  • 线程名带协程调试信息:JVM 启动参数 -Dkotlinx.coroutines.debug(线程名会附 @coroutine#id)。
  • 单元测试替 Main:Dispatchers.setMain(TestDispatcher),结束后 Dispatchers.resetMain()。
  • 观察切换:在关键 withContext 前后打点;或用 DebugProbes(调试构建)。

9) 选型速查(Cheat Sheet)

  • UI 更新/收集状态 → Main(必要时 Main.immediate)。
  • CPU 密集(解析、Diff、加解密、图片处理)→ Default。
  • 阻塞 I/O(老式文件/Socket/DB 驱动、JNI 调用)→ IO。
  • 串行访问某资源(日志文件/同一表)→ Dispatchers.IO.limitedParallelism(1)。
  • 避免 → Unconfined(除非你清楚恢复线程语义)。

10) 代码模板(Android ViewModel)

class ProfileVM : ViewModel() {
  val ui = MutableStateFlow<UiState>(UiState.Loading)

  fun load() = viewModelScope.launch(Dispatchers.Main) {
    val user = withContext(Dispatchers.IO) { dao.queryUser() }        // 阻塞 I/O
    val diff = withContext(Dispatchers.Default) { computeDiff(user) } // CPU 计算
    ui.value = UiState.Ready(diff)
  }
}