我如何使`await …`使用`yield return`(即在迭代器方法中)?

我现有的代码看起来类似于:

IEnumerable GetStuff() { using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn) { conn.Open(); SqlDataReader reader = cmd.ExecuteReader(); while (reader.Read()) { SomeClass someClass = f(reader); // create instance based on returned row yield return someClass; } } } 

看来我可以通过使用reader.ReadAsync()获益。 但是,如果我只修改一行:

  while (await reader.ReadAsync()) 

编译器通知我await只能在标记为async方法中使用,并建议我将方法签名修改为:

 async Task<IEnumerable> GetStuff() 

但是,这样做会使GetStuff()无法使用,因为:

GetStuff()的主体不能是迭代器块,因为Task<IEnumerable>不是迭代器接口类型。

我确信我错过了异步编程模型的关键概念。

问题:

  • 我可以在迭代器中使用ReadAsync()吗? 怎么样?
  • 我怎样才能以不同的方式思考异步范式,以便了解它在这种情况下的工作原理?

你问的问题实际上并没有多大意义。 IEnumerable是一个同步接口,并且返回Task>对你没有多大帮助,因为无论如何,某些线程都必须阻止等待每个项目。

您实际想要返回的是IEnumerable一些异步替代:类似于IObservable ,来自TPL Dataflow的数据流块或IAsyncEnumerable

使用TPL Dataflow,一种方法是:

 ISourceBlock GetStuff() { var block = new BufferBlock(); Task.Run(async () => { using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { await conn.OpenAsync(); SqlDataReader reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { SomeClass someClass; // Create an instance of SomeClass based on row returned. block.Post(someClass); } block.Complete(); } }); return block; } 

您可能希望将error handling添加到上面的代码中,但除此之外,它应该可以工作并且它将完全异步。

然后,其余代码也将异步使用返回块中的项,可能使用ActionBlock

不,您当前不能使用迭代器块的异步。 正如svick所说,你需要像IAsyncEnumerable这样的东西。

如果你有返回值Task>这意味着该函数返回一个Task对象,一旦完成,它将为你提供一个完全形成的IEnumerable(在这个枚举中没有任何空间用于任务异步)。 一旦任务对象完成,调用者应该能够同步迭代它在枚举中返回的所有项。

这是一个返回Task>的解决方案。 通过这样做,你可以获得异步的很大一部分好处:

 async Task> GetStuff() { using (SqlConnection conn = new SqlConnection("")) { using (SqlCommand cmd = new SqlCommand("", conn)) { await conn.OpenAsync(); SqlDataReader reader = await cmd.ExecuteReaderAsync(); return ReadItems(reader).ToArray(); } } } IEnumerable ReadItems(SqlDataReader reader) { while (reader.Read()) { // Create an instance of SomeClass based on row returned. SomeClass someClass = null; yield return someClass; } } 

……以及一个示例用法:

 async void Caller() { // Calls get-stuff, which returns immediately with a Task Task> itemsAsync = GetStuff(); // Wait for the task to complete so we can get the items IEnumerable items = await itemsAsync; // Iterate synchronously through the items which are all already present foreach (SomeClass item in items) { Console.WriteLine(item); } } 

这里有迭代器部分和异步部分在单独的函数中,允许您使用async和yield语法。 GetStuff函数异步获取数据,然后ReadItems同步将数据读入可枚举的数据。

注意ToArray()调用。 这样的事情是必要的,因为枚举器函数执行懒惰,因此你的异步函数可能会在读取所有数据之前处理连接和命令。 这是因为using块覆盖了Task执行的持续时间,但您将在任务完成after进行迭代。

此解决方案不使用ReadAsync ,但它确实使用OpenAsyncExecuteReaderAsync ,这可能会为您带来最大的好处。 根据我的经验,ExecuteReader将占用大部分时间并且最大的好处是异步。 当我读取第一行时, SqlDataReader已经包含了所有其他行,而ReadAsync只是同步返回。 如果您也是这种情况,那么转移到基于推送的系统(如IObservable (这将需要对调用函数进行重大修改)将无法获得显着的好处。

为了便于说明,请考虑针对同一问题的替代方法:

 IEnumerable> GetStuff() { using (SqlConnection conn = new SqlConnection("")) { using (SqlCommand cmd = new SqlCommand("", conn)) { conn.Open(); SqlDataReader reader = cmd.ExecuteReader(); while (true) yield return ReadItem(reader); } } } async Task ReadItem(SqlDataReader reader) { if (await reader.ReadAsync()) { // Create an instance of SomeClass based on row returned. SomeClass someClass = null; return someClass; } else return null; // Mark end of sequence } 

……以及一个示例用法:

 async void Caller() { // Synchronously get a list of Tasks IEnumerable> items = GetStuff(); // Iterate through the Tasks foreach (Task itemAsync in items) { // Wait for the task to complete. We need to wait for // it to complete before we can know if it's the end of // the sequence SomeClass item = await itemAsync; // End of sequence? if (item == null) break; Console.WriteLine(item); } } 

在这种情况下, GetStuff立即返回一个枚举,其中可枚举中的每个项都是一个在完成时将呈现SomeClass对象的任务。 这种方法有一些缺陷。 首先,可枚举同步返回,所以在它返回时我们实际上不知道结果中有多少行,这就是为什么我使它成为无限序列。 这是完全合法的,但它有一些副作用。 我需要使用null来表示无限任务序列中有用数据的结束。 其次,你必须要小心你如何迭代它。 您需要向前迭代它,并且需要在迭代到下一行之前等待每一行。 在完成所有任务之后,您还必须仅处理迭代器,以便GC在完成使用之前不会收集连接。 由于这些原因,这不是一个安全的解决方案,我必须强调,我将其包括在内以帮助回答您的第二个问题。

根据我的经验,在SqlCommand的上下文中严格地说是异步迭代器(或者有可能),我注意到代码的同步版本远远超过它的async对应。 在速度和内存消耗方面。

也许,考虑到我的机器和本地SQL Server实例,测试范围仅限于这种观察。

不要误解我的意思,在适当的环境下,.NET环境中的async / await范例非常简单,强大且有用。 然而,经过多次努力,我不相信数据库访问是一个适当的用例。 除非您当然需要同时执行多个命令,在这种情况下,您只需使用TPL即可同时触发命令。

我首选的方法是采取以下考虑:

  • 保持SQL的单位工作小,简单和可组合(即使你的SQL执行“便宜”)。
  • 避免在可以向上游推送到应用级别的SQL Server上工作。 一个完美的例子就是排序。
  • 最重要的是,大规模测试SQL代码并查看Statistics IO输出/执行计划。 在10k记录快速运行的查询,当有1M记录时,可能(并且可能会)完全不同。

您可以提出这样的论点:在某些报告方案中,上述某些要求是不可能的。 但是,在报告服务的背景下,异步性(即使是一个单词?)真的需要吗?

微软福音传道者里克安德森有一篇关于这个话题的精彩文章 。 请注意,它已经过时(从2009年开始),但仍然非常相关。