Coroutines Don't Beat Threads on Speed — They Beat Asynchronous Complexity
Many people think coroutines are more efficient than threads because:
- They are lighter weight;
- They use fewer threads;
- They switch in user space;
- One thread can run hundreds of thousands of coroutines.
How should I put this... it's complicated.
Actually, these are not the core reasons why Kotlin coroutines changed Android development.
Because:
Thread pools can also reuse threads;
Netty can also handle massive numbers of connections;
Node.js achieved single-threaded high concurrency long ago.
The real efficiency of coroutines lies in:
- They turn asynchronous code back into linear code;
- They turn thread blocking into coroutine suspension;
- They turn synchronous coordination between threads into coordination between coroutines;
- They drastically reduce the number of JUC synchronizers introduced just to organize asynchronous flows.
And these are the things that truly changed the way we do asynchronous development.
1. The biggest cost of the thread era was actually asynchronous complexity
Many people think the biggest cost of the thread model is:
Blocking.
But for the vast majority of Android developers, the real pain point is:
Asynchrony shatters program control flow.
Take the classic callback pattern:
loginAsync { user ->
queryProfileAsync(user.id) { profile ->
queryVipAsync(profile.id) { vip ->
updateUi(vip)
}
}
}
The business logic is actually very simple:
Login
↓
Query user info
↓
Query membership info
↓
Update UI
But the code structure becomes:
callback
└── callback
└── callback
└── callback
And then various problems start to emerge:
- Difficult exception handling;
- Hard-to-manage lifecycles;
- Complex state synchronization;
- Difficult cancellation logic maintenance;
- Sharply rising cognitive load for reading.
What really drains development efficiency is never the threads themselves,
but:
Asynchronous code loses the ability for linear expression.
2. Writing synchronous-style code in the thread era often required various JUC tools
Faced with Callback Hell, a natural thought arises:
Can we rewrite asynchronous code back into synchronous code?
In the thread era, you certainly could.
But it usually required various synchronization tools.
For example:
CountDownLatch latch = new CountDownLatch(1);
loginAsync(user -> {
result = user;
latch.countDown();
});
latch.await();
User user = result;
Or:
User user = future.get();
Or:
User user = blockingQueue.take();
The essence is always:
Async Callback
│
▼
JUC Synchronizer (CountDownLatch, Future, BlockingQueue...)
│
▼
Thread blocking wait
│
▼
Callback notifies thread to continue execution
The code does indeed become:
User user = login();
Profile profile = queryProfile(user.id);
Vip vip = queryVip(profile.id);
updateUi(vip);
But the cost is also very obvious.
To get the readability of synchronous code, we have to:
- Block the current thread;
- Use extra threads to host these waiting tasks to prevent ANR;
- Introduce a large number of JUC synchronization tools to coordinate asynchronous flows.
So projects start to see more and more of:
- CountDownLatch
- Future
- CompletableFuture
- BlockingQueue
- wait / notify
- synchronized
- ReentrantLock
Very often:
These tools are not what the business logic actually needs.
Their sole purpose is just:
To stitch asynchronous callbacks back together into linear code.
More importantly, these synchronization tools themselves carry extra costs.
For example:
- CountDownLatch relies on AQS under the hood;
- Future.get() blocks the thread;
- BlockingQueue heavily relies on Lock, Condition, or CAS internally;
- wait / notify relies on object monitors;
- synchronized and ReentrantLock bring lock contention.
That is to say:
In the thread era, to get synchronous code, you paid not only the cost of thread blocking, but also the cost of massive synchronizer coordination.
3. The greatest value of coroutines: Synchronizing asynchrony, non-blockingly
And coroutines solved this problem elegantly for the first time.
val user = login()
val profile = queryProfile(user.id)
val vip = queryVip(profile.id)
updateUi(vip)
It looks like synchronous code:
A → B → C → D
But the actual execution process is:
login()
↓
Suspend coroutine
↓
Release thread
↓
Wait for network response
↓
Resume coroutine
↓
Continue executing the next line of code
Throughout the entire process:
There is no:
- CountDownLatch
- Future.get()
- CompletableFuture.join()
- BlockingQueue
- wait / notify
- synchronized
- ReentrantLock
Not even:
Thread.sleep()
Because coroutines do not rely on these synchronization tools to wait for results.
The essence of suspend is the compiler-generated Continuation + State Machine.
When an asynchronous task is not complete:
Save execution context
↓
Suspend coroutine
↓
Release thread
↓
Async task completes
↓
resume()
↓
Restore state machine and continue execution
What is waiting is:
The coroutine
Not:
The thread
Therefore:
Coroutines did not turn Callbacks into CountDownLatches, but directly turned Callbacks into Continuations.
The entire asynchronous flow returns to linear expression.
Coroutines achieved something that was very hard to do elegantly in the thread era:
Giving asynchronous code the readability of synchronous code.
While simultaneously maintaining:
Non-blocking execution.
This is perhaps the greatest value of Kotlin coroutines compared to the traditional thread model.
Because it truly realized for the first time:
Linear expression + Non-blocking execution.
4. Coroutines not only reduce lock contention, but also reduce the use of synchronizers
Under the thread model:
When multiple threads access shared resources simultaneously:
- synchronized
- ReentrantLock
- Condition
- wait / notify
- BlockingQueue
are almost unavoidable.
Not only that.
To reorganize asynchrony back into a synchronous flow, you also need:
- CountDownLatch
- Future
- CompletableFuture
- Semaphore
That is to say:
A large amount of code in the thread era is actually centered around:
How threads coordinate with each other.
Rather than:
The business logic itself.
Coroutines change this.
For example:
val mutex = Mutex()
mutex.withLock {
updateData()
}
If the lock is already held:
Suspend coroutine
↓
Release thread
↓
Wait to resume
What is waiting is:
The coroutine
Not:
The thread
Therefore:
- Thread pool starvation is less likely;
- A large number of WAITING threads is less likely;
- Resource waste from lock contention is reduced;
- Scheduling pressure is noticeably decreased.
However, the truly powerful thing about coroutines is not that Mutex is faster than synchronized.
It is:
Many problems that originally required locking simply don't need locking under the coroutine model.
For example:
Multiple Coroutines
│
▼
Channel
│
▼
Single Consumer
│
▼
Modify Shared State
Or:
StateFlow
SharedFlow
Actor
Shared state gradually becomes:
Single Owner
Multiple tasks:
Collaborate through message passing.
Rather than:
Multiple threads competing for the same lock.
Thus, the Kotlin world increasingly favors:
- Mutex
- Channel
- Flow
- Actor
- Semaphore
Over:
- synchronized
- ReentrantLock
- BlockingQueue
- wait / notify
Because:
The coroutine era emphasizes collaboration, not competition.
What is truly reduced is not just lock contention.
More importantly:
Many problems that previously had to be solved with locks and synchronizers simply don't need to exist under the coroutine model.
5. Structured concurrency further reduces complexity
In the thread era:
Task lifecycles and business lifecycles were often separate.
For example:
The page is destroyed,
but the thread is still executing.
So you often see:
- Callbacks updating the UI after the page has exited;
- Futures that cannot be cancelled;
- Background task leaks;
- Lifecycle chaos.
With coroutines:
ViewModelScope
├── task1
├── task2
└── task3
When the parent coroutine is cancelled:
All child coroutines are automatically cancelled.
Lifecycles are naturally bound.
A large number of synchronization and state management problems thus disappear.
This is another easily overlooked advantage of Kotlin coroutines:
Structured concurrency reduces system complexity.
6. Coroutines did not eliminate callbacks
Many people have a feeling when they first encounter coroutines:
Suspend functions have no callbacks.
Actually, they do.
If you decompile a suspend function:
suspend fun login(): User
It will ultimately roughly become:
Object login(Continuation<? super User> continuation)
The essence is still:
Request completes
↓
Callback to Continuation
↓
Resume coroutine execution
The only difference is:
Before:
Developer maintains the callback chain.
Developer maintains the state machine.
Now:
Compiler maintains the callback chain.
Compiler maintains the state machine.
So in a sense:
Coroutines did not eliminate callbacks.
They just:
Hid the callbacks inside the compiler and runtime.
7. What coroutines truly optimize is not threads, but asynchronous programming
Many articles like to say:
Coroutines are faster than threads.
Actually, this is not accurate.
It is always threads that truly execute the code.
What coroutines optimize is never:
CPU computation speed
But:
The organizational cost of asynchronous programs
What they reduce is:
- Callback Hell;
- State machine maintenance cost;
- Usage of JUC synchronizers;
- Lock contention cost;
- Blocking wait cost;
- Lifecycle management cost.
Therefore, a more accurate statement would be:
Coroutines do not make programs run faster.
Rather:
They make asynchronous programs simpler, more stable, and easier to maintain.
8. Summary
Thread pools solve:
The problem of thread reuse.
Coroutines solve:
The problem of asynchronous expression.
Therefore:
Coroutines are more efficient than threads not because they are lighter to create, nor because they use fewer threads.
What truly changed is:
They redesigned the way asynchronous programs are organized.
They turned:
- Callbacks into Continuations;
- Hand-written state machines into compiler-generated state machines;
- Thread blocking into coroutine suspension;
- JUC synchronizers into coroutine collaboration;
- Thread competition into message passing and a single-owner model.
So, what coroutines truly optimize is never the CPU, nor threads.
It is:
The organizational cost of asynchronous programs.
They allow developers to stop wasting massive amounts of energy on:
- How to wait;
- How to synchronize;
- How to lock;
- How to manage callbacks;
- How to maintain state;
And instead refocus their attention on:
The business logic itself.
So, I believe the truly great thing about Kotlin coroutines is not that they are some percentage faster than threads.
It is that they:
Let you write asynchronous programs with a synchronous mindset.
Use a non-blocking suspension mechanism to achieve synchronous-code-like readability.
Coroutines do not eliminate all asynchronous overhead, but reduce the massive synchronization control cost introduced just to make asynchronous flows behave like synchronous code.
What coroutines reduce is not the number of Threads, but:
- Waiting synchronizers like
CountDownLatch; - Async composition mechanisms like
Future/CompletableFuture; - Complex control flow brought by callback nesting;
- Locks, state management, and exception handling code surrounding thread synchronization.
So essentially, the reason coroutines are more efficient than threads is that they reduce asynchronous complexity.
References
Why does the Kotlin official website no longer emphasize "coroutines are lightweight threads"?
From Callback to Coroutines: The Evolution of Android Async Concurrency Solutions
Stop launch(IO): 3 Hidden Anti-patterns of Coroutine Thread Switching
Getting Started with Android Clean Architecture with a Small Demo
Modern Android Architecture Doesn't Need an Event Bus
Why I Don't Handle Exceptions Directly in Android ViewModel?
Top 2 from juejin.cn, machine-translated. The original thread is authoritative.
Linear expression is the core.
The final summary is not bad.