跪拜 Guibai
← Back to the summary

Kotlin's select Clause Solves the Partial-Results Problem That awaitAll Can't


theme: hydrogen highlight: xcode

0.png

In Kotlin coroutines, select occupies a somewhat awkward position.

Most developers have used launch, async, Flow, and perhaps Channel, but few have actually used select in their projects.

Many people who have written Kotlin for years have only occasionally seen it in official documentation or source code.

Of course, developers are not entirely to blame for this.

The official stance on select has always been somewhat ambiguous: select itself is already a stable API, and commonly used functions like onAwait and onReceive can be used normally, but onTimeout is still marked with @ExperimentalCoroutinesApi, and lower-level interfaces carry @InternalCoroutinesApi.

Mixing these APIs together easily raises a question:

Can select be safely used in projects?

Furthermore, since using async + awaitAll, Flow, or Channel seems to solve most asynchronous problems, many people, even if they know about select, rarely study the scenarios where it is suitable.

One additional point: if you check the official website, it says select is Experimental, but if you call the select API, you can use it directly without adding any experimental declarations. Considering the website's last update was at the end of 2024, this is not surprising.

1.png

I had a similar feeling about it before: I knew it existed, but I always felt it was hard to encounter a situation where it was indispensable.

Until one day, I encountered this requirement: launch multiple asynchronous tasks simultaneously, but instead of waiting for all of them to finish, process the result of whichever one completes first.

For example, we are developing an e-commerce application. After a user enters the order settlement page, the application needs to query shipping costs from SF Express, JD Logistics, and YTO simultaneously. The page cannot keep waiting for the slowest one. Within 3 seconds, whichever ones return should be displayed first; if none return, then continue waiting for the first available quote.

If you only use awaitAll() for this scenario, handling it is not as smooth as you might imagine.

Below, starting from this shipping quote feature, let's see what problem select actually solves and why it is more suitable than awaitAll() in certain scenarios.

Start

Suppose we are developing an e-commerce application.

After a user enters the order settlement page, the application needs to query shipping costs from SF Express, JD Logistics, and YTO simultaneously, then display the available shipping options.

This code is easy to write initially: launch an asynchronous task for each logistics company, and finally call awaitAll() to wait for all results.

But the product team added another requirement: the settlement page cannot keep spinning just to gather all quotes; the centralized waiting time is at most 3 seconds. Within 3 seconds, display whichever ones have returned; if none return within 3 seconds, continue waiting for the first available quote instead of always waiting for all three.

Now there's a problem.

awaitAll() must wait for all tasks to finish, and simply wrapping it with withTimeoutOrNull() means you cannot get the partially completed results after a timeout.

In this article, we will start from this problem and see what Kotlin's select is truly suitable for solving.

First, Simulate Three Logistics Companies

To make the running results more intuitive, let's first simulate three quote interfaces:

private data class QuoteProvider(
    val name: String,
    val delayMillis: Long,
    val price: Int?,
)

private val providers = listOf(
    QuoteProvider(name = "SF Express", delayMillis = 600, price = 18),
    QuoteProvider(name = "JD Logistics", delayMillis = 1_400, price = null),
    QuoteProvider(name = "YTO", delayMillis = 5_000, price = 12),
)

private suspend fun requestQuote(provider: QuoteProvider): Int? {
    delay(provider.delayMillis)
    return provider.price
}

Here, delayMillis simulates network latency, and price represents the final returned shipping cost.

JD Logistics' quote is null, which can be interpreted as delivery not being supported for the current address, or this query not returning a valid result.

The response situations for the three logistics companies are as follows:

Now, use the most common async + awaitAll for parallel querying:

private suspend fun loadAllQuotes(): Map<String, Int> = coroutineScope {
    providers
        .map { provider ->
            async {
                provider.name to requestQuote(provider)
            }
        }
        .awaitAll()
        .mapNotNull { (name, price) ->
            price?.let { name to it }
        }
        .toMap()
}

The three requests indeed start simultaneously, but awaitAll() waits until the last task completes before returning. Therefore, we only get the result after about 5 seconds:

{SF Express=18, YTO=12}

The code is fine, but waiting 5 seconds for the settlement page is obviously a bit long.

What About Adding a Timeout to awaitAll?

Soon, we might think of wrapping it with withTimeoutOrNull():

private suspend fun loadQuotesWithSimpleTimeout(): Map<String, Int> =
    withTimeoutOrNull(3.seconds) {
        loadAllQuotes()
    }.orEmpty()

After 3 seconds, the function does return, but the result is:

{}

SF Express only took 600 ms, so why wasn't its quote kept?

Because awaitAll() returns a complete result set. Since YTO hasn't finished, it won't hand over the intermediate results to us.

After 3 seconds, the entire code block is cancelled, and withTimeoutOrNull() can only return null in the end, which is then turned into an empty Map by orEmpty().

