为什么我更喜欢单个'await Task.WhenAll'多次等待?

avo*_*avo 110 .net c# parallel-processing task-parallel-library async-await

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

usr*_*usr 100

是的,使用WhenAll是因为它一次传播所有错误.如果其中一个等待抛出,那么等待多个等待你就会失去错误.

另一个重要的区别是WhenAll将等待所有任务完成.WhenAll将在第一个例外中止等待的链,但继续执行未等待的任务.这会导致意外的并发.

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

  • 另一个重要的区别是WhenAll将等待所有任务完成,即使例如t1或t2抛出异常或被取消. (14认同)
  • "因为它会立即传播所有错误"如果你"等待"它的结果,那就不行了. (7认同)
  • @OskarLindberg OP正在等待第一个任务之前启动所有任务.所以他们同时运行.谢谢你的链接. (5认同)
  • 很抱歉迟来的评论,但我只是碰巧“路过”,我认为使用 WhenAll 来支持等待每个任务的主要原因与性能有关。在单独等待每个任务时,有效地“序列化”任务的执行,WhenAll 有潜力在最长运行的单个任务的时间范围内完成所有任务,因为它们将并行执行(如果执行环境支持) 。我认为应该编辑已接受的答案以反映这一点。 (4认同)
  • @usr我很好奇还是要知道WhenAll是否没有像保存相同的SynchronizationContext那样做一些聪明的事情,以进一步推动其语义之外的好处.我没有找到确凿的文档,但是看看IL,IAsyncStateMachine的实现方式明显不同.我并没有很好地阅读IL,但是至少它们至少会产生更高效的IL代码.(无论如何,仅仅事实上,WhenAll的结果反映了我所涉及的所有任务的状态,这在大多数情况下都是偏好它的原因.) (3认同)
  • 至于如何使用Task管理异常的问题,本文对其背后的推理提供了一个快速但很好的洞察(并且恰好也传达了与多个等待相比的WhenAll的好处):http ://blogs.msdn.com/b/pfxteam/archive/2011/09/28/10217876.aspx (2认同)

Mar*_*scu 25

我的理解是,偏好Task.WhenAll多个awaits的主要原因是性能/任务"搅拌":该DoWork1方法执行如下操作:

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

相比之下,DoWork2这样做:

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

当然,对于你的特定情况来说,这是否足够大,是"依赖于上下文"(请原谅双关语).

  • 您似乎认为向同步上下文发送消息是昂贵的.它真的不是.您有一个被添加到队列的委托,将读取该队列并执行委托.这增加的开销实际上非常小.这不是什么,但它也不大.任何异步操作的费用几乎在所有情况下都会使这种开销相形见绌. (4认同)
  • @ChrisMoschini数据库查询,即使它正在与服务器位于同一台机器上的数据库,也不会比向消息泵添加一些代表的开销更快.内存中的查询几乎肯定会慢得多. (3认同)
  • 另请注意,如果 t1 较慢,而 t2 和 t3 较快 - 则另一个等待立即返回。 (2认同)

Luk*_*oid 16

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

public Task DoSomethingAsync()
{
    return DoSomethingElseAsync();
}
Run Code Online (Sandbox Code Playgroud)

使用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);
}
Run Code Online (Sandbox Code Playgroud)


Dav*_*eli 8

(免责声明:此答案取自 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)
    {
        // ...
    }

}
Run Code Online (Sandbox Code Playgroud)

在这种情况下,如果所有 3 个任务都抛出异常,则只会捕获第一个。任何以后的异常都将丢失。即如果 t2 和 t3 抛出异常,则只有 t2 会被捕获;等。随后的任务异常将不被观察到。

在 WhenAll 中 - 如果任何或所有任务出错,则生成的任务将包含所有异常。await 关键字仍然总是重新抛出第一个异常。所以其他异常仍然有效地未被观察到。解决这个问题的一种方法是在任务 WhenAll 之后添加一个空的延续并将等待放在那里。这样,如果任务失败,结果属性将抛出完整的聚合异常:

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)
    {
        // ...
    }
}
Run Code Online (Sandbox Code Playgroud)


rar*_*rrr 7

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