为什么我更喜欢单一的“等待任务。什么时候所有”超过多个等待?

如果我不关心任务完成的顺序,只是需要他们全部完成,我是否仍然应该使用 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();
}
}
}
127566 次浏览

是的,使用 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);
}

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

(免责声明: 此答案取自于 Ian Griffiths 在 多视野上的 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 中,如果有任何或所有任务出现错误,则生成的任务将包含所有异常。Wait 关键字仍然总是重新引发第一个异常。因此,其他的例外情况仍然没有得到有效的观察。克服这个问题的一种方法是在任务 WhenAll 之后添加一个空的延续,并将 wait 放在那里。这样,如果任务失败,result 属性将引发完整的聚合异常:

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

就这么简单。

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