Strictly speaking, the Deferred corresponding to SF Express has indeed completed, and its result hasn't disappeared.

But the fundamental problem here is: awaitAll() does not provide a result set of partially completed tasks.

We need a different waiting method: instead of waiting for all tasks to finish together, process whichever one finishes first.

This is exactly what select excels at.

First, See What select Can Do

Let's start with the simplest single select:

private suspend fun awaitNext(
    pending: Map<String, Deferred<Int?>>,
): Pair<String, Int?> = select { // select is here!!!
    pending.forEach { (name, deferred) ->
        deferred.onAwait { price ->
            name to price
        }
    }
}

This code might look unfamiliar; let's break it down.

pending holds the asynchronous tasks that still need to be waited on. When iterating over it, we tell select via onAwait:

When this Deferred completes, it also counts as a result that can be returned.

These onAwait calls are the candidate branches for select, also referred to as selection clauses in the official documentation.

When any Deferred completes, the corresponding onAwait is selected, and select immediately returns the logistics company name and quote.

But note: a single select call selects at most one result.

Assuming SF Express returns first, awaitNext() gets:

(SF Express, 18)

As for JD Logistics and YTO, they won't stop executing just because they weren't selected this time. The two requests are still ongoing, and when select is called again later, their results can still be waited on.

This is also a very important characteristic of select: if not selected by select, the task is not cancelled.

There's another small detail: ordinary select is biased. If multiple branches are ready simultaneously, the branch registered earlier takes priority. If random selection is truly needed, selectUnbiased can be used.

When collecting all quotes, this usually only affects the processing order; but when taking only one result, it might affect the final choice.

Put It in a Loop

Our goal is not just to get the fastest one, but to collect all valid quotes returned within 3 seconds.

Since a single select can only return one result, execute it multiple times:

  1. Wait for the next result from the unprocessed tasks;
  2. Save the valid quote;
  3. Remove the completed task from the waiting list;
  4. Continue waiting for the next one, until all are complete or a timeout occurs.

The code is as follows:

First, create the pending tasks, with the return type being Map<String, Deferred<Int?>>.

val pending = providers.associate { provider ->
    provider.name to async { requestQuote(provider) }
}

private suspend fun collectQuotesWithin(
    pending: Map<String, Deferred<Int?>>,
    timeout: Duration,
): Map<String, Int> {
    val remaining = pending.toMutableMap()
    val collected = mutableMapOf<String, Int>()

    withTimeoutOrNull(timeout) {
        while (remaining.isNotEmpty()) {
            val (name, price) = awaitNext(remaining) // awaitNext was implemented above
            remaining.remove(name)

            if (price != null) {
                collected[name] = price
            }
        }
    }

    return collected
}

This time, the collected map for saving quotes is created outside the timeout code block.

Before the 3 seconds are up, every time select gets a result, we process it immediately. Even if a timeout is triggered later, the quotes already written into collected can be returned normally.

Still using the previous three logistics services, if we test this code, the running result becomes:

600 ms: SF Express returns 18 yuan, saved
1400 ms: JD Logistics returns null, ignored
3004 ms: Waiting times out, returning existing results

Final result: {SF Express=18}

YTO needs 5 seconds to complete, so it didn't enter this round of returned results.

ConcurrentHashMap is also not needed here. The requests for the three logistics companies are indeed executed in parallel by different coroutines, but the code for reading results, writing to collected, and removing elements from remaining all runs within the current coroutine, executed one after another, so a regular MutableMap is sufficient.

Additionally, the onAwait calls in select don't need to be written manually one by one. The code block of select is essentially a builder lambda with SelectBuilder as the receiver, so candidate branches can be dynamically added by iterating over a collection, just like in awaitNext().

What If None Return?

11.png

Now consider a worse situation: all three logistics companies are very slow, and none return within 3 seconds.

If an empty result is returned directly, the settlement page will have no delivery methods. So a fallback strategy is adopted here: after 3 seconds, stop waiting for all quotes and only wait for the first valid quote. The default assumption here is that each quote interface has its own timeout boundary and won't execute forever; this premise will be specifically explained later.

Assume the response situations for the three logistics companies this time become:

Nothing can be collected in the first 3 seconds. After entering the fallback phase, although JD Logistics completes first, it returns null and cannot be used; continue waiting until 4200 ms, SF Express returns 18 yuan, and we can stop at this point.

Let's first implement the logic for returning the first one, meaning as soon as one returns, we stop collecting subsequent data. The implementation still uses the previous awaitNext():

private suspend fun awaitFirstNonNull(
    pending: Map<String, Deferred<Int?>>,
): Pair<String, Int>? {
    val remaining = pending.toMutableMap()

    while (remaining.isNotEmpty()) {
        val (name, price) = awaitNext(remaining)
        remaining.remove(name)

        if (price != null) {
            return name to price
        }
    }

    return null
}

