在 Kotlin 协程中,select 算是一个有点尴尬的存在。
大多数开发者都用过 launch、async、Flow,可能也用过 Channel,但真正在项目中使用 select 的开发者,并不多。
甚至不少人写了几年 Kotlin,也只是偶尔在官方文档或者源码中见过它。
当然,这个也不能完全怪开发者。
官方对 select 的态度一直有些模棱两可:select 本身已经是稳定 API,常用的 onAwait、onReceive 也可以正常使用,但 onTimeout 仍然标记着 @ExperimentalCoroutinesApi,更底层的接口又带着 @InternalCoroutinesApi。
这些 API 混在一起,很容易让人产生一个疑问:
select到底能不能放心用在项目中?
再加上平时使用 async + awaitAll、Flow 或者 Channel,似乎已经可以解决大部分异步问题,所以很多人即使知道 select,也很少认真研究它适合什么场景。
这里补充一点,如果你去看官网,会发现它说 select 是 Experimental 的,但是如果你调用 select api,是可以直接用的,不需要加任何实验性的声明,考虑到官网的最近更新日期是 24 年底,这也就不奇怪了。
我之前对它也有类似的感觉:知道有这么个东西,但总觉得不太容易遇到非用不可的地方。
直到有一天,我碰到这样一个需求:同时启动多个异步任务,但不等待它们全部结束,而是谁先完成,就先处理谁的结果。
例如,我们正在开发一个电商应用。用户进入订单结算页后,应用需要同时向顺丰、京东物流和圆通查询运费。页面不能一直等待最慢的那一家,3 秒内有几家返回,就应该先展示几家;如果一家都没有返回,再继续等待第一个可用报价。
这种场景如果只用 awaitAll(),处理起来就没有想象中那么顺手了。
下面我们就从这个物流报价功能开始,看看 select 到底解决了什么问题,以及它为什么在某些场景下比 awaitAll() 更合适。
开始
假设我们正在开发一个电商应用。
用户进入订单结算页后,应用需要同时向顺丰、京东物流和圆通查询运费,然后把可以使用的物流方案展示出来。
这种代码一开始很好写:为每家物流公司启动一个异步任务,最后调用 awaitAll() 等待全部结果。
但是产品又加了一个要求:结算页不能为了凑齐所有报价一直转圈,集中等待时间最多 3 秒。3 秒内有几家返回,就先展示几家;如果 3 秒内一家都没返回,再继续等第一个可用报价,而不是始终等齐三家。
这下问题来了。
awaitAll() 必须等所有任务结束,而简单地套一层 withTimeoutOrNull(),超时后又拿不到已经完成的部分结果。
这篇文章,我们就从这个问题开始,看看 Kotlin 的 select 到底适合解决什么事情。
先模拟三家物流公司
为了让运行结果更直观,我们先模拟三个报价接口:
private data class QuoteProvider(
val name: String,
val delayMillis: Long,
val price: Int?,
)
private val providers = listOf(
QuoteProvider(name = "顺丰", delayMillis = 600, price = 18),
QuoteProvider(name = "京东物流", delayMillis = 1_400, price = null),
QuoteProvider(name = "圆通", delayMillis = 5_000, price = 12),
)
private suspend fun requestQuote(provider: QuoteProvider): Int? {
delay(provider.delayMillis)
return provider.price
}
这里用 delayMillis 模拟网络耗时,price 表示最终返回的运费。
京东物流的报价是 null,可以理解为当前地址不支持配送,或者这次查询没有拿到有效结果。
三家物流的响应情况如下:
- 顺丰:600 ms 后返回 18 元;
- 京东物流:1400 ms 后返回
null; - 圆通:5000 ms 后返回 12 元。
现在用最常见的 async + awaitAll 并行查询:
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()
}
三个请求确实是同时开始的,但 awaitAll() 会等到最后一个任务完成才返回。因此,大约 5 秒后我们才能拿到结果:
{顺丰=18, 圆通=12}
代码没什么问题,但结算页等 5 秒显然有点久。
给 awaitAll 加一个超时行不行
很快,我们会想到在外面套一层 withTimeoutOrNull():
private suspend fun loadQuotesWithSimpleTimeout(): Map<String, Int> =
withTimeoutOrNull(3.seconds) {
loadAllQuotes()
}.orEmpty()
3 秒后函数确实返回了,但结果却是:
{}
顺丰明明只用了 600 ms,为什么它的报价也没有留下来?
因为 awaitAll() 返回的是一份完整的结果。圆通还没有完成,它就不会把中间结果交给我们。
3 秒后整个代码块被取消,withTimeoutOrNull() 最终只能返回 null,再由 orEmpty() 变成空 Map。
严格来说,顺丰对应的 Deferred 确实已经完成了,它的结果也没有消失。
但是这里的根本问题是:awaitAll() 没有提供一份部分完成任务的结果。
我们需要换一种等待方式:不再等所有任务一起完成,而是谁先完成,就先处理谁。
这正是 select 擅长的事情。
先看看 select 能做什么
我们先从最简单的一次 select 开始:
private suspend fun awaitNext(
pending: Map<String, Deferred<Int?>>,
): Pair<String, Int?> = select { // select 在这里!!!
pending.forEach { (name, deferred) ->
deferred.onAwait { price ->
name to price
}
}
}
这段代码看着有点陌生,我们拆开看。
pending 保存了还需要等待的异步任务。遍历它时,我们通过 onAwait 告诉 select:
这个
Deferred完成时,也算一个可以返回的结果。
这些 onAwait 就是 select 的候选分支,官方文档中也把它们称为选择子句(clause)。
当任意一个 Deferred 完成后,对应的 onAwait 就会被选中,select 随即返回物流公司名称和报价。
不过要注意:一次 select 最多只会选择一个结果。
假设顺丰先返回,那么 awaitNext() 得到的就是:
(顺丰, 18)
至于京东物流和圆通,它们不会因为这次没有被选中就停止执行。两个请求还在继续,后面再次调用 select 时,仍然可以等到它们的结果。
这也是 select 一个非常重要的特点:没有被 select,任务不会被取消。
还有一个小细节:普通的 select 是有偏向性的。如果多个分支同时已经就绪,注册位置更靠前的分支优先。如果确实需要随机选择,可以使用 selectUnbiased。
在收集全部报价时,这通常只影响处理顺序;但在只取一个结果时,可能会影响最终选择。
放进循环
我们的目标不是只拿最快的一家,而是收集 3 秒内返回的全部有效报价。
既然一次 select 只能返回一个结果,那就多执行几次:
- 从尚未处理的任务中等待下一个结果;
- 保存有效报价;
- 把已经完成的任务从等待列表中移除;
- 继续等待下一个,直到全部完成或者超时。
代码如下:
先创建 pending 任务,返回结果是 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
remaining.remove(name)
if (price != null) {
collected[name] = price
}
}
}
return collected
}
这次,保存报价的 collected 创建在超时代码块外面。
在 3 秒到达之前,select 每拿到一个结果,我们就立即处理一个。即使后面触发超时,前面已经写入 collected 的报价也可以正常返回。
还是使用前面的三家物流服务,我们测试这段代码,运行结果会变成:
600 ms:顺丰返回 18 元,保存
1400 ms:京东物流返回 null,忽略
3004 ms:等待超时,返回已有结果
最终结果:{顺丰=18}
圆通需要 5 秒才能完成,所以没有进入这一轮返回结果。
这里也不需要 ConcurrentHashMap。三家物流的请求确实由不同的协程并行执行,但读取结果、写入 collected 和删除 remaining 中元素的代码,都运行在当前这个协程中,是一个接一个执行的,普通 MutableMap 就够了。
另外,select 中的 onAwait 并不需要一个个手写。select 的代码块本质上是以 SelectBuilder 为接收者的构建器 Lambda,因此可以像 awaitNext() 中那样,通过遍历集合动态添加候选分支。
一家都没返回怎么办
现在再考虑一个更差的情况:三家物流都很慢,3 秒内一家也没有返回。
如果直接返回空结果,结算页就没有任何配送方式。所以这里采用一个兜底策略:超过 3 秒后,不再等待全部报价,只等第一个有效报价。这里默认每个报价接口都有自己的超时边界,不会永远执行下去,后面还会专门说明这个前提。
假设这次三家物流的响应情况变成:
- 京东物流:3600 ms 后返回
null; - 顺丰:4200 ms 后返回 18 元;
- 圆通:5000 ms 后返回 12 元。
前 3 秒收集不到任何内容。进入兜底阶段后,京东物流虽然先完成,但它返回的是 null,不能使用;继续等待到 4200 ms,顺丰返回 18 元,这时就可以结束了。
我们先实现返回第一个的逻辑,也就是只要谁先返回,我们就停止收集后面的数据,实现仍然使用前面的 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
}
这段代码和前面的收集逻辑非常像,但拿到结果后的处理不同:
- 之前的
collectQuotesWithin()会保存每一个有效报价,然后继续循环; - 现在的
awaitFirstNonNull()找到第一个有效报价后,立即返回。
注意:我们并没有重新发起三次物流请求,也就是说,我们没有重新创建新的任务。
三家物流对应的 Deferred 只创建一次。第一阶段超时以后,尚未完成的请求仍然在运行,第二阶段只是继续等待它们。这也是为什么前面特别强调:select 没有选中某个任务,并不代表那个任务被取消了。
把两个阶段组合起来
现在把“3 秒内尽量收集”和“没有结果时等待第一个有效报价”组合起来:
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 {
// 虽然这个操作不起眼,但很重要。
// 如果余下的任务不再需要,那么就应该取消。
pending.values.forEach { deferred ->
deferred.cancel()
}
}
}
所有请求一开始就通过 async 并行执行。
前 3 秒,collectQuotesWithin() 会尽量收集已经返回的有效报价。只要拿到一家或多家的结果,就直接返回;如果结果为空,才进入 awaitFirstNonNull(),继续等待第一个可用报价。
最后,无论函数正常返回还是发生异常,finally 都会取消不再需要的任务。已经完成的 Deferred 再调用 cancel() 不会有问题;尚未完成的请求则不会继续浪费资源。
这里有一个很关键的作用域关系:pending 中的 Deferred 创建在外层 coroutineScope 中,而 withTimeoutOrNull() 只包住了第一阶段的收集循环。因此,3 秒超时时,被停止的是收集过程,不是外层已经启动的物流请求。我们才能在第二阶段继续复用同一批 Deferred。
为什么这里适合使用 select
这个问题当然不只有 select 一种解法。
我们可以为每家物流公司启动一个 launch,再把结果写入并发容器;也可以让每个请求把结果发送到 Channel。
这些方案都能实现,但当前场景使用 select,看起来会更简单(如果你懂了 select 的话):
- 手里已经有一组正在执行的
Deferred,可以直接通过onAwait等待它们; - 每次只处理一个已经完成的结果,普通的局部
MutableMap就足够; - 第一阶段结束后还要继续使用没有完成的任务,而
select不会自动取消未选中的Deferred。
这并不是说 Channel 或 launch 不好。
如果数据是持续产生的,需要让生产者和消费者长期通信,Channel 会更自然;如果每个结果到达后还要继续并行处理,使用多个 launch 配合线程安全的数据结构也合适。
这里的关键是当前数据怎么产生、结果需要怎么处理。
select 不只能等待 Deferred
前面的代码一直使用 onAwait,是因为我们的报价请求由 Deferred 表示。
select 还能等待其他类型的事件:
Channel.onReceive:等待从Channel接收数据;Channel.onReceiveCatching:接收数据,同时处理Channel已关闭的情况;Channel.onSend:等待某个Channel可以发送数据;Job.onJoin:等待某个Job完成。
例如,一个协程既可以等待后台任务返回,也可以同时等待 Channel 中出现一条消息。哪个先就绪,select 就先执行哪个分支。
两个容易忽略的问题
Deferred 失败时不会自动变成 null
示例中的 requestQuote() 只会返回价格或者 null,不会抛出异常。
真实网络请求却不一定如此。如果某个 Deferred 以异常结束,对它使用 onAwait 时,异常会继续向外抛出。在普通的 coroutineScope 中,一个子协程失败还可能取消其他子协程。
因此,生产代码应该明确哪些错误可以转成 null。
例如网络不可用、当前地区不支持配送等预期失败,可以在请求层处理;协程的 CancellationException 则应该继续抛出,不能当成普通请求失败吞掉。
也就是说,“失败返回 null”不是 select 本身提供的能力,我们开发者自己需要给接口定下返回规则。
兜底等待也需要边界
awaitFirstNonNull() 本身没有总超时时间。如果底层请求可能永远不结束,兜底阶段同样可能一直等待。
当前示例默认每个报价接口都有自己的超时机制,最终会返回价格、返回 null,或者抛出异常。在真实项目中,这个条件必须明确,不能只依赖最外层的 3 秒收集时间。
一点想法
虽然这个物流报价功能看起来很简单,但是需求在实现上还是有很多取舍的。
async + awaitAll 看似没问题,但是它只能在全部任务结束后一次性返回结果,无法满足“3 秒内返回多少就展示多少”的要求。
简单增加 withTimeoutOrNull() 只能限制等待时间,却拿不到 awaitAll() 尚未组装完成的部分结果。
而 select 可以在任意一个 Deferred 完成时立即返回。一次只能拿一个,我们就在外面加一层循环;3 秒内一个有效结果都没有,就继续等待第一个非空结果。
所以,以后再遇到多个异步任务时,可以先想清楚一件事:我们到底要等它们全部完成,还是要按照完成顺序逐个处理?
如果答案是后者,select 很可能就是那个合适的工具。