在Select linq查询中使用async/await

8

阅读完这篇文章后:Nesting await in Parallel.ForEach

我尝试做以下事情:

private static async void Solution3UsingLinq()
{
    var ids = new List<string>() { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" };

    var customerTasks = ids.Select(async i =>
    {
        ICustomerRepo repo = new CustomerRepo();
        var id = await repo.getCustomer(i);
        Console.WriteLine(id);

    });
}

由于某些原因,这个不起作用...我不知道为什么,我认为可能存在死锁,但我不确定...


顺便提一下:C#风格指南规定,C#中的所有方法名都应以大写字母开头。此外,“async”方法应带有后缀“Async”。因此,“getCustomer”实际上应该被称为“GetCustomerAsync”。 - spender
@spender,您绝对正确,这只是我快速编写的一个示例代码,以检查 Linq 中 await 的行为方式... - Ariel Volnicher
1
再次强调:查询表达式的结果是表示查询的对象,而不是查询的结果。您需要通过执行查询来获取结果。 - Eric Lippert
2
因为在任何情况下都不应该做你所做的事情。Select 不是 foreach。如果你想对序列中的每个元素执行某些操作,那么就需要使用 foreach 循环,并将操作放在循环体内。Select 应该是选择某些东西,而不是引起效果 - Eric Lippert
奇怪的评论。Select可以返回函数的结果,而该函数可能需要从外部服务选择进一步的数据,这将是异步的... - jenson-button-event
显示剩余3条评论
2个回答

22

因此,在您的方法结束时,customerTasks 包含一个尚未枚举的 IEnumerable<Task>Select 中的任何代码都不会运行。

创建这样的任务时,最好立即将序列物化以减轻双重枚举(并意外创建第二批任务)的风险。 您可以通过在序列上调用 ToList 来实现这一点。

因此:

var customerTasks = ids.Select(async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var id = await repo.getCustomer(i); //consider changing to GetCustomerAsync
    Console.WriteLine(id);

}).ToList();

现在...怎么处理你的任务列表呢?你需要等待它们全部完成...

你可以使用Task.WhenAll来完成:

await Task.WhenAll(customerTasks);
你可以通过在 Select 语句中从你的 async 委托返回一个值来更进一步,这样最终会得到一个 IEnumerable<Task<Customer>>
然后,您可以使用 Task.WhenAll 的不同重载
IEnumerable<Task<Customer>> customerTasks = ids.Select(async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var c = await repo.getCustomer(i); //consider changing to GetCustomerAsync
    return c;

}).ToList();

Customer[] customers = await Task.WhenAll(customerTasks); //look... all the customers

当然,可能有更有效率的方法一次性获得多个客户,但那是另一个问题。

如果相反,您想按顺序执行异步任务,则:

var customerTasks = ids.Select(async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var id = await repo.getCustomer(i); //consider changing to GetCustomerAsync
    Console.WriteLine(id);

});
foreach(var task in customerTasks) //items in sequence will be materialized one-by-one
{
    await task;
}

2
执行 ToList 将会并行执行所有操作,这可能不是预期的结果。另外,在您的第二个查询中,在这种情况下选择不需要是异步的,只需返回 getCustomer 的结果即可。 - Magnus
@Magnus,你能解释一下为什么“ToList”会并行运行选择操作吗?那么“AsParallel”会做什么呢?我觉得我在这里漏掉了什么。 - Ariel Volnicher
@spender,你能详细解释一下在 "Select" 中使用 await 有什么问题吗?为什么我不能等待每个任务完成后将其写入控制台?为什么你必须把它分割并等待所有任务都使用 "WhenAll" 完成,然后才能遍历它们并将它们的结果写入控制台? - Ariel Volnicher
@ArielVolnicher 进行 ToList 操作将枚举查询并将结果任务放入列表中。通过这样做,所有任务都将被启动(并并行执行)。 - Magnus
@spender,非常抱歉,我仍然不理解您的编辑。在您的编辑中,我们在选择查询内部使用了await。如果我理解正确,效果将是任务将同时启动,并且当它们每个完成时,Console.Wrtieline将被调用。那么为什么我还需要使用foreach再次遍历这些任务呢? - Ariel Volnicher
感谢您指出Task.WhenAll的过载问题。我本来要使用Task.WhenAll(customerTasks),然后是customerTasks.Select(x => x.Result).ToArray()。我来这里寻找更简洁的解决方案,因为通常使用Task.Result是不好的做法。 - Inrego

3

补充:

关于LINQ语句何时执行,特别是Where语句,似乎存在一些困惑。我创建了一个小程序来展示源数据实际被访问的时间。结果在本答案末尾。

补充结束

你必须意识到大多数LINQ函数的惰性。

惰性LINQ函数只会在你开始枚举时改变IEnumerable.GetEnumerator()将返回的Enumerator。因此,只要调用惰性LINQ函数,查询就不会被执行。

