为什么我应该更喜欢单个的 'await Task.WhenAll' 而不是多个 awaits?

189

如果我不关心任务完成的顺序,只需它们全部完成,那么我是否应该使用await Task.WhenAll而不是多个await呢?例如,下面的DoWork2方法是否比DoWork1更可取(为什么)?

using System;
using System.Threading.Tasks;

namespace ConsoleApp
{
    class Program
    {
        static async Task<string> DoTaskAsync(string name, int timeout)
        {
            var start = DateTime.Now;
            Console.WriteLine("Enter {0}, {1}", name, timeout);
            await Task.Delay(timeout);
            Console.WriteLine("Exit {0}, {1}", name, (DateTime.Now - start).TotalMilliseconds);
            return name;
        }

        static async Task DoWork1()
        {
            var t1 = DoTaskAsync("t1.1", 3000);
            var t2 = DoTaskAsync("t1.2", 2000);
            var t3 = DoTaskAsync("t1.3", 1000);

            await t1; await t2; await t3;

            Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
        }

        static async Task DoWork2()
        {
            var t1 = DoTaskAsync("t2.1", 3000);
            var t2 = DoTaskAsync("t2.2", 2000);
            var t3 = DoTaskAsync("t2.3", 1000);

            await Task.WhenAll(t1, t2, t3);

            Console.WriteLine("DoWork2 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
        }


        static void Main(string[] args)
        {
            Task.WhenAll(DoWork1(), DoWork2()).Wait();
        }
    }
}

2
如果您实际上不知道需要同时执行多少个任务怎么办?如果您有1000个需要运行的任务呢?第一个选择将不太易读:await t1; await t2; ....; await tn => 在这两种情况下,第二个选择始终是最佳选择。 - cuongle
你的评论很有道理。我只是在尝试澄清自己的问题,与我最近回答的另一个问题有关。在那种情况下,有3个任务。 - avo
6个回答

171

使用WhenAll,因为它可以一次性传播所有错误。如果使用多个awaits, 如果其中一个await抛出错误,则会失去错误。

另一个重要的区别是,在失败(故障或取消任务)的情况下,WhenAll将等待所有任务完成。在顺序手动等待中,由于想要等待的程序部分实际上会提前继续进行,这会导致意外的并发问题。

我认为,这也使得阅读代码更加容易,因为您想要的语义直接记录在代码中。


20
如果你使用await等待它的结果,它就不会同时传播所有错误。 - svick
3
抱歉晚了,我只是“路过”,但我认为使用WhenAll而不是等待每个任务的主要原因与性能有关。逐个等待每个任务有效地“串行化”任务的执行,而WhenAll有潜力在单个任务运行最长时间范围内完成所有任务,因为它们将并行执行(如果执行环境支持)。我认为应该编辑已接受的答案以反映这一点。 - Oskar Lindberg
8
@OskarLindberg 发帖者在等待第一个任务之前启动了所有任务,因此它们会同时运行。感谢提供的链接。 - usr
3
我还很好奇,当使用WhenAll方法时,它是否会像保持相同的SynchronizationContext这样做一些聪明的事情,以进一步推动其好处。我没有找到确凿的文档,但看IL代码显然有不同的IAsyncStateMachine实现在起作用。虽然我对IL的阅读能力不是很好,但至少WhenAll似乎生成了更高效的IL代码。(无论如何,仅仅WhenAll方法的结果反映了涉及的所有任务的状态,这对我来说已经足够在大多数情况下优先选择WhenAll方法。) - Oskar Lindberg
18
另一个重要的区别在于,WhenAll会等待所有任务完成,即使t1或t2抛出异常或被取消。 - Magnus
显示剩余6条评论

37

我的理解是,使用 Task.WhenAll 相比多个 await 的主要原因在于性能和任务“翻转”: DoWork1 方法执行以下操作:

  • 从给定的上下文开始
  • 保存上下文
  • 等待 t1
  • 恢复原始上下文
  • 保存上下文
  • 等待 t2
  • 恢复原始上下文
  • 保存上下文
  • 等待 t3
  • 恢复原始上下文

相比之下,DoWork2 执行以下操作:

