【问题标题】:How can I make `await …` work with `yield return` (i.e. inside an iterator method)?如何使 `await ...` 与 `yield return` 一起工作(即在迭代器方法中)?
【发布时间】:2012-10-19 08:47:45
【问题描述】:

我的现有代码类似于:

IEnumerable<SomeClass> 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<SomeClass>> GetStuff()

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

GetStuff() 的主体不能是迭代器块,因为Task&lt;IEnumerable&lt;SomeClass&gt;&gt; 不是迭代器接口类型。

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

问题:

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

【问题讨论】:

  • 迭代器块在这里真的有用吗?不会构建List&lt;SomClass&gt; 并返回几乎相同的工作吗?
  • @svick:不,因为处理 SomeClass 很昂贵。迭代器块的结果由多个线程处理。针对生产数据构建完整列表将花费几分钟时间,而这些时间可以用于处理结果。此外,完整列表会非常大,不必要地消耗内存。

标签: c# ado.net async-await generator yield-return


【解决方案1】:

从 C# 8 开始,这可以通过 IAsyncEnumerable 完成

修改代码:

async IAsyncEnumerable<SomeClass> 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;
        }
    } 
}

像这样消费它:

await foreach (var stuff in GetStuff())
    ...

【讨论】:

    【解决方案2】:

    问题是你问的实际上没有多大意义。 IEnumerable&lt;T&gt; 是一个同步接口,返回 Task&lt;IEnumerable&lt;T&gt;&gt; 对您没有多大帮助,因为无论如何,某些线程必须阻塞等待每个项目。

    您真正想要返回的是IEnumerable&lt;T&gt; 的一些异步替代方案:类似于IObservable&lt;T&gt;,来自TPL Dataflow 的数据流块或IAsyncEnumerable&lt;T&gt;,计划添加到C# 8.0/.Net Core 3.0。 (与此同时,somelibraries 包含它。)

    使用 TPL 数据流,一种方法是:

    ISourceBlock<SomeClass> GetStuff() {
        var block = new BufferBlock<SomeClass>();
    
        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;
    }
    

    您可能希望在上面的代码中添加错误处理,但除此之外,它应该可以工作并且它将是完全异步的。

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

    【讨论】:

    • 我没有投反对票,但对读者来说:迈克的答案更好,请看下面!
    • @CsabaToth 你能解释一下你为什么这么认为吗?迈克在他的回答中包含了两个替代方案,你的意思是哪一个? (此外,我倾向于否决实际上错误的答案或提倡不良做法,而不仅仅是我认为另一个答案更好。当然,这取决于你如何投票。)
    • 天真地,刚刚开始研究 TPL 数据流,这看起来是一个更好的解决方案,因为它是我收集的数据流旨在解决的问题之一(并且相对最佳)。
    • Svick , ExecuteReaderAsync() 是否从 db 获取所有相关行?还是 ReadAsync 是谁做的?
    • @RoyiNamir 如果ExecuteReaderAsync() 检索了所有行,则没有理由使ReadAsync() 异步。但这只是我的猜测,快速浏览一下文档并没有说明它,我不知道它是如何实际实现的。
    【解决方案3】:

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

    也许,由于测试范围仅限于我的机器和本地 SQL Server 实例,因此对这一观察持保留态度。

    别误会,.NET 环境中的async/await 范式在适当的情况下非常简单、强大且有用。然而,经过大量的辛勤工作,我不相信数据库访问是一个合适的用例。当然,除非您需要同时执行多个命令,在这种情况下,您可以简单地使用TPL 来同时触发这些命令。

    我更喜欢的方法是考虑以下因素:

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

    您可以提出以下论点:在某些报告场景中,上述某些要求是不可能的。但是,在报告服务的上下文中,真的需要异步性(甚至是一个词吗?)?

    Microsoft 布道者 Rick Anderson 就这个主题发表了精彩的 article。请注意,它已经过时(从 2009 年开始),但仍然非常重要。

    【讨论】:

      【解决方案4】:

      不,您目前不能将 async 与迭代器块一起使用。正如 svick 所说,你需要像 IAsyncEnumerable 这样的东西来做到这一点。

      如果您有返回值Task&lt;IEnumerable&lt;SomeClass&gt;&gt;,则意味着该函数返回单个Task 对象,一旦完成,将为您提供一个完整的 IEnumerable(此枚举中没有任务异步空间)。一旦任务对象完成,调用者应该能够同步遍历它在可枚举中返回的所有项目。

      这是一个返回Task&lt;IEnumerable&lt;SomeClass&gt;&gt; 的解决方案。通过执行以下操作,您可以获得异步的很大一部分好处:

      async Task<IEnumerable<SomeClass>> 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<SomeClass> 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<IEnumerable<SomeClass>> itemsAsync = GetStuff();
          // Wait for the task to complete so we can get the items
          IEnumerable<SomeClass> 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&lt;T&gt; 之类的基于推送的系统将不会获得显着的好处(这将需要对调用函数进行重大修改)。

      为了说明,请考虑解决同一问题的另一种方法:

      IEnumerable<Task<SomeClass>> 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<SomeClass> 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<Task<SomeClass>> items = GetStuff();
          // Iterate through the Tasks
          foreach (Task<SomeClass> 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 在完成使用之前不会收集连接。由于这些原因,这不是一个安全的解决方案,我必须强调,我将其包括在内是为了帮助回答您的第二个问题。

      【讨论】:

      • 我相信你错了,SqlConnection.Dispose() 在你的第二个版本中不会被调用。该连接在迭代器块方法中位于using 中,因此在释放枚举器时会释放它,这会自动发生在foreach 的末尾。您可以在第一个版本中使用类似的方法,将一次性对象传递给ReadItems()
      • 是的,我认为你是对的。我已从答案中删除了该声明。
      猜你喜欢
      • 2017-12-19
      • 2013-05-19
      • 2022-01-15
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-05-24
      相关资源
      最近更新 更多