为什么我更喜欢单身';等待任务.WhenAll';多次等待

本文关键字:等待 WhenAll 任务 为什么 更喜欢 单身 | 更新日期: 2023-09-27 18:28:06

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

为什么我更喜欢单身';等待任务.WhenAll';多次等待

是的,使用WhenAll,因为它一次传播所有错误。在多次等待的情况下,如果前一次等待投球中有一次,您将失去失误。

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

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

我的理解是,比起多个await,更喜欢Task.WhenAll的主要原因是性能/任务"搅乱":DoWork1方法的作用如下:

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

相比之下,DoWork2做到了这一点:

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

当然,对于你的特殊情况来说,这是否足够重要,取决于"上下文"(请原谅双关语)。

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

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

(免责声明:此答案来源于Ian Griffiths关于Pluralsight的TPL异步课程)

喜欢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之后添加一个空的延续,并将等待放在那里。这样,如果任务失败,result属性将抛出完整的Aggregate Exception:

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)
    {
        // ...
    }
}

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

就这么简单。

如果对外部api或数据库有多个http调用IEnumerable,请使用WhenAll并行执行请求,而不是等待一个调用完成,然后继续其他调用。