你是不是还没用过 select?实战 Kotlin select

在 Kotlin 协程中,select 算是一个有点尴尬的存在。

大多数开发者都用过 launchasyncFlow,可能也用过 Channel,但真正在项目中使用 select 的开发者,并不多。

甚至不少人写了几年 Kotlin,也只是偶尔在官方文档或者源码中见过它。

当然,这个也不能完全怪开发者。

官方对 select 的态度一直有些模棱两可:select 本身已经是稳定 API,常用的 onAwaitonReceive 也可以正常使用,但 onTimeout 仍然标记着 @ExperimentalCoroutinesApi,更底层的接口又带着 @InternalCoroutinesApi

这些 API 混在一起,很容易让人产生一个疑问:

select 到底能不能放心用在项目中?

再加上平时使用 async + awaitAllFlow 或者 Channel,似乎已经可以解决大部分异步问题,所以很多人即使知道 select,也很少认真研究它适合什么场景。

这里补充一点,如果你去看官网,会发现它说 select 是 Experimental 的,但是如果你调用 select api,是可以直接用的,不需要加任何实验性的声明,考虑到官网的最近更新日期是 24 年底,这也就不奇怪了。

我之前对它也有类似的感觉:知道有这么个东西,但总觉得不太容易遇到非用不可的地方。

直到有一天,我碰到这样一个需求:同时启动多个异步任务,但不等待它们全部结束,而是谁先完成,就先处理谁的结果。

例如,我们正在开发一个电商应用。用户进入订单结算页后,应用需要同时向顺丰、京东物流和圆通查询运费。页面不能一直等待最慢的那一家,3 秒内有几家返回,就应该先展示几家;如果一家都没有返回,再继续等待第一个可用报价。

这种场景如果只用 awaitAll(),处理起来就没有想象中那么顺手了。

下面我们就从这个物流报价功能开始,看看 select 到底解决了什么问题,以及它为什么在某些场景下比 awaitAll() 更合适。

开始

假设我们正在开发一个电商应用。

用户进入订单结算页后,应用需要同时向顺丰、京东物流和圆通查询运费,然后把可以使用的物流方案展示出来。

这种代码一开始很好写:为每家物流公司启动一个异步任务,最后调用 awaitAll() 等待全部结果。

但是产品又加了一个要求:结算页不能为了凑齐所有报价一直转圈,集中等待时间最多 3 秒。3 秒内有几家返回,就先展示几家;如果 3 秒内一家都没返回,再继续等第一个可用报价,而不是始终等齐三家。

这下问题来了。

awaitAll() 必须等所有任务结束,而简单地套一层 withTimeoutOrNull(),超时后又拿不到已经完成的部分结果。

这篇文章,我们就从这个问题开始,看看 Kotlin 的 select 到底适合解决什么事情。

先模拟三家物流公司

为了让运行结果更直观,我们先模拟三个报价接口:

kotlin 复制代码
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 并行查询:

kotlin 复制代码
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 秒后我们才能拿到结果:

text 复制代码
{顺丰=18, 圆通=12}

代码没什么问题,但结算页等 5 秒显然有点久。

给 awaitAll 加一个超时行不行

很快,我们会想到在外面套一层 withTimeoutOrNull()

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

3 秒后函数确实返回了,但结果却是:

text 复制代码
{}

顺丰明明只用了 600 ms,为什么它的报价也没有留下来?

因为 awaitAll() 返回的是一份完整的结果。圆通还没有完成,它就不会把中间结果交给我们。

3 秒后整个代码块被取消,withTimeoutOrNull() 最终只能返回 null,再由 orEmpty() 变成空 Map

严格来说,顺丰对应的 Deferred 确实已经完成了,它的结果也没有消失。

但是这里的根本问题是:awaitAll() 没有提供一份部分完成任务的结果。

我们需要换一种等待方式:不再等所有任务一起完成,而是谁先完成,就先处理谁。

这正是 select 擅长的事情。

先看看 select 能做什么

我们先从最简单的一次 select 开始:

kotlin 复制代码
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() 得到的就是:

text 复制代码
(顺丰, 18)

至于京东物流和圆通,它们不会因为这次没有被选中就停止执行。两个请求还在继续,后面再次调用 select 时,仍然可以等到它们的结果。

这也是 select 一个非常重要的特点:没有被 select,任务不会被取消。

还有一个小细节:普通的 select 是有偏向性的。如果多个分支同时已经就绪,注册位置更靠前的分支优先。如果确实需要随机选择,可以使用 selectUnbiased

在收集全部报价时,这通常只影响处理顺序;但在只取一个结果时,可能会影响最终选择。

放进循环

我们的目标不是只拿最快的一家,而是收集 3 秒内返回的全部有效报价。

既然一次 select 只能返回一个结果,那就多执行几次:

  1. 从尚未处理的任务中等待下一个结果;
  2. 保存有效报价;
  3. 把已经完成的任务从等待列表中移除;
  4. 继续等待下一个,直到全部完成或者超时。

代码如下:

先创建 pending 任务,返回结果是 Map<String, Deferred<Int?>> 类型。

kotlin 复制代码
val pending = providers.associate { provider ->
    provider.name to async { requestQuote(provider) }
}
kotlin 复制代码
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 的报价也可以正常返回。

还是使用前面的三家物流服务,我们测试这段代码,运行结果会变成:

text 复制代码
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()

kotlin 复制代码
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 秒内尽量收集"和"没有结果时等待第一个有效报价"组合起来:

kotlin 复制代码
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

这并不是说 Channellaunch 不好。

如果数据是持续产生的,需要让生产者和消费者长期通信,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 很可能就是那个合适的工具。

相关推荐
__Witheart__2 小时前
适用于Android内核boot.img生成流程
android·rockchip
木易 士心2 小时前
Jetpack Compose 深度解析:初始组合与重组的底层奥秘
android
GitLqr11 小时前
Flutter 无障碍开发实战:玩转 Semantics 解决视障用户使用痛点
android·flutter·dart
雨白14 小时前
掌握 NestedScrolling 嵌套滑动:手写仿知乎折叠主页
android
Xzaveir16 小时前
别把所有“认证”都塞进 AuthService:实名、一键登录与号码身份的领域拆分
android·人工智能
BerrySen17816 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
AFinalStone17 小时前
Android 7系统休眠唤醒(一)电源管理架构全景图
android·powermanager·电源管理
YM52e18 小时前
鸿蒙Flutter Center居中组件:Align对齐详解
android·学习·flutter·华为·harmonyos·鸿蒙
时间的拾荒人19 小时前
MySQL 视图详解
android·数据库·mysql