跪拜 Guibai
← Back to the summary

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.