Kotlin Coroutines: '协' Means Collaboration, Not Lightweight Threads
Kotlin Coroutines: The Emphasis Is on the Character 「协」
Many people learning Kotlin coroutines first encounter launch, async, suspend, and Dispatchers.
These APIs are certainly important.
But if you only focus on APIs, it is easy to reduce coroutines to 'lighter-weight threads' or 'a more convenient tool for asynchronous programming.'
That understanding is incomplete.
Technically, a coroutine is a task abstraction that can be suspended and resumed. From an actual programming perspective, the more valuable capability of Kotlin coroutines is how they use suspension and structured concurrency to organize multiple tasks.
So we can use the character 「协」 to build an intuition:
Coroutines certainly cannot do without threads, but what they are really good at expressing is how tasks collaborate with each other.
Note that this is a teaching perspective to aid understanding, not a strict definition of coroutines. Threads can also collaborate, and Java has non-blocking concurrency tools; coroutines also need threads to execute code.
More precisely:
Threads mainly provide execution resources; coroutines provide a suspendable task model; structured concurrency is responsible for organizing the collaborative relationships among tasks.
1. Traditional Java Concurrency: Creating Threads Isn't the Hard Part
In traditional Java concurrent programming, we often need to deal with these problems:
- When multiple threads modify data at the same time, how do we ensure the data is correct?
- After one thread finishes its work, how does it notify other threads?
- When multiple tasks have dependencies, how do we coordinate execution order?
- If one task fails or is cancelled, what should happen to the other tasks?
- After the page or request that launched the tasks ends, should background tasks still continue?
Java provides a rich set of concurrency tools.
synchronized, Lock, wait/notify, Future, BlockingQueue, CountDownLatch, and ExecutorService all solve problems in different scenarios.
Modern Java also provides CompletableFuture, virtual threads, and structured concurrency; not all Java concurrent code depends on blocking waits.
However, starting from the traditional concurrency tools still makes one problem clear:
As task dependency relationships become more complex, developers have to manually coordinate more and more.
2. synchronized: How Can Multiple Threads Safely Access Shared Data?
Consider the simplest scenario.
Two threads modify a shared variable at the same time:
int count = 0;
// Thread A
count++;
// Thread B
count++;
We want the final result to be 2.
But count++ is not an indivisible operation. It usually consists of steps such as read, compute, and write back.
When multiple threads interleave, they may read the same old value, causing a lost update. So the final result is not necessarily 2.
One solution is to use synchronized:
private final Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
synchronized guarantees that only one thread can enter the critical section protected by the same lock at a time.
This coordinates multiple threads' modifications to a shared variable.
However, this only solves a local problem:
How to safely access shared data.
If thread A needs to wait for thread B to finish, or multiple tasks must run in a specific order, synchronized alone is usually not enough.
3. Lock: More Flexible Control Over Thread Mutual Exclusion
Besides synchronized, Java also provides the Lock interface, whose most common implementation is ReentrantLock:
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Like synchronized, it protects shared data and prevents multiple threads from entering the critical section at the same time.
So why do we need Lock?
Because it provides some more flexible control capabilities.
1. Trying to acquire a lock
if (lock.tryLock()) {
try {
doWork();
} finally {
lock.unlock();
}
} else {
handleLockUnavailable();
}
If the lock is currently unavailable, tryLock() returns immediately.
Depending on business needs, the developer can decide whether to retry, skip, or run other logic when the lock is not available.
2. Interruptible lock waiting
lock.lockInterruptibly();
try {
doWork();
} finally {
lock.unlock();
}
A thread waiting for the lock can respond to interruption, which helps implement certain cancellation and exit flows.
3. Fair vs. unfair locks
ReentrantLock supports configuring a fairness policy, giving threads that have waited longer a better chance to acquire the lock first.
However, fair locks usually bring extra overhead. Whether fairness is needed should be decided based on a trade-off between throughput and waiting strategy.
4. Where is the boundary of Lock?
Lock mainly solves mutual-exclusion access between threads, along with lock acquisition and release.
But if the business requires task A to wait until tasks B, C, and D have all completed before continuing, Lock alone is not enough to express the full task relationship.
The developer may also need to combine:
- Condition variables;
CountDownLatch;Future;- Thread pools;
- Timeout and cancellation mechanisms;
- Exception collection mechanisms.
Explicit locks must also be released correctly. If unlock() is missed, other threads may be unable to acquire the lock for a long time.
Therefore:
Locks solve the local shared-state problem, but that does not mean the entire asynchronous task collaboration problem is solved.
4. CountDownLatch: How to Wait for Multiple Tasks to Complete
Suppose a page needs to load three mutually independent data sources:
- User information;
- Order information;
- Recommendation information.
We want all three tasks to finish before continuing with subsequent processing.
Traditional Java can use CountDownLatch:
CountDownLatch latch = new CountDownLatch(3);
executor.execute(() -> {
try {
loadUser();
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
loadOrders();
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
loadRecommendations();
} finally {
latch.countDown();
}
});
latch.await();
continueWork();
CountDownLatch(3) means the initial count is 3.
Each task calls countDown() once when it ends. When the count reaches zero, the thread waiting on await() can continue.
It solves a very practical problem:
How to make one thread wait for multiple tasks to finish?
But it also has clear capability boundaries.
1. await() blocks a thread
If the count has not reached zero, await() blocks the calling thread.
In an ordinary background thread, this may mean that a thread cannot do other work for a while; if called on the Android main thread, it may affect UI responsiveness, and in severe cases may trigger an ANR.
2. CountDownLatch does not protect shared data
CountDownLatch's job is to coordinate waiting and completion timing, not to provide mutual exclusion.
If multiple tasks modify the same mutable object at the same time, you still need locks, atomic classes, or other thread-safety mechanisms.
However, it has corresponding memory-visibility guarantees: operations performed by a task before calling countDown() should be visible to another thread after it successfully returns from await().
3. The count reaching zero does not mean all tasks succeeded
Even if loadUser() throws an exception, countDown() in the finally block still executes.
This avoids the waiting side being blocked forever, but it also means:
The count reaching zero only means the task execution flow has ended; it does not mean all tasks succeeded.
The waiting side does not automatically get the tasks' return values or exceptions.
If a task submission fails, the task never starts, or the code misses a countDown(), the waiting side may keep waiting forever. Therefore, real projects usually also need to design:
- Exception collection;
- Return-value passing;
- Timeouts;
- Cancellation;
- Resource cleanup.
4. It does not manage the overall task lifecycle
CountDownLatch does not know why these tasks are being run together, nor does it automatically establish parent-child relationships between tasks.
If one task fails, it does not automatically cancel the other tasks; if the page is destroyed, it does not automatically stop the tasks associated with that page.
What it is good at solving is:
'Wait until the count reaches zero.'
But it does not answer:
'Who do these tasks belong to? Why are they run together? What happens after failure or cancellation?'
Strictly speaking, CountDownLatch and coroutines' coroutineScope are not tools at the same level.
The former is a synchronization primitive; the latter expresses the structure and lifecycle of tasks. Putting them side by side is meant to show two different ways of organizing tasks, not to say that coroutineScope is a direct replacement for CountDownLatch.
5. Future: Waiting for a Result May Also Mean Blocking a Thread
Another common Java concurrency scenario:
Future<Data> future = executor.submit(() -> loadData());
Data data = future.get();
If the task has not finished, get() blocks the calling thread.
We only wanted to wait for one task's result, but we may also be occupying a thread.
In traditional programming, developers often have to choose among several approaches:
- Block the current thread and wait for the task to finish;
- Register a callback and continue after the task ends;
- Use additional synchronization tools to coordinate multiple tasks;
- Use
CompletableFutureto build asynchronous task chains.
CompletableFuture already supports result composition, exception handling, and non-blocking callbacks. Modern Java's virtual threads and structured concurrency further improve task organization.
Therefore, the problem is not 'Java cannot implement task collaboration,' nor can we broadly say 'all Java thread collaboration is blocking.'
A more accurate statement is:
Traditional Java thread-collaboration tools often express waiting by blocking threads; coroutines can express waiting by suspending coroutines.
Different models can organize tasks, but they express task dependencies, cancellation, exceptions, and lifecycle relationships differently.
6. Why Can Thread Waiting Cause Android ANR?
The Android main thread is responsible for handling input events, doing UI work, and participating in drawing.
It must respond in a timely manner and cannot wait a long time for other threads.
For example, a background thread holds a lock:
synchronized (lock) {
loadData();
}
Meanwhile, the main thread also tries to acquire the same lock:
synchronized (lock) {
updateUI();
}
If the background thread holds the lock for a long time, the main thread can only wait for the lock to be released.
Similar risks include:
- Calling
Future.get()on the main thread; - Calling
Thread.join()on the main thread; - Calling
CountDownLatch.await()on the main thread; - Calling
BlockingQueue.take()on the main thread; - Performing blocking I/O such as network or file operations on the main thread;
- The main thread waiting on a lock long-held by a background thread;
- Multiple threads forming a circular dependency and eventually deadlocking.
The common point of these problems is:
To wait for some condition or task to finish, the main thread stops handling other work.
Not every wait causes an ANR; a very short wait may not be noticeable to the user. What really needs attention is the main thread being unable to handle the work the system requires in time because of blocking calls, lock contention, or complex dependencies.
This raises an important question:
If what we really need is to wait for a task's result, do we have to do it by blocking a thread?
7. Kotlin Coroutines: From Thread Blocking to Coroutine Suspension
First look at a blocking call:
val result = future.get()
Even if this code is written inside a coroutine, get() may still block the thread executing it.
Putting blocking code into a coroutine does not automatically turn it into non-blocking code.
Now look at a call that supports suspension:
viewModelScope.launch {
val result = repository.loadData()
uiState.value = result
}
Assume loadData() uses an API that genuinely supports suspension.
When the result is not ready, the current coroutine can suspend. The thread executing it does not have to sit there waiting; it can continue running other tasks.
When the data is ready, the coroutine is resumed and continues from the point after the suspension.
The key difference here is:
When a thread blocks, what stops is the thread; when a coroutine suspends, what pauses is the coroutine itself.
A thread is an execution resource; a coroutine is a task that can be suspended and resumed.
While a coroutine is suspended, it does not need to keep occupying a thread. Therefore, a large number of tasks in a waiting state do not necessarily need to correspond one-to-one with a large number of threads.
8. How Does the Same Multi-Task Collaboration Work with Coroutines?
Using the same scenario: load user, order, and recommendation information separately.
With Kotlin coroutines, it can be organized like this:
suspend fun loadPage(): PageData = coroutineScope {
val user = async {
loadUser()
}
val orders = async {
loadOrders()
}
val recommendations = async {
loadRecommendations()
}
PageData(
user = user.await(),
orders = orders.await(),
recommendations = recommendations.await()
)
}
The three async calls create three concurrently executing child coroutines, and await() is used to obtain the corresponding results.
'Concurrent' here means that multiple tasks can interleave their progress over time; it does not guarantee that they run on three different threads. Which threads are actually used depends on the coroutine context, dispatcher, and underlying operations.
1. No need to manually maintain a counter
With CountDownLatch, the developer needs to initialize a count and make sure every task calls countDown() at the right time.
With coroutineScope, the scope waits for its child coroutines to complete.
What we express directly is the task structure, not a manually maintained completion counter.
2. Waiting for a result does not require blocking a thread
Deferred.await() is a suspend function.
If the result is not ready, the coroutine calling it can suspend, without needing to block the current thread the way Future.get() does.
However, await() itself does not automatically switch threads, nor can it fix blocking code inside child tasks.
If loadUser() internally performs a blocking operation directly on the main thread, the main thread will still be blocked.
3. Tasks have clear lifecycle boundaries
The child coroutines created by the three async calls all belong to the same coroutineScope.
The scope can only return normally after all child tasks complete. If the scope is cancelled externally, the cancellation also propagates to its child coroutines.
This embodies an important principle of structured concurrency:
Tasks cannot arbitrarily outlive the scope that created them.
9. Coroutines Also Need Synchronization: Mutex
Coroutines do not automatically guarantee the safety of shared data.
For example:
var count = 0
coroutineScope {
repeat(1000) {
launch(Dispatchers.Default) {
count++
}
}
}
These coroutines may run on different threads at the same time, and ordinary count++ is still not an atomic operation.
The final result may be less than 1000.
In the coroutine world, you can use Mutex:
private val mutex = Mutex()
private var count = 0
suspend fun increment() {
mutex.withLock {
count++
}
}
Mutex coordinates multiple coroutines' access to shared state.
If the lock is unavailable, the coroutine trying to acquire it can suspend, instead of necessarily blocking the thread executing it.
Coroutines improve the way we wait, but they do not automatically solve concurrency-safety problems for shared state.
10. From BlockingQueue to Channel: How Do Producers and Consumers Collaborate?
Besides locks and task waiting, producer-consumer is also a common collaboration scenario.
Java often uses BlockingQueue:
BlockingQueue<Data> queue = new ArrayBlockingQueue<>(10);
// Producer thread
queue.put(loadData());
// Consumer thread
Data data = queue.take();
process(data);
BlockingQueue is thread-safe and can implement backpressure through a bounded capacity.
When the queue is full, put() waits for available space; when the queue is empty, take() waits for new data.
This waiting is blocking:
- While
put()waits, the producer thread is blocked; - While
take()waits, the consumer thread is blocked; - A blocked thread cannot execute other tasks during that time.
Of course, BlockingQueue also provides non-blocking or timed methods such as offer() and poll().
But if you need to continue automatically once a condition is met, traditional approaches usually still require blocking threads, polling, or an additional asynchronous scheduling mechanism.
Using Channel to express data passing between coroutines
Kotlin coroutines can use Channel:
val channel = Channel<Data>(capacity = 10)
val producer = launch {
repeat(100) {
channel.send(loadData())
}
channel.close()
}
val consumer = launch {
for (data in channel) {
process(data)
}
}
Channel also passes data between producers and consumers and handles mismatched production and consumption speeds through capacity limits.
The key difference:
- When the Channel is full,
send()can suspend the producer coroutine; - When the Channel is empty,
receive()can suspend the consumer coroutine; - During suspension, the thread executing the coroutine can continue running other tasks.
Therefore:
When BlockingQueue waits, it usually blocks a thread; when Channel waits, it can suspend a coroutine.
This does not mean Channel does not use threads at all. Producing and consuming data still need to execute on threads; it is just that waiting for data or space does not need to keep occupying a thread.
Channel also has non-suspending operations
If you do not want to wait, you can use:
val sendResult = channel.trySend(data)
val receiveResult = channel.tryReceive()
These return immediately and do not suspend the coroutine.
This is somewhat similar to BlockingQueue's offer() and poll().
Different capacities represent different collaboration styles
A Channel without a buffer:
val channel = Channel<Data>()
By default it uses rendezvous mode.
When the producer sends data, it needs to wait for the consumer to be ready to receive. One send and one receive complete the collaboration directly.
A bounded Channel:
val channel = Channel<Data>(capacity = 10)
It allows the producer to temporarily get ahead of the consumer, but once the buffer is full, the producer suspends, creating backpressure.
An unlimited Channel:
val channel = Channel<Data>(capacity = Channel.UNLIMITED)
The sender usually will not suspend due to insufficient capacity, but if the consumer is slow for a long time, data can keep piling up and consume a lot of memory.
Therefore, a bigger Channel capacity is not always better.
Capacity itself is part of the task collaboration strategy.
Lifecycle and end-of-stream signals
BlockingQueue has no built-in 'production ended' semantics. Traditional code sometimes puts a special object into the queue as an end signal:
queue.put(POISON_PILL);
The consumer exits after receiving this object.
This approach requires the producer and consumer to agree on a protocol in advance and to handle multiple consumers, exceptions, and interruption correctly.
Channel supports explicit closing:
channel.close()
When receiving with a for loop, once the Channel is closed and the remaining data has been consumed, the loop ends naturally:
for (data in channel) {
process(data)
}
A Channel can also carry a close cause.
However, a raw Channel does not automatically have a complete parent-child task relationship just because it was created in some scope.
If you want the production process to be bound to a coroutine scope, you can use coroutine builders such as produce:
val dataChannel: ReceiveChannel<Data> = produce {
repeat(100) {
send(loadData())
}
}
You can also explicitly close the Channel in the production task's finally block.
Channel is more than a 'non-blocking BlockingQueue'
Both can implement the producer-consumer model, but they belong to different programming models.
BlockingQueue is mainly responsible for a thread-safe data queue and blocking waits. It does not automatically manage:
- Which business scope the producer belongs to;
- When the consumer should be cancelled;
- What happens to other tasks after one task fails;
- Whether the entire data flow should end when the page is destroyed.
Channel can be combined with coroutine scopes, cancellation, and structured concurrency, connecting data passing to task lifecycle.
However, that connection still needs to be designed correctly. Creating a global Channel carelessly can also cause resource leaks, tasks that never end, or data that nobody consumes.
So a more accurate comparison is:
BlockingQueue mainly coordinates data passing in the thread model, and usually blocks threads while waiting for conditions; Channel mainly coordinates data passing in the coroutine model, and can suspend coroutines while waiting for conditions.
11. What Are Coroutines' Advantages in Multi-Task Collaboration?
This can be understood from four aspects.
1. From blocking waits to suspending waits
In traditional concurrent programming, operations such as Future.get(), CountDownLatch.await(), and BlockingQueue.take() may block the calling thread.
Coroutines can express waiting through suspend functions, await(), send(), and receive(), giving threads the opportunity to execute other tasks while waiting.
This reflects an important value of coroutines:
Waiting for a task or condition does not necessarily require continuously occupying a thread.
But the premise is that the call chain genuinely supports suspension, or that blocking operations are reasonably scheduled onto an appropriate thread pool.
2. From manual coordination to structured organization
In traditional Java code, we may need to combine:
ExecutorService;Future;CountDownLatch;BlockingQueue;- Cancellation mechanisms;
- Exception containers;
- Timeout mechanisms.
Coroutines, through coroutineScope, Job, launch, async, Channel, and other mechanisms, can express task relationships, data passing, waiting, and cancellation within a relatively unified model.
This is not to say Java cannot achieve the same capabilities; it is that Kotlin coroutines provide a more unified way to organize them.
3. From waiting for tasks to complete to managing task lifecycle
CountDownLatch can tell us when the count reaches zero, but it does not know which page, request, or business flow these tasks belong to.
Structured concurrency is concerned with the whole task lifecycle:
- Who starts the task?
- Who is responsible for waiting for it to complete?
- If the parent task is cancelled, what should happen to child tasks?
- If a child task fails, do other tasks continue?
- After the page is destroyed, should related tasks still survive?
For example, viewModelScope in Android cancels its coroutines when the ViewModel is cleared.
This lifecycle binding adds a structural constraint beyond simply waiting for tasks to complete.
12. Don't Simply Understand Coroutines as Lightweight Threads
'Lightweight thread' can help beginners understand that coroutines can be created in large numbers, but this metaphor is also easy to mislead.
Coroutines and threads are not concepts at the same level.
A thread is an execution resource provided by the operating system or runtime. A coroutine is a task organized by the program that can be suspended and resumed.
Multiple coroutines can share a small number of threads, and a coroutine may be executed by different threads at different stages.
Therefore, when using coroutines, you should not only ask:
Which thread does this code run on?
You should also continue to ask:
- Which scope does this coroutine belong to?
- When does it end?
- Who is responsible for cancelling it?
- If it fails, which tasks will be affected?
- When it suspends, does it release the thread?
- Is the underlying API it calls truly non-blocking, or does it still block a thread?
- Does it access mutable state shared by multiple threads?
- How do producers and consumers pass data and handle backpressure?
These questions matter more than simply memorizing a few dispatchers.
13. Summary
When learning Java concurrency, we encounter tools such as locks, thread pools, Future, CountDownLatch, and BlockingQueue.
They solve respectively:
- Mutual exclusion of shared data;
- Task scheduling;
- Result retrieval;
- Thread waiting;
- Coordination of completion timing;
- Data passing between producers and consumers.
These tools are still important today, and modern Java keeps improving task organization through CompletableFuture, virtual threads, and structured concurrency.
Therefore, we cannot simply assume that Java thread collaboration is necessarily blocking.
A more accurate distinction is:
Traditional thread collaboration usually expresses waiting by blocking threads, while coroutines can express waiting by suspending tasks.
When task relationships become complex, the hard part is often not just how to start tasks, but how to answer these questions:
- Who do these tasks belong to?
- Who waits for them to complete?
- If one task fails, what happens to the others?
- After an external flow is cancelled, do internal tasks continue?
- How should results, exceptions, and resources be handled uniformly?
- How do producers and consumers pass data?
- When consumption cannot keep up with production, how does the system handle backpressure?
Kotlin coroutines use suspension so that waiting does not have to block threads; use structured concurrency so that task lifecycle, cancellation, and failure propagation have clear boundaries; and use tools such as Channel so that coroutines can pass data without blocking threads.
So this sentence can be used as an entry point for understanding coroutines:
Threads mainly provide execution resources; coroutines provide a suspendable task model; structured concurrency is responsible for organizing collaboration between tasks.
If you want a shorter, easier-to-remember sentence, you can also say:
Threads are about execution; coroutines are about collaboration.
That is not a strict definition, but a way of understanding.