只有当你开始枚举时,查询才会被执行。枚举是在调用foreach或非惰性LINQ函数(如ToList()Any()FirstOrDefault()Max()等)时开始的。

在每个LINQ函数的注释部分都描述了该函数是否惰性。你还可以通过检查返回值来确定函数是否惰性。如果它返回一个IEnumerable<...>(或IQueryable),那么LINQ尚未被枚举。

这种惰性的好处是,只要使用惰性函数,改变LINQ表达式就不会耗费时间。只有当你使用非惰性函数时,你必须意识到它的影响。

例如,如果获取序列的第一个元素需要很长时间来计算,因为涉及排序、分组、数据库查询等,请确保不要多次枚举同一序列(不要为同一序列使用非惰性函数)

不要在家里这样做:

假设你有以下查询

var query = toDoLists
    .Where(todo => todo.Person == me)
    .GroupBy(todo => todo.Priority)
    .Select(todoGroup => new
    {
        Priority = todoGroup.Key,
        Hours = todoGroup.Select(todo => todo.ExpectedWorkTime).Sum(),
     }
     .OrderByDescending(work => work.Priority)
     .ThenBy(work => work.WorkCount);

这个查询只包含懒加载的LINQ函数。在所有这些语句执行之后,todoLists还没有被访问。

但是,一旦获取了结果序列的第一个元素,所有元素都必须被访问(可能多次)以按优先级对它们进行分组、计算涉及的总工作时间并按降序优先级排序。

Any()和First()也是如此:

if (query.Any())                           // do grouping, summing, ordering
{
    var highestOnTodoList = query.First(); // do all work again
    Process(highestOnTodoList);
}
else
{   // nothing to do
    GoFishing();
}

在这种情况下,最好使用正确的函数:
var highestOnToDoList = query.FirstOrDefault(); // do grouping / summing/ ordering
if (highestOnTioDoList != null)
   etc.

回到你的问题

Enumerable.Select语句仅为您创建了一个IEnumerable对象,您忘记枚举它了。

此外,您多次构造了CustomerRepo。这是有意为之吗?

ICustomerRepo repo = new CustomerRepo();
IEnumerable<Task<CustomerRepo>> query = ids.Select(id => repo.getCustomer(i));

foreach (var task in query)
{
     id = await task;
     Console.WriteLine(id);
}

补充:LINQ语句何时执行?

我创建了一个小程序来测试LINQ语句何时执行,特别是在执行Where时。

一个返回IEnumerable的函数:

IEnumerable<int> GetNumbers()
{
    for (int i=0; i<10; ++i)
    {
        yield return i;
    }
}

使用老式枚举器的程序。
public static void Main()
{
    IEnumerable<int> number = GetNumbers();
    IEnumerable<int> smallNumbers = numbers.Where(number => number < 3);

    IEnumerator<int> smallEnumerator = smallNumbers.GetEnumerator();

    bool smallNumberAvailable = smallEnumerator.MoveNext();
    while (smallNumberAvailable)
    {
        int smallNumber = smallEnumerator.Current;
        Console.WriteLine(smallNumber);
        smallNumberAvailable = smallEnumerator.MoveNext();
    }
}

在调试过程中,我注意到当第一次调用MoveNext()时,GetNumbers将会被执行,直到第一个yield return语句。 每次调用MoveNext()之后,yield return之后的语句将被执行,直到下一个yield return被执行。通过改变代码,使用foreach、Any()、FirstOrDefault()、ToDictionary等访问枚举器的方法,可以显示出这些函数调用是实际访问源的时间点。
if (smallNumbers.Any())
{
    int x = smallNumbers.First();
    Console.WriteLine(x);
}

调试显示,源代码 从头开始枚举两次。 因此,这样做确实不明智,特别是如果您需要执行许多计算以计算第一个元素(GroupBy、OrderBy、数据库访问等)。


你的意思是我构建了多次 Repo 吗?那当然不是我的本意,你是指因为它在选择中吗? - Ariel Volnicher
另外,在你提供的例子中,你调用了“Sum()”,这不是一个非惰性函数吗? - Ariel Volnicher
构建多个时间:是的,因为在 Enumerable.Select 的选择器参数中的代码将针对 Select 函数的每个输入项执行一次。 - Harald Coppoolse
@ArielVolnicher:函数 Where 运行并返回一个惰性枚举对象。我想指出的是说,说 Where 不运行是具有误导性的。它会运行;它必须运行!它返回代表查询的对象 - Eric Lippert
当然,所有函数在被调用时都会执行,也就是说:执行的意思是生成的枚举器会发生变化(在IQueryable中:表达式会发生变化)。我的意思是数据还没有被枚举,因此你放在Where中的代码 - Harald Coppoolse
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接