End

Kotlin 朱涛-21 协程 Select 选择最快的结果

本文地址


目录

21 | Select:选择最快的结果

和 Kotlin 的 Channel 一样,select 在很多编程语言当中都有类似的实现,比如 Go、Rust 等。不同语言中,select 的语法可能不太一样,但背后的核心理念都是 选择更快的结果

select 在 Kotlin 1.6 中,仍然是一个实验性的特性(Experimental)。

选择最快的结果

需求:假如我们想查询一个商品的详情,目前有两个服务,其中缓存服务速度快但信息可能是旧的,而网络服务速度慢但信息是最新的。

不使用 select

data class Product(val id: String, val price: Double, val tag: Any)

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val productId = "xxxId"

    suspend fun getCacheInfo(id: String): Product = delay(200L).let { return Product(id, 9.9, "cache") }
    suspend fun getNetInfo(id: String): Product = delay(200L).let { return Product(id, 9.8, "net") }
    fun updateUI(product: Product) = println("${product}, ${System.currentTimeMillis() - startTime}")

    updateUI(getCacheInfo(productId)) // 先使用缓存
    updateUI(getNetInfo(productId))   // 再查询网络
}
Product(id=xxxId, price=9.9, tag=cache), 218
Product(id=xxxId, price=9.8, tag=net), 431

存在的问题:因为 getCacheInfo() 是一个挂起函数,所以只有这个挂起函数执行完成以后,才可以继续执行后面的任务。

select + async

上面这个场景,我们可以用 async + select 来实现,async 可以实现并发,select 则可以选择最快的结果

val product = select<Product> { // 使用 select 包裹 async 启动的两个协程,泛型代表要选择的数据类型
    async { getCacheInfo(productId) }.onAwait { it } // async 两个挂起函数,可以让这两个查询并发执行
    async { getNetInfo(productId) }.onAwait { it }   // 使用 onAwait{} 将执行结果回传给 select{}
}

updateUI(product) // 只会打印最快的结果 Product(id=xxxId, price=9.9, tag=cache), 218

存在的问题:上面代码只会选择最快的那个结果,如果缓存是最快的话,就不会再展示最新的 net 内容。

select + async + Deferred

在上面代码的基础上,稍加修改就可以实现,先展示缓存、再展示最新的内容。

val cacheDeferred: Deferred<Product> = async { getCacheInfo(productId) } // 并发执行
val netDeferred: Deferred<Product> = async { getNetInfo(productId) }     // 并发执行
val product = select<Product> {  // 通过 select 选择最快的那个结果
    cacheDeferred.onAwait { it }
    netDeferred.onAwait { it }
}

updateUI(product)
if ("cache" == product.tag) {   // 如果当前结果来自缓存,那么再取最新的网络结果更新 UI
    updateUI(netDeferred.await())
}
Product(id=xxxId, price=9.9, tag=cache), 214
Product(id=xxxId, price=9.8, tag=net), 215

上面代码中,如果缓存是最快的话,就先展示缓存,再展示最新的 net 内容;否则,只展示最新的 net 内容。

使用 select 的优势

这个例子不使用 select 同样也可以实现,不过,select 这样的代码模式的优势在于,扩展性非常好

可以看到,当增加一个缓存服务进来的时候,我们的代码只需要做很小的改动,就可以实现。

总的来说,对比传统的挂起函数串行的执行流程,select 这样的代码模式,不仅可以提升程序的整体响应速度,还可以大大提升程序的灵活性、扩展性。

选择多个结果

在协程中返回一个内容的时候,我们可以使用挂起函数、async,但如果要返回多个结果的话,就需要用到 Channel 和 Flow。

需求:假如有两个管道,现在需要将它们中的数据收集出来,并逐个打印。

不使用 select

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    fun log(text: Any) = println("${text}, ${System.currentTimeMillis() - startTime}")

    val channel1: ReceiveChannel<Int> = produce { (1..2).forEach { send(it);delay(100L) } }
    val channel2: ReceiveChannel<String> = produce { listOf("a", "b").forEach { send(it);delay(100L) } }

    log(">> start channel1")
    channel1.consumeEach { log("consumeEach1 - $it") }
    log(">> start channel2")
    channel2.consumeEach { log("consumeEach2 - $it") } // channel1 执行完毕以后,才会执行 channel2
    log("end")
}
>> start channel1, 9
consumeEach1 - 1, 33
consumeEach1 - 2, 131
>> start channel2, 244
consumeEach2 - a, 244
consumeEach2 - b, 350
end, 458

存在的问题:两个管道是串行执行的,只有当 channel1 执行完毕以后,才会执行 channel2。

select + Channel.onReceive

repeat(4) { // 重复执行 4 次 select,目的是把两个管道中的所有数据都消耗掉
    select {
        log("   select$it")
        channel1.onReceive { log("onReceive1 - $it") } // 注意,onReceive 可能会崩溃
        channel2.onReceive { log("onReceive2 - $it") } // 注意,onReceive 可能会崩溃
    }
}
log("end")
   select0, 15
