协程深度解析:明明只有 8 个异步任务,为何 App 线程数瞬间突破 一倍?

1 阅读4分钟

在 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 对比

维度ViewModelApplication
生命周期
并发方式分散触发一次爆发
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

这个是源码里的场景 image.png

这是去除在viewModel 里去除

launch(Dispatchers.IO)

和 Application 里换成

private val applicationScope = CoroutineScope(SupervisorJob() + appExecutor.asCoroutineDispatcher())

的场景 image.png