炉 IV · 浇铸CH 16进度 16/26

调度器:线程、withContext 与 Android 主线程

协程不需要线程 —— 直到它需要。Dispatcher 就是那个决定「这段代码在哪个线程上跑」的东西,而它是 Android 协程代码里被误用最多的部分:到处包 withContext(Dispatchers.IO)(其中一半是多余的)、把 CPU 密集的活扔进 IO 池、在 Dispatchers.IO 里跑一百个并发请求把线程池撑爆。这一章讲清四个调度器的真实分工,然后给你一条能消灭一半 withContext 的原则:主安全(main-safety)

Dispatchers主安全withContextlimitedParallelism

四个调度器,各司其职

调度器线程数给什么用
Dispatchers.Default= CPU 核心数(至少 2)CPU 密集:排序大列表、解析大 JSON、图片处理、加密。线程数等于核心数是有道理的 —— 再多也跑不快,只会增加上下文切换
Dispatchers.IO默认上限 64(或核心数,取大者)阻塞式 IO:文件读写、老的阻塞式网络库、SQLite(Room 的底层)。线程多是因为它们大部分时间在等,不占 CPU
Dispatchers.Main1(Android 主线程)碰 UI 的一切
Dispatchers.Unconfined别用。它在「调用它的那个线程」上恢复,行为难以预测。只在测试和某些库的内部实现里有意义

一个很多人不知道的细节:IODefault 共享同一个底层线程池。它们只是在这个池子上设置了不同的并行度上限。所以从 Default 切到 IO(或反过来)通常不会真的切换线程 —— 只是换了个「配额」。这让 withContext 的开销比你想象的低。

◆ 锭 · 主安全(main-safety)原则

一个 suspend 函数,应该可以从任何线程(包括主线程)安全调用,而不会阻塞它。—— 也就是说:切线程是被调用者的责任,不是调用者的。

// ✗ 坏:把责任推给每一个调用方
suspend fun loadUser(id: String): User = api.get(id)      // 内部是阻塞的!
// 于是每个调用点都得记得包一层:
viewModelScope.launch { withContext(Dispatchers.IO) { loadUser(id) } }   // 忘一次就 ANR

// ✓ 好:函数自己管好自己
suspend fun loadUser(id: String): User = withContext(Dispatchers.IO) {
    api.get(id)                       // 阻塞调用被在函数内部
}
// 调用点干干净净,而且不可能用错:
viewModelScope.launch { loadUser(id) }

这一条原则能让你的 ViewModel 里几乎不再出现 withContext它们应该全部下沉到 Repository / 数据源层。ViewModel 里满屏 withContext(Dispatchers.IO),是一个明确的架构坏味道。

那一半多余的 withContext

推论来了:如果一个库已经是主安全的,你就不该再包 withContext而 Android 上你最常用的那几个库,早就是主安全的了

需要 withContext(IO) 吗说明
Retrofit 的 suspend 函数不需要它内部已经在自己的线程上执行请求
Room 的 suspend DAO / Flow不需要Room 自己会切到它的查询执行器上。手动再包一层不但多余,还可能干扰它的事务线程绑定
DataStore不需要同上
Ktor client不需要同上
老的阻塞式 API(File.readText()SharedPreferences.commit()、某些第三方 SDK)需要这才是 withContext(Dispatchers.IO) 真正的战场
CPU 密集的纯计算需要,但用 Default见下面的炉渣

去搜一下你的 Repository —— 我打赌里面有一批 withContext(Dispatchers.IO) { api.getUser() }它们是无害的,但它们是噪音:它们在告诉读者「这里有阻塞风险」,而事实上没有。

⚠ 炉渣 · 别把 CPU 密集的活扔进 IO
withContext(Dispatchers.IO) {
    hugeList.sortedBy { it.score }      // ✗ 纯 CPU 计算,扔进了 64 线程的 IO 池
}

后果:你可能同时启动几十个这样的任务,它们全都在争抢 CPU(因为 IO 池允许 64 个并行)。而 CPU 核心只有 8 个 —— 于是大量的上下文切换,整体反而更慢,还会饿死其他任务。

Default 的线程数等于核心数,正是为了防止这件事。判据一句话:这段代码在「等」(IO),还是在「算」(CPU)?等 → IO;算 → Default

limitedParallelism:别再自建线程池

「我想限制这个操作最多 4 个并发」—— 以前你会 Executors.newFixedThreadPool(4).asCoroutineDispatcher(),然后自己负责关闭它(忘了就泄漏一个线程池)。

// 现在:从共享池里切一块出来,不创建任何新线程
val dbDispatcher = Dispatchers.IO.limitedParallelism(4)      // 数据库最多 4 个并发
val uploadDispatcher = Dispatchers.IO.limitedParallelism(2)  // 上传最多 2 个

