自从 VB/C#开始支持 async/await 后,开发者一直在期待异步版本的 IEnumerable。但直到 C# 7 和 ValueTask 发布前,从性能的角度来看这一要求几乎是不可能实现的。
在老版本 C#中,开发者每次使用 await 时都需要进行内存分配。如果要枚举 10,000 个项,则需要分配 10,000 个 Task 对象。就算使用任务缓存,这个数量也实在是太多了。通过使用ValueTask
,可以只在某些情况下分配内存,此时IAsyncEnumerable<T>
这种想法似乎也更可行了。
因此本文准备回顾一下 2015 年 9 月有关异步流的提议。
IAsyncEnumerable 和 IAsyncEnumerator
这一套接口是IEnumerable<T>
的异步组件,不过通过下列方式进行了相应的简化:
public interface IAsyncEnumerable<t> { public IAsyncEnumerator<t> GetEnumerator(); } public interface IAsyncEnumerator<out t=""> { public T Current { get; } public Task<bool> MoveNextAsync(); } </bool></out></t></t>
如上所示,IEnumerator 缺少 Dispose 或 Reset 方法。Reset 的存在只是为了实现与 COM 的兼容性,如果真的有多个枚举器实现了这种方法,这种做法还是会让人感觉意外。Dispose 大概会被取消,因为很多人认为,假设所有枚举器必然是的可释放的,这想法本身就是错的。
仅仅让MoveNext
成为异步的,即可为我们带来两个收益:
Task<bool>
的缓存要比Task<T>
的缓存更容易,因此可减少内存分配量。
已经支持IEnumerator<T>
的类只需要额外添加一个方法。
如上所述,“健谈”的异步库最大的问题就是内存分配。对于实现 IAsyncEnumerator 的类,这并不一定会成为问题:
假设正在对这样的异步序列进行 foreach,并且在内部进行了缓冲,因此在 99.9% 的时间里,一个元素都会是本地可用并且同步可用的。如果正在 await 的 Task 已经完成,编译器会避免进行繁重的运算,并且会无需暂停直接从任务中获得所需的值。如果调用的特定方法内所有 await 的 Task 均已完成,那么所用方法绝对不会分配状态机,或通过委托存储为继续(Continuation),因为这些东西只会在首次需要时进行构造。
就算异步方法同步达到了 return 语句,由于 await 无需暂停,此时依然需要构造一个 Task 才能返回。因此一般来说这依然需要进行一次分配。然而编译器为该过程使用的助手 API 实际上会对已完成的 Task 缓存某些通用值,包括 true 和 false。简而言之,针对已缓冲序列调用的 MoveNextAsync 以及调用方法通常什么都不会分配。
但如果数据并不一定被缓冲,此时又会怎样?
猜测:此时适合使用ValueTask
或其他自定义的任务类型。理论上我们甚至可以提供一个“可重置的任务”,借此在枚举器调用 MoveNextAsync 时清除“已完成”标记。此类优化尚未进行过公开的讨论,甚至有可能是不可行的,不过 C# 7 开始考虑这个问题。
异步 LINQ
回到 2015 提议,接下来要考虑的是 LINQ。LINQ 最大的问题在于 synchronous/asynchronous 源和 synchronous/asynchronous 委托之间组合的绝对数目。例如一个简单的 Where 函数可能需要四个重载(Overload):
public static IEnumerable<t> Where<t>(this IEnumerable<t> source, Func<t bool=""> predicate); public static IAsyncEnumerable<t> Where<t>(this IAsyncEnumerable<t> source, Func<t bool=""> predicate); public static IAsyncEnumerable<t> Where<t>(this IEnumerable<t> source, Func<t task=""><bool>> predicate); public static IAsyncEnumerable<t> Where<t>(this IAsyncEnumerable<t> source, Func<t task=""><bool>> predicate); </bool></t></t></t></t></bool></t></t></t></t></t></t></t></t></t></t></t></t>
因此提议中提到:
因此我们要么需要将 LINQ 的外围应用翻四倍,要么需要为该语言引入某种新的隐式转换,例如从 IEnumerable 变为 IAsyncEnumerable,或从 Func 变为 Func>。这种做法值得考虑,但我们觉得也许可以通过某种方式让 LINQ 支持异步序列。
翻四倍的方法的影响可能被低估了,因为一些 LINQ 操作可能需要多个委托。
另一个问题是,到底要使用基于Task
还是基于ValueTask
的委托。2017 年的一份文档提到了这个问题:“希望通过异步委托实现重载(可通过ValueTask
提高效率)”。
语言支持
很明显,对于 IAsyncEnumerable 人们首先会考虑异步 foreach,但如果代码中根本没有实际出现 Task 对象又会如何?这方面讨论过的选项包括:
foreach (string s in asyncStream) { ... } //implied await await foreach (string s in asyncStream) { ... } foreach (await string s in asyncStream) { ... } foreach async (string s in asyncStream) { ... } foreach await (string s in asyncStream) { ... }
同样的问题在于,执行诸如 ConfigureAwait 等操作时,从性能的角度考虑,库中的哪些东西是最重要的?如果不使用 Task,又该如何进行 ConfigureAwait?此时的最佳做法是同时向 IAsyncEnumerable 增加一个 ConfigureAwait 扩展方法。这样即可返回包装序列,进而返回包装枚举器,其 MoveNextAsync 可返回针对包装的枚举器中所包含的 MoveNextAsync 方法返回的任务调用 ConfigureAwait 后的结果:
该提议进一步谈到:
为此必须要让异步 foreach 像目前的同步 foreach 一样成为基于模式的(Pattern based),这样即可灵活调用任何 GetEnumerator、MoveNext 和 Current 成员,而无须考虑对象是否实现了正式的“接口”。这样做的原因在于 Task.ConfigureAwait 的结果并不是 Task。
继续回到我们的猜测,这意味着一个类将可以在提供基于自定义枚举器的ValueTask
等内容同时,继续支持IAsyncEnumerable<T>
。这一点与List<T>
的工作方式类似,可通过通用的IEnumerable<T>
和基于结构(Struct)的备用枚举器实现更高性能。
取消令牌
接着是 2017 年 1 月的会议纪要,一起看看异步流。首先是一个有关取消令牌(Cancellation token)的棘手问题。
GetAsyncEnumerator 能够接受可选的取消令牌,但具体是怎样做的?根据会议纪要:
- 使用另一个重载 :-(
- 使用一个默认参数 (CLS :-()
- 使用一个扩展方法(要求该扩展方法位于范围内)
有趣的是,尽管 C#长期以来都支持默认参数,但 CLS,即通用语言规范的约束依然是生效的。对于不熟悉这一概念的人可以这样理解:CLS 定义了.NET 平台上所有语言必须支持的最小功能集。另外,大部分库,尤其是基础库的令牌必须兼容CLS。
抛开具体API 不谈, IAsyncEnumerable<T>
获得取消令牌的方法就很明确了。但诸如 Foreach block 等迭代器如何获得取消令牌还不明确。他们正在考虑通过某种“走后门”的方法从状态机中得到令牌,但这可能需要修改枚举器的接口。
TryMoveNext?
继续看看之前提到的性能问题,如果可以在不进行异步调用的情况下检查是否已经具备可用数据,情况又会如何?
这正是添加bool? TryMoveNext()
方法的理论依据。True/false 可以按照预期工作,但如果获得了空值,则意味着需要调用MoveNextAsync
来确定是否存在任何额外的数据。
此外也可考虑使用显式 Chunking,其中每个调用可返回仅代表已缓冲数据的可枚举结果。
public IAsyncEnumerable<ienumerable><customer>> GetElementsAsync();</customer></ienumerable>
这些提议在供应方和消耗方目前都还存在一定的问题,因此据此决策尚未确定。
评论