合并并发的IAsyncEnumerable操作以提高性能

136 阅读4分钟

[

Stefan Schranz

](stefansch.medium.com/?source=pos…)

斯特凡-施兰茨

关注

6月20日

-

4分钟阅读

[

保存

](medium.com/m/signin?ac…)

合并并发的IAsyncEnumerable操作以提高性能

最近我终于有机会在对这些新的异步流进行的一些概念验证之外,使用一些IAsyncEnumerable方法进行工作。由于它很适合,我想我可以同时运行多个IAsyncEnumerable,但我很快注意到没有一个好的、内置的方法可以将多个异步流合并成一个,所以我决定潜心研究并建立我自己的系统

IAsyncEnumerable的快速介绍

如果你还不熟悉IAsyncEnumerable,我可以给你介绍。它是一个(相对)新的特性,是在C#8中引入的,本质上代表了一个基于异步拉动的流。它是众所周知的IEnumerable的异步对应物。如果你现在想知道为什么我们需要这个,因为我们已经可以使用Task<IEnumerable>,主要的区别是新的API允许一个生成器,一旦有东西被返回就会产生执行。这在IEnumerable中是不可能的,因为它不可能有一个异步的生成器。你总是在完成后得到所有的结果,而中间没有任何东西。

下面是一个演示IAsyncEnumerable的例子。

async Task Main(){ await foreach (var item in Iterate()) {  Console.WriteLine(item); }}

合并IAsyncEnumerable

很好,现在我们知道它是如何工作的了。让我们回到我最初面临的问题。我想,从本质上讲,消耗一个端点,如

public async IAsyncEnumerable<int> Iterate(){ for (var i = 0; i < 5; ++i) {  await Task.Delay(100);  yield return i; }}

多次,同时进行。但是,假设我们一次创建多个IAsyncEnumerable。

var asyncIterators = Enumerable.Range(0, 3).Select(_ => Iterate());

现在,我们要如何迭代它们呢?

如果我们一个一个地迭代,那么我们基本上先迭代第一个IAsyncEnumerable,然后是第二个,然后是第三个。这将花费我们总共15*100ms的时间来完成。与普通的 "热 "任务不同,第2号和第3号IAsyncEnumerable也不会在后台开始迭代--记住,我们是在处理基于拉动的生成器。除非有人从里面拉出内容,否则根本不会发生什么。

所以,让我们开始吧。我的主要目标是将合并后的东西也暴露为IAsyncEnumerable,这样外面就不必改变它们的用法,适应不同的模式。

首先,我创建了一个接受任何数量的IAsyncEnumerable的类,并继承于IAsyncEnumerable。

public class MergedAsyncEnumerable<T> : IAsyncEnumerable<T>{ private readonly IAsyncEnumerable<T>[] _asyncEnumerables;

GetAsyncEnumerator是一个由接口引入的方法,所以我们必须实现它。我还添加了一个方法,我们将用它来消耗合并后的流,我们返回枚举器以符合接口的要求。

现在我们有了这个设置,我们需要做的就是在ConsumeMergedAsyncEnumerabled方法中把我们的逻辑理顺。核心思想是将我们的异步枚举器转化为一个结构,该结构可以并发地迭代每一个异步枚举器,并让我们为每一个枚举器拉出一个任务。

一旦我们有了每个异步枚举对象的当前 "待定 "任务,我们就可以使用Task.WhenAny(...)来抓取第一个完成的任务,并将迭代器的索引向前移动。如果任何一个async enumerable没有任何进一步的条目,我们将在下一个WhenAny中忽略它,并且我们继续这样做,直到所有的条目都被处理。

这就是我们的索引迭代器结构。

private record IndexedIteratorResult(T Item, bool HasMore, int Index);

它接受一个IAsyncEnumerator并按需推进。

把这一切放在一起,这就是最终的实现。

private async IAsyncEnumerable<T> ConsumeMergedAsyncEnumerabled() {  var iterators = _asyncEnumerables   .Select((x, index) => new IndexedIterator(x, index))   .ToArray();

首先,我们创建一个IndexedIterators数组,每个异步枚举器都有一个。

然后,我们通过将每个迭代器提前一个来启动初始的任务数组。这将同时发生。

接下来,我们循环浏览这些任务,直到所有的任务都是空的,这基本上表明没有任何一个异步枚举器还有任何项目。

我们抓取集合中第一个完成的任务,返回它的值(因此为我们组合的IAsyncEnumerable的消费者生成一个条目),并根据它是否有剩余的项目,将其标记为完成,或推进其迭代器。

所有这些工作背后的驱动力是,所有异步流的Task都在同时运行,而我们恰好选择了第一个完成的,而其他的则继续工作。

为了证明这一点,下面是我们得到的结果。

sw.Restart();

完美!我们达到了500毫秒,而不是1500毫秒。我们达到了500ms,而不是1500ms,保留了IAsyncEnumerable的处理方式,并且正确地使所有的东西都能同时运行!我完全知道这一点。

请注意,我完全知道这可能不是最聪明或最有效的方法,但这是我的工作,这有助于我正确理解IAsyncEnumerable,它的约束和它的基础机制。

希望这对你有帮助!