为啥我应该更喜欢单个'await Task.WhenAll'而不是多个等待?

Posted

技术标签:

【中文标题】为啥我应该更喜欢单个\'await Task.WhenAll\'而不是多个等待?【英文标题】:Why should I prefer single 'await Task.WhenAll' over multiple awaits?为什么我应该更喜欢单个'await Task.WhenAll'而不是多个等待? 【发布时间】:2013-08-21 02:01:27 【问题描述】:

如果我不关心任务完成的顺序,只需要它们全部完成,我还应该使用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();
        
    

【问题讨论】:

如果您实际上不知道需要并行执行多少任务怎么办?如果您有 1000 个任务需要运行怎么办?第一个不太可读await t1; await t2; ....; await tn => 在这两种情况下,第二个总是最好的选择 您的评论很有道理。我只是想为自己澄清一些事情,这与我最近answered 的另一个问题有关。在这种情况下,有 3 个任务。 【参考方案1】:

是的,使用WhenAll,因为它会同时传播所有错误。使用多个等待,如果前面的一个等待抛出,您将丢失错误。

另一个重要的区别是WhenAll 将等待所有任务完成即使出现故障(故障或取消的任务)。手动按顺序等待会导致意外的并发,因为您的程序中想要等待的部分实际上会提前继续。

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

【讨论】:

“因为它会同时传播所有错误”如果你await它的结果则不会。 关于如何使用 Task 管理异常的问题,这篇文章对它背后的推理给出了一个快速但很好的见解(恰好也顺便记下了 WhenAll 的好处与多重等待相比):blogs.msdn.com/b/pfxteam/archive/2011/09/28/10217876.aspx @OskarLindberg OP 正在等待第一个任务之前开始所有任务。所以它们同时运行。感谢您的链接。 @usr 我仍然很想知道 WhenAll 是否没有做一些聪明的事情,比如保存相同的 SynchronizationContext,以进一步推动其除了语义之外的好处。我没有找到确凿的文档,但是查看 IL 显然有不同的 IAsyncStateMachine 实现在起作用。我没有很好地阅读 IL,但WhenAll 至少似乎可以生成更高效的 IL 代码。 (无论如何,仅凭 WhenAll 的结果反映了我所涉及的所有任务的状态这一事实就足以在大多数情况下更喜欢它。) 另一个重要的区别是 WhenAll 将等待所有任务完成,即使例如 t1 或 t2 抛出异常或被取消。【参考方案2】:

我的理解是,更喜欢Task.WhenAll 而不是多个awaits 的主要原因是性能/任务“搅动”:DoWork1 方法的作用如下:

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

相比之下,DoWork2 这样做:

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

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

【讨论】:

您似乎认为向同步上下文发送消息很昂贵。真的不是。您有一个委托被添加到队列中,该队列将被读取并执行委托。这增加的开销确实非常小。这不是什么,但也不是很大。在几乎所有情况下,无论异步操作是什么,开销都会相形见绌。 同意,这只是我能想到的唯一原因。好吧,再加上与 Task.WaitAll 的相似性,线程切换的成本更高。 @Servy 正如 Marcel 指出的那样,这真的取决于。例如,如果您在所有数据库任务上使用 await 作为原则问题,并且该数据库与 asp.net 实例位于同一台机器上,则在某些情况下您将等待内存中索引的数据库命中,更便宜比那个同步开关和线程池洗牌。在这种情况下,使用 WhenAll() 可能会取得重大的整体胜利,所以......这真的取决于。 @ChrisMoschini 数据库查询,即使它访问与服务器位于同一台机器上的数据库,也不会比在消息中添加一些代理的开销更快泵。内存中的查询仍然几乎肯定会慢很多。 另请注意,如果 t1 较慢而 t2 和 t3 较快 - 那么另一个等待立即返回。【参考方案3】:

异步方法被实现为状态机。可以编写方法使它们不被编译到状态机中,这通常被称为快速通道异步方法。这些可以像这样实现:

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);

【讨论】:

【参考方案4】:

(免责声明:此答案取自/启发自 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 个任务都抛出异常,则只会捕获第一个。以后的任何异常都将丢失。 IE。如果 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)
    
        // ...
    

【讨论】:

【参考方案5】:

此问题的其他答案提供了首选 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); 更易于维护,因为它更清楚地传达了您的意图,并且更不容易受到可能来自善意代码更新甚至只是合并出错的特殊错误的影响。

【讨论】:

以上是关于为啥我应该更喜欢单个'await Task.WhenAll'而不是多个等待?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 NumPy 和 SciPy 有很多相同的功能?我应该更喜欢哪个? [复制]

为啥我应该更喜欢“显式类型的初始化程序”习语而不是显式给出类型

为啥我应该更喜欢 unsafe_unretained 限定符而不是为弱引用属性赋值? [复制]

为啥我应该使用 Amazon Kinesis 而不是 SNS-SQS?

为啥 JSLint 更喜欢点符号而不是方括号?

为啥我更喜欢使用成员初始化列表?