onReceive1 - 1, 41
   select1, 42
onReceive2 - a, 45
   select2, 45
onReceive1 - 2, 140
   select3, 140
onReceive2 - b, 156
end, 156

onReceive{} 是 Channel 在 select 当中的语法,当 Channel 中有数据以后,它就会被回调,通过这个 Lambda,我们也可以将结果传出去。

注意,上面的代码中的 onReceive 可能会导致崩溃。

onReceive 可能会导致崩溃

如果两个管道中 delay 的时间间隔较大,比如将 channel1 中 delay 值从 100 改为 1000,这时程序就会出问题了。

   select0, 12
onReceive1 - 1, 39
   select1, 39
onReceive2 - a, 43
   select2, 43
onReceive2 - b, 158
Exception in thread "main" ClosedReceiveChannelException: Channel was closed
	at channels.Closed.getReceiveException(AbstractChannel.kt:1108)
	at channels.AbstractChannel$ReceiveSelect.resumeReceiveClosed(AbstractChannel.kt:989)

这是因为,Channel 中的数据发送完数据以后,就会被关闭,此时再调用 onReceive 就会触发 ClosedReceiveChannelException 异常。

select + onReceiveCatching

在 19 讲中讲过,使用 receiveCatching() 可以防止出现 ClosedReceiveChannelException,类似的,当 Channel 与 select 配合的时候,可以使用 onReceiveCatching{} 这个高阶函数。

repeat(5) { // 可以随便重复多少次都不会崩溃
    select {
        log("   select$it")
        channel1.onReceiveCatching { log("onReceive1 - ${it.getOrNull()}") }
        channel2.onReceiveCatching { log("onReceive2 - ${it.getOrNull()}") }
    }
}
log("end")
channel1.cancel() // 主动取消
channel2.cancel()
   select0, 11
onReceive1 - 1, 34
   select1, 35
onReceive2 - a, 38
   select2, 38
onReceive2 - b, 141    // 因为 channel1 还在 delay,所以依旧会选择 channel2
   select3, 141
onReceive2 - null, 249 // 如果获取的结果是空,就代表管道已经被关闭了
   select4, 249
onReceive2 - null, 249
end, 249

使用 onReceiveCatching{} 后就不用担心崩溃的问题了,如果获取的结果是 null,就代表管道已经被关闭了。

上面的代码中,我们在 repeat() 后,主动将 channel1、channel2 取消了。如果不这么做,当得到所有结果以后,程序不会立即退出。这是因为,由于 channel1 没有接收者,所以会一直 delay。

这种将多路数据以非阻塞的方式合并成一路数据的模式,在其他领域也有广泛的应用,比如操作系统、Java NIO 等等。

项目实战

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    fun log(text: Any) = println("${text}, ${System.currentTimeMillis() - startTime}")

    suspend fun <T> getFastDeferred(vararg deferreds: Deferred<T>): T = select {
        for (deferred in deferreds) {
            deferred.onAwait { t: T ->
                log("onAwait: $t")
                deferreds.forEach { it.cancel() } // 取消其他的 Deferred
                t   // 返回的是最快的那个结果
            }
        }
    }

    fun <T> getDeferredAsync(result: T): Deferred<T> = async {
        val delayTime = Random.nextLong(1000L)
        log("Deferred: $result, delay=$delayTime")
        delay(delayTime)
        result
    }

    val list: Array<Deferred<Int>> = Array(6) { getDeferredAsync(it) }
    val result: Int = getFastDeferred(*list) // 在前面加一个 * 就变成可变数组了
    log("result: $result")
}
Deferred: 0, delay=703, 12
Deferred: 1, delay=129, 19
Deferred: 2, delay=679, 19
Deferred: 3, delay=318, 19
Deferred: 4, delay=58, 19   // 这个任务最快
Deferred: 5, delay=934, 19
onAwait: 4, 94
result: 4, 96

Deferred、Channel 相关 API

当 Deferred、Channel 与 select 配合的时候,它们原本的 API 会多一个 on 前缀。所以,只要记住了 Deferred、Channel 的 API,不需要额外记忆 select 的 API,只需要在原本的 API 的前面加上一个 on 就行了。

public interface Deferred : CoroutineContext.Element {
    public suspend fun join()
    public suspend fun await(): T
    public val onJoin: SelectClause0
    public val onAwait: SelectClause1<T>
}

public interface SendChannel<in E> {
    public suspend fun send(element: E)
    public val onSend: SelectClause2<E, SendChannel<E>>
}

public interface ReceiveChannel<out E> {
    public suspend fun receive(): E
    public suspend fun receiveCatching(): ChannelResult<E>
    public val onReceive: SelectClause1<E>
    public val onReceiveCatching: SelectClause1<ChannelResult<E>>
}

小结

select,就是选择更快的结果

当 select 与 async、Channel 搭配以后,我们可以并发执行协程任务,以此提升程序的执行效率,并且还可以改善程序的扩展性、灵活性。

2017-03-20

posted @ 2017-03-20 14:02  白乾涛  阅读(2697)  评论(0编辑  收藏  举报