This code is very similar to the previous collection logic, but the handling after getting a result is different:

Note: we did not re-initiate the three logistics requests, meaning we did not create new tasks.

The Deferred objects for the three logistics companies are only created once. After the first phase times out, the unfinished requests are still running, and the second phase just continues waiting for them. This is also why it was specifically emphasized earlier: select not selecting a task does not mean that task is cancelled.

Combining the Two Phases

Now combine "collect as much as possible within 3 seconds" and "wait for the first valid quote when there are no results":

suspend fun loadQuotes(): Map<String, Int> = coroutineScope {
    val pending = providers.associate { provider ->
        provider.name to async {
            requestQuote(provider)
        }
    }

    try {
        val collected = collectQuotesWithin(
            pending = pending,
            timeout = 3.seconds,
        )

        if (collected.isNotEmpty()) {
            collected
        } else {
            val first = awaitFirstNonNull(pending)
            first
                ?.let { (name, price) -> mapOf(name to price) }
                .orEmpty()
        }
    } finally {
        // Although this operation seems inconspicuous, it is very important.
        // If the remaining tasks are no longer needed, they should be cancelled.
        pending.values.forEach { deferred ->
            deferred.cancel()
        }
    }
}

All requests are executed in parallel via async from the start.

In the first 3 seconds, collectQuotesWithin() tries its best to collect valid quotes that have already returned. As long as one or more results are obtained, they are returned directly; if the result is empty, it enters awaitFirstNonNull() to continue waiting for the first available quote.

Finally, regardless of whether the function returns normally or an exception occurs, finally cancels the tasks that are no longer needed. Calling cancel() on an already completed Deferred causes no issues; unfinished requests will not continue to waste resources.

There's a crucial scoping relationship here: the Deferred objects in pending are created in the outer coroutineScope, while withTimeoutOrNull() only wraps the first phase's collection loop. Therefore, when the 3-second timeout occurs, what is stopped is the collection process, not the logistics requests already launched in the outer scope. This is why we can continue reusing the same batch of Deferred objects in the second phase.

Why select Is Suitable Here

This problem certainly doesn't have only a select-based solution.

We could launch a launch for each logistics company and write the results into a concurrent container; or we could have each request send its result to a Channel.

These solutions can all be implemented, but using select in the current scenario seems simpler (if you understand select):

This is not to say that Channel or launch is bad.

If data is produced continuously and requires long-term communication between producers and consumers, Channel is more natural; if each result needs further parallel processing upon arrival, using multiple launch instances with thread-safe data structures is also appropriate.

The key here is how the current data is produced and how the results need to be processed.

select Can Wait for More Than Just Deferred

The previous code consistently used onAwait because our quote requests were represented by Deferred.

select can also wait for other types of events:

For example, a coroutine can both wait for a background task to return and simultaneously wait for a message to appear in a Channel. Whichever is ready first, select executes that branch first.

Two Easily Overlooked Issues

A Failed Deferred Does Not Automatically Become null

The requestQuote() in the example only returns a price or null, and does not throw exceptions.

Real network requests are not necessarily like this. If a Deferred ends with an exception, using onAwait on it will cause the exception to continue propagating outward. In a regular coroutineScope, a child coroutine failing can also cancel other child coroutines.

Therefore, production code should clearly define which errors can be converted to null.

For example, expected failures like network unavailability or delivery not being supported in the current region can be handled at the request layer; CancellationException from coroutines should continue to be thrown and not swallowed as a regular request failure.

In other words, "return null on failure" is not a capability provided by select itself; we developers need to define the return rules for the interface.

Fallback Waiting Also Needs Boundaries

awaitFirstNonNull() itself has no total timeout. If the underlying request might never end, the fallback phase could also wait indefinitely.

The current example assumes each quote interface has its own timeout mechanism and will eventually return a price, return null, or throw an exception. In a real project, this condition must be explicit and cannot rely solely on the outermost 3-second collection time.

Some Thoughts

Although this shipping quote feature seems very simple, the requirements still involve many trade-offs in implementation.

async + awaitAll seems fine on the surface, but it can only return results once after all tasks are finished, unable to meet the requirement of "display however many return within 3 seconds."

Simply adding withTimeoutOrNull() only limits the waiting time but cannot get the partial results that awaitAll() hasn't finished assembling.

select, on the other hand, can return immediately when any Deferred completes. Since it can only take one at a time, we add a loop outside; if there are no valid results within 3 seconds, we continue waiting for the first non-null result.

So, in the future, when encountering multiple asynchronous tasks, first think clearly about one thing: do we want to wait for all of them to complete, or do we want to process them one by one in order of completion?

If the answer is the latter, select is very likely the right tool for the job.