面试官:“请实现下面这个并发策略”。
这是一个获取最高价格的请求策略。请求价格的接口被分成了并发组(蓝色)和串行组(绿色)。并发即所有请求同时发出,串行组即仅当上一请求返回才请求下一个。并发组和串行组之间又是并发的关系,当并发组中所有请求返回时会截断串行组,即终止串行组中后续请求,然后选取所有返回价格中的最大值。串行组中每个请求都有底价,当返回价格高于底价时则截断后续请求,否则继续串行请求直到比价成功或被并发组截断。
这是一道串并行网络请求嵌套且有截断操作的复杂问题。但如果使用 Kotlin 的 Flow,问题就变得异常简单。
关于 Kotlin Flow 基础概念、原理、应用的详细介绍可以点击下面的文章:
Kotlin Flow 能极大地简化实现代码,是因为“模型的力量”。
好的模型总是以独特视角切入问题,以更准确有效的表达描述问题,最终降低了问题的复杂性使其易于处理。
比如:3D 图像的旋转、平移、缩放是一个复杂操作。但当把 3D 图像的点集合抽象为矩阵,就能通过矩阵的运算来表达这些复杂操作。
Kotlin Flow 即是对多个连续异步过程的抽象模型,它将其理解为异步数据流,即一条时间轴上按序产生数据,其生产者和消费者之间一条管道,生产者从管道的一头插入数据,消费者从另一头取数据。
同样地,为了简化面试题并发策略的实现逻辑,将网络请求抽象为“数据类+挂起方法”:
// 数据类
data class Request(
val name: String, // 请求名称
val delay: Long, // 响应时延
val price: Int, // 价格
val bottomPrice: Int = 0 // 底价
)
// 获取价格
private suspend fun load(request: Request): Request {
delay(request.delay)
return request
}
每个 Request 描述一个请求,一次 load() 调用表示一次网络请求。
这样一来,表达串/并行组就很省事了:
// 并行组
private val parallelList = listOf(
Request("parallel1", 1600, 10),
Request("parallel2", 2900, 20),
Request("parallel3", 2000, 11),
Request("parallel4", 3000, 30),
Request("parallel5", 5000, 50),
Request("parallel6", 10000, 60),
)
// 串行流(包含底价)
private val sequenceList = listOf(
Request("sequence1", 2100, 30, 30),
Request("sequence2", 1100, 19, 20),
Request("sequence3", 2000, 15, 16),
Request("sequence4", 2200, 7, 10),
Request("sequence5", 3000, 6, 5),
Request("sequence6", 2000, 5, 5),
Request("sequence7", 400, 5, 5),
)
串行请求以列表形式存在,使用asFlow()方法将其转换为 Flow:
val sequenceFlow: Flow = sequenceList.asFlow()
异步数据流 sequenceFlow 中流动的元素是 Request。
其中 asFlow() 是 Iterable 的扩展方法:
public fun Iterable.asFlow(): Flow =
// 创建一个流
flow {
// 遍历列表并将其中每个元素串行地发送到流上
forEach { value ->
emit(value)
}
}
元素按照列表顺序挨个发射,所以这是个串行流。
sequenceFlow 中每一个元素对应一个异步请求,使用onEach()方法即可实现该效果:
val sequenceFlow: Flow =
sequenceList.asFlow().onEach { load(it) }
其中 onEach() 的定义如下:
public fun Flow.onEach(action: suspend (T) -> Unit): Flow =
transform { value ->
action(value)// 在发射数据前做一件事情
return@transform emit(value)// 总是将数据发送到下游
}
public inline fun Flow.transform(
crossinline transform: suspend FlowCollector.(value: T) -> Unit
): Flow =
// 构建下游流
flow {
// 收集上游数据(这里的逻辑在下游流被收集的时候调用)
collect { value ->
// 处理上游数据
return@collect transform(value)
}
}
几乎所有 Flow 的扩展方法都使用拦截转发机制实现,即创建一个新的流,它作为中间消费者会订阅上游数据并转发给下游。关于这方面更详细的讲解可以点击Kotlin 进阶 | 异步数据流 Flow 的使用场景。
https://juejin.cn/post/6989032238079803429
当前场景中,下游想消费价格,使用map()将流上数据进行变换:
val sequenceFlow: Flow =
sequenceList.asFlow().map { load(it).price }
在 map 之前,sequenceFlow 中流动的是 Request,map 之后就变成了价格。
现在只是定义了流,即指定了流中会输入什么数据,执行什么变化,但流并没有被启动,数据并不会自发地从上游流到下游,这种特性叫冷流。就好比你做了一个月后的旅游攻略,但现在还未踏上行程。
触发冷流流动的操作叫收集:
scope.launch {
sequenceFlow.collect { price ->
// 价格最终会串行地流到这里
}
}
但先不急着收集串行流,因为它需要和并行流一起触发。
依葫芦画瓢,先将并行组转化为流:
val parallelFlow = parallelList.asFlow()
此时 parallelFlow 还是个串行流,调用flatMapMerge()使其并发。该方法名指明了要做三件事flat展平、map变换、merge合流。
为啥做了这三件事后,就产生了并发?
这先得从“map变换”说起:
public fun Flow.flatMapMerge(
transform: suspend (value: T) -> Flow//变换lambda
): Flow = map(transform).flattenMerge()
flatMapMerge() 的实现先用 map() 将元素变换,然后用 flattenMerge() 将其展平再合并。变换被定义为(value: T) -> Flow
有了嵌套就需要展平,即将二维结构摊平到一维。
拿列表举例,比如List>:
val lists = listOf(
listOf(1,2,3),
listOf(4,5,6)
)
Log.v("ttaylor","${lists.flatten()}") //[1, 2, 3, 4, 5, 6]
Log.v("ttaylor","${lists.flatMap { it.map { it+1 } }}") //[2, 3, 4, 5, 6, 7]
List.flat()将两层嵌套结构变成单层结构,List.flatMap()在展平的同时提供了变换内部 List 的机会。
而 flattenMerge() 即是将Flow
public fun Flow>.flattenMerge(
concurrency: Int = DEFAULT_CONCURRENCY
): Flow {
return if (concurrency == 1) flattenConcat() else ChannelFlowMerge(this, concurrency)
}
当并发数大于1时,构建 ChannelFlowMerge 对象:
internal class ChannelFlowMerge(
private val flow: Flow>, // 嵌套流
private val concurrency: Int,
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = Channel.BUFFERED,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
) : ChannelFlow(context, capacity, onBufferOverflow) {
override suspend fun collectTo(scope: ProducerScope) {
// 信号量控制并发数
val semaphore = Semaphore(concurrency)
// 最终收集者
val collector = SendingCollector(scope)
val job: Job? = coroutineContext[Job]
// 收集嵌套流
flow.collect { inner ->
job?.ensureActive()
semaphore.acquire()
// 为每一个内嵌流启动新协程并收集之(并发根源)
scope.launch {
try {
// 所有内嵌流的元素都会发送给最终收集者(merge)
inner.collect(collector)
} finally {
semaphore.release()
}
}
}
}
}
所有疑云都消散了:
为啥要展平?
因为有嵌套流。
为啥要将流中每个元素都转换为一个新的流形成嵌套?
因为每个新的流会在单独协程中被收集,不同协程间是并发的。
变换,展平之后为啥要合流?
因为所有内嵌流都会将数据发送同一个收集者,以方便下游消费数据。
理解了“展平变换合流”之后,当前场景就应做如下变换:
val parallelFlow = parallelList.asFlow().flatMapMerge {
request -> flow { emit(load(request).price) }
}
每个 Request 都被变换成一个流,该流会执行请求并将价格发送给下游。
现在只要收集 parallelFlow 就能并发地拿到价格:
scope.launch {
parallelFlow.collect { price ->
// 价格最终会并行地流到这里
}
}
但先不急着收集并发流,因为它需要和串行流一起触发。
串/并行流都已具备,是不是执行下面的操作就能实现题意:
scope.launch {
flowOf(sequenceFlow, parallelFlow)
.flattenMerge() // 串行组&并行组并发
.collect { // 获取并/串流中所有价格 }
}
sequenceFlow 和 parallelFlow 都是Flow
漏了一个功能点:截流。
实现截流最简单的思路是:算出并发流的最高价,以此价格终止串行流。
求流中元素的最大值无异于求列表元素最大值:以临时变量暂存最大值,遍历列表,将每个元素和最大值比较,若大于则更新临时变量。
按此思路,新增扩展方法如下:
public suspend fun Flow.max(): T {
var max: Any? = NULL
// 收集流
collect { value ->
if(value > max) max = value
}
return max as T
}
在 Flow 中,像 max() 这样的方法称为终端消费者,因为它是 Flow 最下游的收集者。collect() 是 suspend 方法,它会挂起协程直到所有元素都被消费完毕。
Kotlin 提供了丰富的终端消费者,比如reduce()也可以实现相同的效果:
public suspend fun Flow.reduce(
// 累加算法
operation: suspend (accumulator: S, value: T) -> S
): S {
var accumulator: Any? = NULL// 累加器
collect { value ->
// 累加每个元素
accumulator = if (accumulator !== NULL) {
operation(accumulator as S, value)
} else {
value
}
}
return accumulator as S
}
它的算法框架和 max 一模一样,只是实现了比取最大值稍复杂的累加功能。
也可以用 reduce() 实现当前需求:
val parallelMaxPrice = parallelList.asFlow()
.flatMapMerge { request -> flow { emit(load(request).price) } }
.reduce { max, cur -> if (cur > max) cur else max }
对流使用终端消费者后,流就不再是流了,而变成一个值。就像上面的 parallelMaxPrice 是一个 Int 值。
这个值得继续流动起来,才能参与串行流的截断。
在 Flow 中一个屡试不爽的常规操作就是:“拦截转发”。
private val parallelMaxFlow = flow {
parallelList.asFlow()
.flatMapMerge { request -> flow { emit(load(request).price) } }
.reduce { max, cur -> if (cur > max) cur else max }
// reduce 的返回值是价格,在新流中发送之
.also { emit(it) }
}
这样所有并发组的请求流外层就被套了一层新的流,流动的是并发流最高价格。
最后一个问题:如何中断流?—— 抛异常。
internal actual class AbortFlowException actual constructor(
actual val owner: FlowCollector<*>
) : CancellationException("Flow was aborted, no more elements needed") {}
只要在 Flow 中抛出AbortFlowException就可实现流的中断。它继承自 CancellationException,所以流的中断依赖于 suspend 方法的中断。关于这方面原理的讲解可以点击裸辞-疫情-闭关-复习-大厂offer(一)
https://juejin.cn/post/7126712379894661151
Kotlin 为中断流提供了一个更友好的扩展方法transformWhile(),套路依然是拦截转发,即新建下游流,它生产数据的方式是通过收集上游数据,并将数据转发到一个带有发射数据能力的 lambda 中,当前这个 lambda 有一个返回值,该值决定了是否要终止上游流数据的生产(即是否抛出异常)。关于该方法更详细的介绍可以点击Android 架构之 MVI 初级体 | Flow 替换 LiveData 重构数据链路
https://juejin.cn/post/7087718088681979934
当前并/串行流中流动的都是价格,为了方便实现截断,在价格外再包一层:
data class RequestSwitch(val price: Int, val isDone: Boolean)
当 isDone = true 表示整个请求链应该被终止。用 RequestSwitch 重构串/并行流:
// 串行流
val sequenceFlow = sequenceList.asFlow()
.map {
// 价格 >= 底价,则自行截断
val isDone = it.run { price >= bottomPrice }
RequestSwitch(load(it).price, isDone)
}.transformWhile {
// 总是将串行流中的价格转发到合并流,并且永不截断
emit(RequestSwitch(it.price, false))
!it.isDone // 为true表示串行流自行截断
}
// 并行流
val parallelFlow = flow {
parallelList.asFlow()
.flatMapMerge { request -> flow { emit(load(request).price) } }
.reduce { max, cur -> if (cur > max) cur else max }
// 并行流的 isDone = true 表示截断串行流
.also { emit(RequestSwitch(it, true)) }
}
// 消费合流
scope.launch {
val maxPrice = flowOf(sequenceFlow, parallelFlow)//串并合流
.flattenMerge()
.transformWhile {
emit(it.price) // 总是将上游价格转发至下游
!it.isDone // 这一行为true表示终止合流
}
.reduce { max, value -> if (value > max) value else max }
}
仅用了 31 行代码就表达了如此复杂的并发策略。还未使用 java 实现过该功能,yy 一下,100 行左右?(欢迎 java 大佬在评论区给出实现方案)
面试官:“为获取最高价格策略整体设置一个超时,并且当所有请求都失败时,返回-1。”
作者:唐子玄
链接:https://juejin.cn/post/7253652211108331579
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。