  • 从给定上下文开始
  • 保存上下文
  • 等待 t1、t2 和 t3 的所有任务
  • 恢复原始上下文

当然,这是否对您的特定情况有足够大的影响,取决于“上下文”(请原谅双关语)。


4
你似乎认为向同步上下文发送消息很昂贵。实际上并不是。你有一个委托将被添加到队列中,队列会被读取并执行该委托。这样增加的开销非常小。虽然不是零,但也不算大。在几乎所有情况下,异步操作的开销将使此类开销相形见绌。 - Servy
同意,那只是我能想到的喜欢一个而不是另一个的唯一原因。还有,它与 Task.WaitAll 的相似性,其中线程切换是更重要的成本。 - Marcel Popescu
1
@Servy,正如Marcel所指出的那样,这真的取决于情况。例如,如果您将await用于所有db任务作为原则,并且该db位于与asp.net实例相同的机器上,则有时您将等待一个内存中的db命中,它比同步开关和线程池洗牌更便宜。在这种情况下,使用WhenAll()可能会带来显着的整体优势,因此...这确实取决于情况。 - Chris Moschini
3
即使数据库查询操作是在与服务器同一台机器上的数据库进行,它也不可能比向消息泵添加几个委托的开销更快。基于内存的查询操作几乎肯定会慢得多。请注意不要改变原文意思。 - Servy
2
还要注意,如果t1较慢而t2和t3较快,则其他的等待立即返回。 - Maverick Meerkat

22

异步方法被实现为状态机。可以编写不会编译成状态机的方法,通常称为快速跟踪异步方法。可以像这样实现:

public Task DoSomethingAsync()
{
    return DoSomethingElseAsync();
}

使用Task.WhenAll时,即使保持这个快速代码,调用者也可以等待所有任务完成,例如:

public Task DoSomethingAsync()
{
    var t1 = DoTaskAsync("t2.1", 3000);
    var t2 = DoTaskAsync("t2.2", 2000);
    var t3 = DoTaskAsync("t2.3", 1000);

    return Task.WhenAll(t1, t2, t3);
}

“快速跟踪异步”是什么意思?您的意思是DoSomethingAsync没有标记为async,也不会await DoSomethingElseAsync,而是直接返回其值吗? - user276648

10

免责声明:本答案取材/灵感来自于Ian Griffiths在Pluralsight上的TPL Async课程。

使用WhenAll的另一个原因是异常处理。

假设您在DoWork方法中有一个try-catch块,并且它们正在调用不同的DoTask方法:

static async Task DoWork1() // modified with try-catch
{
    try
    {
        var t1 = DoTask1Async("t1.1", 3000);
        var t2 = DoTask2Async("t1.2", 2000);
        var t3 = DoTask3Async("t1.3", 1000);

        await t1; await t2; await t3;

        Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
    }
    catch (Exception x)
    {
        // ...
    }

}

在这种情况下,如果所有3个任务都抛出异常,只有第一个异常会被捕获。后面的异常将会丢失。例如,如果t2和t3抛出异常,只有t2会被捕获;等等。进一步的任务异常将会无法观察。
而在WhenAll中,如果任何或所有任务发生错误,结果任务将包含所有的异常。await关键字仍然总是重新抛出第一个异常。因此,其他异常仍然有效地未被观察到。克服这个问题的一种方法是,在任务WhenAll之后添加一个空的继续,并将await放置在那里。这样如果任务失败,结果属性将抛出完整的聚合异常:
static async Task DoWork2() //modified to catch all exceptions
{
    try
    {
        var t1 = DoTask1Async("t1.1", 3000);
        var t2 = DoTask2Async("t1.2", 2000);
        var t3 = DoTask3Async("t1.3", 1000);

        var t = Task.WhenAll(t1, t2, t3);
        await t.ContinueWith(x => { });

        Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t.Result[0], t.Result[1], t.Result[2]));
    }
    catch (Exception x)
    {
        // ...
    }
}

9
这个问题的其他答案提供了技术方面为什么首选 await Task.WhenAll(t1, t2, t3); 的原因。本答案旨在从更柔和的角度考虑它(正如@usr所暗示的),同时仍然得出相同的结论。 await Task.WhenAll(t1, t2, t3); 是一种更加功能性的方法,因为它声明了意图并且是原子操作的。
使用 await t1; await t2; await t3;,没有任何阻止队友(或者甚至是你未来的自己!)在每个await语句之间添加代码。当然,你已经将它压缩成一行,可以实现这样的效果,但这并不能解决问题。此外,在团队设置中在给定的代码行上包含多个语句通常是不好的做法,因为它会使源文件更难以扫描。
简单地说,await Task.WhenAll(t1, t2, t3); 更易于维护,因为它更清晰地传达了你的意图,并且不太容易受到对代码的良好更新甚至只是合并出错而导致的奇怪错误的影响。

-2

就这么简单。

如果你需要执行多个 http 调用 IEnumerable 到外部 api 或者数据库,使用 WhenAll 并行执行请求,而不是等待一个调用完成再继续其他调用。


1
问题根本不涉及HTTP。 - D J
你好,欢迎来到本站。如果 OP 在调用 taskB 之前等待了 taskA,那么这可能是正确的,但他们首先启动了所有任务。 - markonius

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