Menu

C# async await:Task、Task<T>、WhenAll 与常见陷阱

async 和 await 让 C# 代码在等待慢操作时不阻塞线程。本文讲解异步方法如何执行、Task 与 Task<T>、用 Task.WhenAll 同时运行互不依赖的操作、异步代码中的异常,以及 async void 和 .Result 为什么会导致 bug 和死锁。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

程序里大多数慢操作其实都是在等待:等 Web 服务器响应,等数据库返回行,等文件读完。async 和 await 让方法在这类等待期间暂停,而不占着一个线程不放,等待结束后再从中断的地方继续。代码读起来仍然像普通代码一样从上到下执行。

第一个异步方法

async 方法返回 Task(没有结果)或 Task<T>(结果类型为 T)。在方法内部,await 等待另一个任务并把它的结果交给你。

输出:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync 写的是 return 2.50m,编译器会把它包装进方法返回的 Task<decimal> 中,await 再把它解包出来。Task.Delay 是 Thread.Sleep 的异步版本:时间到了它就完成,等待期间不阻塞任何线程。

从 C# 7.1 开始,Main 本身也可以是异步的,在现代 .NET 中你会这样写这个程序:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

本页的可运行示例在普通的 Main 中通过 .GetAwaiter().GetResult() 调用异步方法 RunAsync,这正是编译器为异步 Main 生成的代码。在控制台应用中这样做是安全的;死锁一节会解释为什么同样的调用在 UI 代码中很危险。

执行到 await 时发生了什么

异步方法会同步执行,直到它第一次对一个未完成的任务执行 await。此时它向调用方返回一个 Task,方法的剩余部分变成一个后续操作(continuation),在被等待的任务完成后运行。

输出:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

“Download: starting” 在 DownloadAsync 返回之前就打印出来了,因为第一个 await 之前的所有代码都在调用方的线程上运行。接着方法交回一个未完成的任务,调用方继续往下执行,直到延时结束才出现 “Download: finished”。调用异步方法就会启动它;对它执行 await 才是等待结果的方式。

async 不会创建线程。方法暂停期间根本没有线程在等它,这就是为什么服务器只用少量线程就能让成千上万个请求同时等待数据库。对于需要在另一个线程上运行的 CPU 密集型工作,请使用 Task.Run,详见 Task。

用 Task.WhenAll 同时运行多个操作

一个接一个地 await 是顺序执行:每个操作都要等上一个完成后才开始。如果这些操作互不依赖,就先把它们全部启动,再用 Task.WhenAll 一起等待:

示例输出:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

顺序版本的耗时是三次等待之和,并发版本的耗时大约等于最慢的那一次。不管哪个任务先完成,Task.WhenAll 返回的结果顺序都和你传入任务的顺序一致。它也接受列表,这是“获取每一项”的常见写法:await Task.WhenAll(ids.Select(id => FetchAsync(id)))。

与之对应的 Task.WhenAny 会在第一个任务完成时就完成,适合实现超时和“谁先返回用谁”。

异步代码中的异常

异步方法内部抛出的异常会保存在返回的任务中,并在该任务被 await 时重新抛出,所以在 await 外面套一个普通的 try/catch 就能捕获:

输出:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

调用 LoadProfileAsync(-1) 时并没有抛出异常:异常一直保存在任务里,直到有代码 await 它。一个从未被 await 的任务会把它的异常完全藏起来,这也是启动的任务都应该 await 的又一个理由。使用 WhenAll 时,await 会重新抛出第一个异常,而组合任务的 Exception 属性(一个 AggregateException)包含所有异常。这里读取 ok.Result 没有问题,因为 ok 已经完成了。

async void:只用于事件处理程序

异步方法也可以返回 void。除了事件处理程序,其他地方都应避免这样写:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

使用 async void 时没有可以 await 的任务,所以调用方会在工作完成之前就继续执行;方法内部抛出的异常会直接在同步上下文(或线程池)上抛出,调用方的任何 try/catch 都够不着。在控制台或服务器应用中,这通常会让进程终止。在 async void 事件处理程序内部,请自己捕获所有异常。

一个相关的错误是调用异步方法时不加 await。编译器会发出警告(CS4014),方法会在后台运行,没有任何代码关注它的结果或错误。

.Result 和 .Wait() 导致的死锁