withContext(dbDispatcher) { dao.query() }

它是一个视图,不是一个新池子 —— 底层还是那个共享线程池,只是限制了「同时最多几个任务在跑」。不需要关闭、不会泄漏、没有额外线程。这是替代自建线程池的标准答案。

Android 主线程:MainMain.immediate

withContext(Dispatchers.Main) { textView.text = name }
withContext(Dispatchers.Main.immediate) { textView.text = name }

差别:Main 永远把任务 post 到主线程的消息队列(哪怕你已经在主线程上);Main.immediate 会先检查 —— 如果已经在主线程,直接执行,省掉一次 post

省掉的这一次 post 意味着:状态更新会在当前帧内完成,而不是等到下一次消息循环。在高频更新(滚动、动画、输入)时,这个差别是可以被看见的(少一帧的延迟)。

Android 的 lifecycleScopeviewModelScope 默认都用 Main.immediate —— Google 已经替你选好了。所以你几乎不需要手写 withContext(Dispatchers.Main):从 viewModelScope.launch 出来的代码本来就在主线程上。

CoroutineContext:不只是 Dispatcher

Dispatcher 只是 CoroutineContext一个元素。这个 Context 是一个「元素集合」(有点像一个类型安全的 Map),常见的元素有四种:

launch(Dispatchers.IO + CoroutineName("sync") + SupervisorJob() + handler) { }
//     ^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^  ^^^^^^^
//     在哪跑             调试用的名字             生命周期(第15章)   异常处理(第17章)

那个 + 是重载的运算符(第 11 章)—— 它在合并上下文元素。理解了这一点,你就理解了 withContext(x) 的本质:它是「用一个新的上下文运行这段代码,完了再切回来」,而不只是「切线程」。

CoroutineName 值得单独提一句:它会出现在调试器和崩溃日志里。给关键协程起个名字,出事时能省你半小时。

可测试性:注入你的调度器

硬编码 Dispatchers.IO 会让单测变得很难(你控制不了线程调度,测试变得不确定、还慢)。把调度器当依赖注入

class UserRepo(
    private val io: CoroutineDispatcher = Dispatchers.IO      // 默认值给生产用
) {
    suspend fun load(id: String) = withContext(io) { ... }
}

// 测试里
@Test fun test() = runTest {
    val repo = UserRepo(StandardTestDispatcher(testScheduler))   // 完全可控
    // runTest 还会自动跳过所有 delay —— 一个 delay(5000) 的测试瞬间跑完
}

runTest 的「跳过 delay」是个大杀器:那些为了测超时/重试而不得不真等 5 秒的测试,现在是毫秒级的。如果你的协程测试还在用 runBlocking + Thread.sleep,那是一整个时代之前的写法了。

▸ 旧模具 · 从 AsyncTask 到今天

快速回忆一下 Android 异步的进化史,你会更明白协程解决了什么:

  • AsyncTask(已废弃):线程切换是写死在类结构里的(doInBackground / onPostExecute)—— 想改个线程池?重写整个类。而且它不知道生命周期,泄漏 Activity 是家常便饭。
  • Handler + Thread:全靠手工,正确性完全依赖程序员的记性。
  • RxJavasubscribeOn / observeOn —— 强大,但那两个操作符的语义差别,每个人都要被坑一次才记住(一个影响上游,一个影响下游,而且 subscribeOn 只有第一个生效)。
  • 协程withContext —— 它的作用范围就是那对花括号,没有上游下游之分,代码写在哪儿就在哪儿生效。

「线程切换的作用域 = 词法作用域」—— 这就是协程在这件事上赢的全部理由。它符合你读代码的直觉。

✚ 动手锻打

1. 搜项目里所有的 withContext(Dispatchers.IO),逐个问:里面包的是 Retrofit / Room / DataStore 吗?是的话,删掉它(它们已经主安全)。剩下的,检查是不是在 ViewModel 层 —— 是的话,把它下沉到 Repository。

2. 搜有没有把纯计算放进 Dispatchers.IO 的(sortedBymap 大列表、JSON 解析、图片压缩)。换成 Default

3. 搜自建的线程池(Executors.new*asCoroutineDispatcher())。能不能换成 Dispatchers.IO.limitedParallelism(n)?—— 少一个要管理生命周期的对象。

4. 挑一个 Repository,把 dispatcher 改成构造函数注入,然后用 runTest 重写它的测试。如果原来的测试里有 Thread.sleep 或者真实的 delay 等待,看看能省下多少秒。

下一章:协程里最反直觉的一课 —— try/catch 包住 async 为什么抓不到异常。