异步任务的串并行嵌套及截断

引子

面试官:“请实现下面这个并发策略”。

异步任务的串并行嵌套及截断_第1张图片

这是一个获取最高价格的请求策略。请求价格的接口被分成了并发组(蓝色)和串行组(绿色)。并发即所有请求同时发出,串行组即仅当上一请求返回才请求下一个。并发组和串行组之间又是并发的关系,当并发组中所有请求返回时会截断串行组,即终止串行组中后续请求,然后选取所有返回价格中的最大值。串行组中每个请求都有底价,当返回价格高于底价时则截断后续请求,否则继续串行请求直到比价成功或被并发组截断。

这是一道串并行网络请求嵌套且有截断操作的复杂问题。但如果使用 Kotlin 的 Flow,问题就变得异常简单。

关于 Kotlin Flow 基础概念、原理、应用的详细介绍可以点击下面的文章:

  • Kotlin 进阶 | 异步数据流 Flow 的使用场景
  • Kotlin 异步 | Flow 限流的应用场景及原理
  • Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?(一)

模型的力量

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,即将流中元素变换成一个新的流。这样就形成了嵌套流,从Flow到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>展平为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类型的流。flowOf() 在它们外面又套了一层,形成Flow>嵌套结构,接着使用 flattenMerge() 将其展平合流,最终下游可以拿到串/并流中的所有价格。

漏了一个功能点:截流。

实现截流最简单的思路是:算出并发流的最高价,以此价格终止串行流。

求流中元素的最大值无异于求列表元素最大值:以临时变量暂存最大值,遍历列表,将每个元素和最大值比较,若大于则更新临时变量。

按此思路,新增扩展方法如下:

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。”

推荐阅读

  • Kotlin 进阶 | 异步数据流 Flow 的使用场景
  • Kotlin 异步 | Flow 限流的应用场景及原理
  • Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?(一)
  • 面试题 | 等待多个异步任务的结果
  • Android 架构之 MVI 初级体 | Flow 替换 LiveData 重构数据链路

作者:唐子玄
链接:https://juejin.cn/post/7253652211108331579
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你可能感兴趣的:(Kotlin,流,android)