用 .Result 或 .Wait() 阻塞等待任务,是异步代码最常出错的地方。在有同步上下文的应用中(WinForms、WPF、MAUI、经典 ASP.NET),过程是这样的:

  1. UI 线程调用 GetDataAsync().Result 并阻塞,等待任务完成。
  2. 在 GetDataAsync 内部,某个 await 完成了。默认情况下,它的后续操作必须在开始时所在的上下文上运行:也就是 UI 线程。
  3. UI 线程在第 1 步被阻塞,所以后续操作永远无法运行,任务永远无法完成,第 1 步也就永远不会结束。

应用会卡死,而且没有任何异常。控制台应用和 ASP.NET Core 没有这种上下文,所以同样的代码在测试程序里能运行,在桌面应用里却会卡住。解决办法:

  • 在整条调用链上都使用 await,不要阻塞(“一路 async 到底”)。为此,事件处理程序可以是 async void。
  • 在不需要回到调用方上下文的库代码中,写 await SomethingAsync().ConfigureAwait(false);。这样后续操作会在线程池上运行,而不是在捕获的上下文上,库也省掉了一次不必要的线程切换。只有当调用链上的每个 await 都这样写时,它才能保护阻塞的调用方,所以把它当作良好的库代码习惯,而不是解决 .Result 问题的办法。

ASP.NET Core 中的应用代码不需要 ConfigureAwait(false),因为没有需要返回的上下文。

常见错误

  • 逐个 await 互不依赖的调用。 先启动它们,再 await Task.WhenAll(...)。
  • 编写 async void 方法。 请返回 Task;async void 只留给事件处理程序。
  • 在 UI 或经典 ASP.NET 代码中使用 .Result 和 .Wait()。 它们会死锁;请改用 await。
  • 忘记写 await。 工作在无人关注的情况下运行,它的异常也会消失。
  • 用 Task.Run 包装 I/O 操作。 File.ReadAllTextAsync 或 HttpClient.GetStringAsync 这样的异步 API 本来就会释放线程;Task.Run 只会多一次线程池跳转。
  • 以为 async 就是“在另一个线程上运行”。 它的意思是“可以暂停而不阻塞”。CPU 密集型工作请用 Task.Run。

常见问题

C# 中的 async 和 await 是怎么工作的?

给方法加上 async 后,就可以在里面使用 await。当方法对一个尚未完成的任务执行 await 时,它会立即返回给调用方,并交回一个代表剩余工作的 Task。被等待的操作完成后,方法从 await 之后继续执行。等待期间没有任何线程被阻塞。

Task 和 Task<T> 有什么区别?

Task 表示不产生值的操作,相当于 void 方法的异步版本。Task<T> 表示会产生一个 T 的操作:对 Task<int> 执行 await 得到的就是那个 int。声明为 async Task<int> 的异步方法只需写 return 42;,编译器会把它包装进任务中。

如何同时运行多个异步操作?

先把它们全部启动,再一起等待:var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);。如果写成 await GetUserAsync(); await GetOrdersAsync();,它们会一个接一个地执行,总耗时是各自耗时之和,而不是最慢的那一个。

为什么 C# 中不推荐 async void?

async void 方法无法被等待,所以调用方不知道它何时结束;方法内部抛出的异常调用方也捕获不到:它会在同步上下文上抛出,通常导致进程崩溃。请改为返回 Task。async void 只为事件处理程序而存在,因为它们的签名要求返回 void。

为什么 .Result 或 .Wait() 会导致死锁?

在有同步上下文的应用中(WinForms、WPF、经典 ASP.NET),.Result 会阻塞 UI 线程或请求线程,而被等待方法的后续代码正等着在同一个线程上运行。双方永远互相等待。请在整条调用链上都使用 await,不要阻塞;在库代码中使用 ConfigureAwait(false)。

C# 的 Main 方法可以是 async 的吗?

可以,从 C# 7.1 开始:static async Task Main() 或 static async Task<int> Main()。顶级语句(C# 9)可以直接使用 await。在较老的代码中,等价做法是在普通 Main 里调用 MainAsync().GetAwaiter().GetResult(),这在控制台应用中是安全的,因为控制台应用没有同步上下文。

Coddy 编程语言插图

和 Coddy 一起学编程

开始