Wrapping an Already-Async TaskRunner in Dispatchers.IO Doubles Your App's Thread Count
In-Depth Analysis of Coroutines: With Only 8 Async Tasks, Why Did the App's Thread Count Instantly Double?
In Kotlin coroutines, there is a very subtle but real problem:
You just added
Dispatchers.IO, and the thread count increased — not by a little, but exponentially.
More critically:
👉 This problem is more severe during the Application startup phase than in a ViewModel.
0. First, Clarify the "Real Business Scenario"
To make the problem closer to real engineering, let's assume a typical App:
App Startup & UI Initialization Behavior
When the entire application starts, it triggers two types of concurrent tasks simultaneously:
① Application Startup Phase (Global Initialization)
App starts → Automatically triggers 4 concurrent initialization tasks
For example:
- Database initialization
- SDK initialization
- Configuration loading
- Logging system initialization
② UI Page Startup Phase (Business Concurrency)
Entering the home page → ViewModel automatically triggers 4 concurrent requests
For example:
- User information
- Home page list
- Recommendation feed
- Unread messages
👉 Two phases total: 8 concurrent tasks
We Simulate with a Unified Task Model
To simplify the problem, we define a task executor:
object TaskRunner {
private val executor = Executors.newFixedThreadPool(10) {
Thread(it, "TaskRunner-Pool")
}
suspend fun test(time: Long): String =
withContext(executor.asCoroutineDispatcher()) {
Thread.sleep(time) // Simulate real IO
"done on ${Thread.currentThread().name}"
}
}
Key Points
✔ TaskRunner already has its own thread pool ✔ It is already a complete execution model ✔ It already "knows how to run itself"
This simulates time-consuming tasks like network requests, e.g., Retrofit.
1. ViewModel Scenario: UI Concurrent Requests
Home page ViewModel:
viewModelScope.launch {
launch(Dispatchers.IO) {
repeat(4) {
launch {
TaskRunner.test(1000)
}
}
}
}
Thread Phenomenon
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
🔍 Execution Chain
Main
→ Dispatchers.IO (Scheduling layer)
→ Default Dispatcher (Relay layer)
→ TaskRunner Pool (Actual execution)
The Essence of the Problem
IO does not perform any business execution here; it only does three things:
- Forwards tasks
- Wakes up workers
- Switches contexts
👉 But the real work is done by TaskRunner
2. Application Scenario: The Problem Starts to Amplify (Critical)
When the App starts:
class App : Application() {
val appScope = CoroutineScope(SupervisorJob())
override fun onCreate() {
super.onCreate()
appScope.launch(Dispatchers.IO) {
repeat(4) {
launch {
TaskRunner.test(1000)
}
}
}
}
}
What Happens During the Application Phase?
The moment the App starts:
The system automatically triggers:
→ Initializes 4 concurrent tasks
Application Thread Phenomenon
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. Key Difference: Why Application "Explodes" More?
✔ Characteristic 1: No UI Rhythm Control
ViewModel:
- Page triggered
- User behavior driven
- Has lifecycle cancel
Application:
- One-time startup
- No throttling
- Global Scope
👉 All tasks are fired instantly
✔ Characteristic 2: Initialization Tasks Are Naturally IO-Intensive
Typical App initialization:
- DB init
- SDK init
- config load
- log system
- network
Developer's first reaction:
Dispatchers.IO
✔ Characteristic 3: Deeper Task Nesting
launch(Dispatchers.IO) {
initA()
initB()
initC()
}
Each init might also:
- launch(IO) again
- withContext(IO) again
- collect Flow again
👉 Application directly forms:
IO → IO → IO → Executor
4. Core Problem: Scheduling Redundancy
The real problem is not too many threads, but:
❗ The same task is being "relayed" repeatedly by multiple Dispatchers
❌ Wrong Perception
Dispatchers.IO = Improves performance
❌ What Actually Happens
launch(IO)
→ launch(Default)
→ withContext(executor)
👉 Result:
- 3 layers of scheduling
- Multiple thread switches
- Workers idling
5. The Most Critical Phenomenon: A Large Number of "Threads That Don't Do Work"
You will see:
DefaultDispatcher-worker-1 | TIMED_WAITING
DefaultDispatcher-worker-2 | TIMED_WAITING
DefaultDispatcher-worker-3 | TIMED_WAITING
❗ What Are These Threads Doing?
The answer is:
👉 They are "relaying tasks," not executing them
6. ViewModel vs Application Comparison
| Dimension | ViewModel | Application |
|---|---|---|
| Lifecycle | Short | Long |
| Concurrency Method | Scattered triggers | One-time burst |
| IO Usage | Local | Global |
| Control Capability | cancel | No cancel |
| Thread Risk | Medium | High |
👉 The essence of Application:
No rhythm, only a flood peak
7. 🔥 Essential Summary
The real problem is not:
❌ Too much concurrency
But:
❗ You let a system that already has execution capability be scheduled again by Dispatchers.IO
❌ Typical Anti-Pattern
launch(Dispatchers.IO) {
TaskRunner.test()
}
But TaskRunner itself already has:
withContext(executor)
👉 Equivalent to:
IO scheduling a task that IO has already scheduled
8. ✔ Correct Principles
✔ Principle 1: Do Not Uniformly IO-ify Application
appScope.launch {
TaskRunner.test()
}
✔ Principle 2: IO Is Only for "Blocking Points"
withContext(Dispatchers.IO) {
File.read()
}
✔ Principle 3: Avoid IO Wrapping IO
❌ IO → IO → Executor
✔ Principle 4: Execution Model Should Be Pushed Down to the Data Layer
suspend fun test() =
withContext(executor) { }
9. Conclusion
👉 When the bottom layer is already an "asynchronous execution model," the thread scheduling of the upper scope should be constrained to a small range.
Source code: ScopeSample
This is the scenario in the source code
This is the scenario after removing
launch(Dispatchers.IO)
in the ViewModel and replacing it in Application with
private val applicationScope = CoroutineScope(SupervisorJob() + appExecutor.asCoroutineDispatcher())
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Good code example.