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

时间:2012-10-30 00:54:29

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

我现有的代码类似于:

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<IEnumerable<SomeClass>>不是迭代器接口类型。

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

问题:

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

3 个答案:

答案 0 :(得分:21)

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

您实际想要返回的是IEnumerable<T>的一些异步替代:类似IObservable<T>,来自TPL Dataflow的数据流块或IAsyncEnumerable<T>,计划添加到C#8.0 / .Net Core 3.0。 (与此同时,some libraries包含它。)

使用TPL Dataflow,一种方法是:

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

答案 1 :(得分:18)

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

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

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

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<T>(将需要对调用函数进行重大修改),您将无法获得显着的好处。

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

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在使用完成之前不会收集连接。由于这些原因,这不是一个安全的解决方案,我必须强调,我将其包括在内以帮助回答您的第二个问题。

答案 2 :(得分:1)

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

  

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

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

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

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

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

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