Task.WhenAll() 和 foreach(var task in tasks) 有什么区别?

3

经过几个小时的努力,我在我的应用程序中发现了一个错误。我认为下面的两个函数具有相同的行为,但实际上它们并不相同。

有人能告诉我底层到底发生了什么,以及它们为什么表现出不同的方式吗?

public async Task MyFunction1(IEnumerable<Task> tasks){
    await Task.WhenAll(tasks);

    Console.WriteLine("all done"); // happens AFTER all tasks are finished
}

public async Task MyFunction2(IEnumerable<Task> tasks){
    foreach(var task in tasks){
        await task;
    }

    Console.WriteLine("all done"); // happens BEFORE all tasks are finished
}

4
“happens BEFORE all tasks are finished” 看起来很奇怪 - 你能提供一个 [MCVE] 来展示这种情况吗?我看不出为什么 foreach 会按照你描述的方式运行,因为你使用了 await 等待所有任务完成(可能不如使用 WhenAll 优化,但仍然是全部等待)。“在所有任务完成之前发生”这个表述看起来很奇怪,请问可以提供一个最小可复现示例(MCVE)来说明这种情况吗?根据你所描述的,你使用了 await 等待所有任务完成,我无法理解为何 foreach 会有此行为(虽然可能比使用 WhenAll 更低效,但仍然是全部等待)。 - Alexei Levenkov
那段代码甚至无法编译。return在哪里? - Maria Ines Parnisari
“WhenAll”和手动逐个在循环中等待的一般区别在于,后者会不断地在异步方法之间切换,需要大量的上下文切换,而前者则可以在内部等待它们全部完成而没有这些额外开销。 - poke
@miparnisari 代码编译通过。不需要加 return - Servy
@Servy 啊,我的C#技能有点生疏了,哈哈。 - Maria Ines Parnisari
@AlexeiLevenkov 很不幸,我无法在简单的示例中重现它。这是一些复杂的代码(一个虚假的事件总线,在相当大的系统的集成测试中使用)。 - Andrzej Gis
2个回答

8

如果所有任务都成功完成,它们将完全相同。

如果您使用WhenAll并且任何项失败,直到所有项目完成,它仍然不会完成,它将表示一个AggregatException,其中包装了所有任务的所有错误。

如果您对每个任务使用await,则一旦遇到任何失败的项,它将立即完成,并表示该错误的异常,而不是其他错误。


这两者的区别在于,在添加任何连续项之前,WhenAll将在开始时全部实现整个IEnumerable。 如果IEnumerable代表已存在和已启动任务的集合,则这不相关,但是如果迭代可枚举性会创建和/或启动任务,则在开始时实现序列会并行运行它们,并在获取下一个任务之前等待每个任务将使它们顺序执行。 下面是一个IEnumerable,您可以传递它以按我在此处描述的方式运行:

public static IEnumerable<Task> TaskGeneratorSequence()
{
    for(int i = 0; i < 10; i++)
        yield return Task.Delay(TimeSpan.FromSeconds(2);
}

感谢您的回答。假设没有抛出任何异常,那么可能是什么原因导致了我所描述的情况? - Andrzej Gis
@gisek 取消操作,或者是一个输入序列,它在迭代过程中创建任务。 - Servy

1
可能最重要的功能区别是,当您的任务执行真正的异步操作(例如IO)时,Task.WhenAll可以引入并发。这可能是您想要的,也可能不是,具体取决于您的情况。
例如,如果您的任务正在使用相同的EF DbContext查询数据库,则下一个查询将在第一个查询“在飞行”时立即启动,这会导致EF崩溃,因为它不支持使用相同上下文进行多个同时查询。
这是因为您没有单独等待每个异步操作。您正在等待代表所有这些异步操作完成的任务。它们也可以以任何顺序完成。
但是,当您在foreach中单独等待每个异步操作时,只有在当前操作完成时才会触发下一个任务,从而防止并发并确保串行执行。
以下是演示此行为的简单示例:
async Task Main()
{
    var tasks = new []{1, 2, 3, 4, 5}.Select(i => OperationAsync(i));

    foreach(var t in tasks)
    {
        await t;
    }

    await Task.WhenAll(tasks);
}

static Random _rand = new Random();
public async Task OperationAsync(int number)
{
    // simulate an asynchronous operation
    // taking anywhere between 100 to 3000 milliseconds
    await Task.Delay(_rand.Next(100, 3000));
    Console.WriteLine(number);
}

你会发现,无论 OperationAsync 执行多长时间,使用 foreach 循环始终会打印出 1、2、3、4、5。但是使用 Task.WhenAll 时,它们会并发执行并按完成顺序打印。

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