在 Kotlin 协程中,有一个非常隐蔽但真实存在的问题:
你只是加了
Dispatchers.IO,线程数却变多了,多的不是一点点,而是成倍增长
更关键的是:
👉 这个问题在 Application 启动阶段,比 ViewModel 更严重
0. 先讲清楚“业务真实场景”
为了让问题更贴近真实工程,我们假设一个典型 App:
App 启动 & UI 初始化行为
整个应用在启动时,会同时触发两类并发任务:
① Application 启动阶段(全局初始化)
启动 App → 自动触发 4 个并发初始化任务
例如:
- 数据库初始化
- SDK 初始化
- 配置加载
- 日志系统初始化
② UI 页面启动阶段(业务并发)
进入首页 → ViewModel 自动触发 4 个并发请求
例如:
- 用户信息
- 首页列表
- 推荐流
- 未读消息
👉 两个阶段一共:8 个并发任务
我们用统一任务模型模拟
为了简化问题,我们定义一个任务执行器:
object TaskRunner {
private val executor = Executors.newFixedThreadPool(10) {
Thread(it, "TaskRunner-Pool")
}
suspend fun test(time: Long): String =
withContext(executor.asCoroutineDispatcher()) {
Thread.sleep(time) // 模拟真实 IO
"done on ${Thread.currentThread().name}"
}
}
关键点
✔ TaskRunner 已经自带线程池 ✔ 已经是完整执行模型 ✔ 已经“会自己跑”
这是模拟 网络请求的耗时任务,比如Retrofit.
1. ViewModel 场景:UI 并发请求
首页 ViewModel:
viewModelScope.launch {
launch(Dispatchers.IO) {
repeat(4) {
launch {
TaskRunner.test(1000)
}
}
}
}
线程现象
DefaultDispatcher-worker-1 | TIMED_WAITING
DefaultDispatcher-worker-2 | TIMED_WAITING
DefaultDispatcher-worker-3 | TIMED_WAITING
DefaultDispatcher-worker-4 | TIMED_WAITING
TaskRunner-Pool-1 | WAITING
TaskRunner-Pool-2 | WAITING
🔍 执行链路
Main
→ Dispatchers.IO(调度层)
→ Default Dispatcher(中转层)
→ TaskRunner Pool(真正执行)
问题本质
IO 在这里没有做任何业务执行,只做了三件事:
- 转发任务
- 唤醒 worker
- 切换上下文
👉 但真正干活的是 TaskRunner
2. Application 场景:问题开始放大(关键)
App 启动时:
class App : Application() {
val appScope = CoroutineScope(SupervisorJob())
override fun onCreate() {
super.onCreate()
appScope.launch(Dispatchers.IO) {
repeat(4) {
launch {
TaskRunner.test(1000)
}
}
}
}
}
Application 阶段发生了什么?
启动 App 的瞬间:
系统自动触发:
→ 初始化 4 个并发任务
Application 线程现象
DefaultDispatcher-worker-1 | TIMED_WAITING
DefaultDispatcher-worker-2 | TIMED_WAITING
DefaultDispatcher-worker-3 | TIMED_WAITING
DefaultDispatcher-worker-4 | TIMED_WAITING
TaskRunner-Pool-1 | WAITING
TaskRunner-Pool-2 | WAITING
3. 关键差异:为什么 Application 更“炸”?
✔ 特点 1:没有 UI 节奏控制
ViewModel:
- 页面触发
- 用户行为驱动
- 有生命周期 cancel
Application:
- 一次性启动
- 无节流
- 全局 Scope
👉 **所有任务瞬间发射 **
✔ 特点 2:初始化任务天然 IO 密集
典型 App 初始化:
- DB init
- SDK init
- config load
- log system
- network
开发者第一反应:
Dispatchers.IO
✔ 特点 3:任务嵌套更深
launch(Dispatchers.IO) {
initA()
initB()
initC()
}
每个 init 可能还会:
- 再 launch(IO)
- 再 withContext(IO)
- 再 collect Flow
👉 Application 直接形成:
IO → IO → IO → Executor
4. 核心问题:调度冗余
真正问题不是线程多,而是:
❗ 同一个任务被多个 Dispatcher 重复“搬运”
❌ 错误认知
Dispatchers.IO = 提升性能
❌ 实际发生
launch(IO)
→ launch(Default)
→ withContext(executor)
👉 结果:
- 3 层调度
- 多次线程切换
- worker 空转
5. 最关键现象:大量“不会干活的线程”
你会看到:
DefaultDispatcher-worker-1 | TIMED_WAITING
DefaultDispatcher-worker-2 | TIMED_WAITING
DefaultDispatcher-worker-3 | TIMED_WAITING
❗ 这些线程在干嘛?
答案是:
👉 在“搬运任务”,而不是执行任务
6. ViewModel vs Application 对比
| 维度 | ViewModel | Application |
|---|---|---|
| 生命周期 | 短 | 长 |
| 并发方式 | 分散触发 | 一次爆发 |
| IO 使用 | 局部 | 全局 |
| 控制能力 | cancel | 无 cancel |
| 线程风险 | 中 | 高 |
👉 Application 本质:
没有节奏,只有洪峰
7. 🔥 本质总结
真正的问题不是:
❌ 并发太多
而是:
❗ 你让一个已经具备执行能力的系统,被 Dispatchers.IO 再调度了一次
❌ 典型反模式
launch(Dispatchers.IO) {
TaskRunner.test()
}
但 TaskRunner 本身已经:
withContext(executor)
👉 等价于:
IO 再调度 IO 已经调度好的任务
8. ✔ 正确原则
✔ 原则 1:Application 不要统一 IO 化
appScope.launch {
TaskRunner.test()
}
✔ 原则 2:IO 只用于“阻塞点”
withContext(Dispatchers.IO) {
File.read()
}
✔ 原则 3:避免 IO 套 IO
❌ IO → IO → Executor
✔ 原则 4:执行模型应该下沉到 Data 层
suspend fun test() =
withContext(executor) { }
9. 结论
👉 当底层已经是“异步执行模型”时,上层scope的线程调度应该被束缚在一个小范围
源码: ScopeSample
这个是源码里的场景
这是去除在viewModel 里去除
launch(Dispatchers.IO)
和 Application 里换成
private val applicationScope = CoroutineScope(SupervisorJob() + appExecutor.asCoroutineDispatcher())
的场景