概述
使用 async 和 await 进行异步编程
任务异步编程模型(Task Asynchronous Programming, TAP)为异步代码提供了一个抽象。你可以像往常一样将代码写成一系列语句。你可以阅读这些代码,就像每个语句在下一个语句开始之前完成一样。编译器会进行许多转换,因为其中一些语句可能会启动工作并返回一个表示正在进行的任务(Task)。
这种语法的目标是:使代码看起来像是一系列语句,但实际上是根据外部资源分配和任务完成情况以更复杂的顺序执行。这类似于人们给出包含异步任务的过程说明。在本文中,你将使用制作早餐的例子来展示 async 和 await 关键字如何使包含一系列异步指令的代码更容易理解。你可以像下面的列表一样编写制作早餐的说明:
- 倒一杯咖啡。
- 热锅,然后煎两个鸡蛋。
- 煎三片培根。
- 烤两片面包。
- 在烤好的面包上加黄油和果酱。
- 倒一杯橙汁。
如果你有烹饪经验,你会异步执行这些指令。你会先开始加热煎锅,然后开始煎培根。你会把面包放进烤面包机,然后开始煎鸡蛋。在每个步骤中,你会开始一个任务,然后把注意力转向那些已经准备好进行的任务。
制作早餐是异步工作的一个很好的例子,但不是并行的。一个人(或线程)可以处理所有这些任务。继续早餐的类比,一个人可以通过在第一个任务完成之前开始下一个任务来异步制作早餐。烹饪进度不需要一直盯着它。一旦你开始加热煎锅煎鸡蛋,你可以开始煎培根。一旦培根开始煎,你可以把面包放进烤面包机。
对于并行算法,你需要多个厨师(或线程)。一个人煎鸡蛋,一个人煎培根,等等。每个人只专注于一个任务。每个厨师(或线程)都会同步等待培根准备翻面,或者等待面包跳出烤面包机。
现在,考虑将这些相同的指令编写为C#语句:
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// 这些类故意是空的,仅用于演示目的。它们只是标记类,不包含任何属性,也没有其他作用。
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
// 倒一杯咖啡
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
// 煎两个鸡蛋
Egg eggs = FryEggs(2);
Console.WriteLine("eggs are ready");
// 煎三片培根
Bacon bacon = FryBacon(3);
Console.WriteLine("bacon is ready");
// 烤两片面包
Toast toast = ToastBread(2);
ApplyButter(toast); // 给面包涂黄油
ApplyJam(toast); // 给面包涂果酱
Console.WriteLine("toast is ready");
// 倒一杯橙汁
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!"); // 早餐准备好了
}
// 倒橙汁
private static Juice PourOJ()
{
Console.WriteLine("Pouring orange juice");
return new Juice();
}
// 给面包涂果酱
private static void ApplyJam(Toast toast) =>
Console.WriteLine("Putting jam on the toast");
// 给面包涂黄油
private static void ApplyButter(Toast toast) =>
Console.WriteLine("Putting butter on the toast");
// 烤面包
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting...");
Task.Delay(3000).Wait(); // 模拟烤面包的时间
Console.WriteLine("Remove toast from toaster");
return new Toast();
}
// 煎培根
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"putting {slices} slices of bacon in the pan");
Console.WriteLine("cooking first side of bacon...");
Task.Delay(3000).Wait(); // 模拟煎培根的时间
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("flipping a slice of bacon");
}
Console.WriteLine("cooking the second side of bacon...");
Task.Delay(3000).Wait(); // 模拟煎培根的时间
Console.WriteLine("Put bacon on plate");
return new Bacon();
}
// 煎鸡蛋
private static Egg FryEggs(int howMany)
{
Console.WriteLine("Warming the egg pan...");
Task.Delay(3000).Wait(); // 模拟加热煎锅的时间
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
Task.Delay(3000).Wait(); // 模拟煎鸡蛋的时间
Console.WriteLine("Put eggs on plate");
return new Egg();
}
// 倒咖啡
private static Coffee PourCoffee()
{
Console.WriteLine("Pouring coffee");
return new Coffee();
}
}
}
同步准备早餐大约花了30分钟,因为总时间是每个任务时间的总和。
计算机不会像人类那样理解这些指令。计算机会在每个语句上阻塞,直到工作完成后才会继续执行下一个语句。这会导致早餐不够理想。后面的任务在前面的任务完成之前不会开始。这将花费更长的时间来制作早餐,并且一些食物在上桌之前就已经冷了。
如果你想让计算机异步执行上述指令,你必须编写异步代码。
这些问题对于你今天编写的程序来说非常重要。当你编写客户端程序时,你希望用户界面能够响应用户输入。你的应用程序在从网络下载数据时不应该让手机看起来像是冻结了。当你编写服务器程序时,你不希望线程被阻塞。那些线程可以处理其他请求。当存在异步替代方案时使用同步代码会损害你以更低成本扩展的能力。你要为那些被阻塞的线程付出代价。
成功的现代应用程序需要异步代码。没有语言支持,编写异步代码需要回调、完成事件或其他方式,这些方式会模糊代码的原意。同步代码的优点在于其逐步的操作使其易于扫描和理解。传统的异步模型迫使你关注代码的异步特性,而不是代码的基本操作。
不要阻塞,而要使用 await
前面的代码展示了一种不好的实践:构造同步代码来执行异步操作。按这种方式编写的代码会阻塞执行它的线程,无法进行其他工作。在任务进行时,它不会被打断。这就好比你在把面包放进烤面包机后盯着烤面包机看,忽略任何与你说话的人,直到面包弹出。
让我们从更新这段代码开始,使线程在任务运行时不会阻塞。await 关键字提供了一种非阻塞的方式来启动任务,然后在任务完成时继续执行。制作早餐代码的简单异步版本如下所示:
static async Task Main(string[] args)
{
// 倒一杯咖啡
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready"); // 咖啡准备好了
// 异步煎两个鸡蛋
Egg eggs = await FryEggsAsync(2);
Console.WriteLine("eggs are ready"); // 鸡蛋准备好了
// 异步煎三片培根
Bacon bacon = await FryBaconAsync(3);
Console.WriteLine("bacon is ready"); // 培根准备好了
// 异步烤两片面包
Toast toast = await ToastBreadAsync(2);
ApplyButter(toast); // 给面包涂黄油
ApplyJam(toast); // 给面包涂果酱
Console.WriteLine("toast is ready"); // 面包准备好了
// 倒一杯橙汁
Juice oj = PourOJ();
Console.WriteLine("oj is ready"); // 橙汁准备好了
Console.WriteLine("Breakfast is ready!"); // 早餐准备好了
}
详细解释:
通过在适当的位置使用await
关键字,这些任务能够异步进行,而不会阻塞主线程。这使得程序能够更高效地运行,同时保持响应性。
-
异步主方法:
static async Task Main(string[] args)
主方法被定义为异步的,并返回一个
Task
,以便能够使用await
关键字。 -
倒咖啡:
Coffee cup = PourCoffee(); Console.WriteLine("coffee is ready");
调用
PourCoffee
方法倒一杯咖啡,并打印“coffee is ready”。 -
异步煎鸡蛋:
Egg eggs = await FryEggsAsync(2); Console.WriteLine("eggs are ready");
调用异步方法
FryEggsAsync
煎两个鸡蛋,并使用await
等待完成,然后打印“eggs are ready”。 -
异步煎培根:
Bacon bacon = await FryBaconAsync(3); Console.WriteLine("bacon is ready");
调用异步方法
FryBaconAsync
煎三片培根,并使用await
等待完成,然后打印“bacon is ready”。 -
异步烤面包:
Toast toast = await ToastBreadAsync(2); ApplyButter(toast); ApplyJam(toast); Console.WriteLine("toast is ready");
调用异步方法
ToastBreadAsync
烤两片面包,并使用await
等待完成。完成后给面包涂黄油和果酱,然后打印“toast is ready”。 -
倒橙汁:
Juice oj = PourOJ(); Console.WriteLine("oj is ready");
调用
PourOJ
方法倒一杯橙汁,并打印“oj is ready”。 -
打印早餐准备完成:
Console.WriteLine("Breakfast is ready!");
打印“Breakfast is ready!”表示所有任务完成,早餐准备好了。
重要
总耗时大致与最初的同步版本相同。代码尚未利用异步编程的一些关键特性。
提示
FryEggsAsync、FryBaconAsync 和 ToastBreadAsync 方法的主体已经更新为分别返回 Task<Egg>、Task<Bacon> 和 Task<Toast>。这些方法的名称从原版重命名为包含“Async”后缀。它们的实现作为本文后面最终版本的一部分展示。
备注
Main 方法返回 Task,尽管没有返回表达式——这是设计使然。有关更多信息,请参见 void 返回的 async 函数的评估。
这段代码在煎鸡蛋或煎培根时不会阻塞。然而,它不会启动任何其他任务。你仍然会把面包放进烤面包机并盯着它直到面包弹出。但至少,你会回应任何想要你注意的人。在一家有多个订单的餐厅里,厨师可以在第一个早餐烹饪时开始另一个早餐。
现在,当等待任何尚未完成的启动任务时,处理早餐的线程不会被阻塞。对于某些应用程序,这个变化已经足够。GUI 应用程序在只做这个更改的情况下仍然会响应用户。然而,对于这个场景,你需要更多。你不希望每个组件任务按顺序执行。最好是在等待前一个任务完成之前就启动每个组件任务。
并发启动任务
在许多情况下,你希望立即启动几个独立的任务。然后,在每个任务完成时,你可以继续处理已经准备好的其他工作。在制作早餐的类比中,这样你可以更快地完成早餐。你还可以让所有事情在接近同一时间完成。你会得到一个热腾腾的早餐。
System.Threading.Tasks.Task 及相关类型是一些类,你可以使用它们来处理进行中的任务。这使你可以编写更接近于制作早餐方式的代码。你会同时开始煮鸡蛋、煎培根和烤面包。当每个任务需要操作时,你会转移注意力到那个任务,处理下一个操作,然后等待其他需要你注意的事情。
你启动一个任务,并保留表示工作的 Task 对象。在处理任务结果之前,你会等待每个任务。
让我们对早餐代码进行这些更改。第一步是在操作启动时存储任务,而不是等待它们:
// 倒一杯咖啡
Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready"); // 咖啡准备好了
// 异步开始煎鸡蛋,并保存任务对象
Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask; // 等待鸡蛋煎好
Console.WriteLine("Eggs are ready"); // 鸡蛋准备好了
// 异步开始煎培根,并保存任务对象
Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask; // 等待培根煎好
Console.WriteLine("Bacon is ready"); // 培根准备好了
// 异步开始烤面包,并保存任务对象
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask; // 等待面包烤好
ApplyButter(toast); // 给面包涂黄油
ApplyJam(toast); // 给面包涂果酱
Console.WriteLine("Toast is ready"); // 面包准备好了
// 倒一杯橙汁
Juice oj = PourOJ();
Console.WriteLine("Oj is ready"); // 橙汁准备好了
Console.WriteLine("Breakfast is ready!"); // 早餐准备好了
前面的代码不会让你的早餐准备得更快。所有任务在启动后都会立即等待。接下来,你可以将培根和鸡蛋的等待语句移动到方法的末尾,在上早餐之前:
// 倒一杯咖啡
Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready"); // 咖啡准备好了
// 异步开始煎鸡蛋,并保存任务对象
Task<Egg> eggsTask = FryEggsAsync(2);
// 异步开始煎培根,并保存任务对象
Task<Bacon> baconTask = FryBaconAsync(3);
// 异步开始烤面包,并保存任务对象
Task<Toast> toastTask = ToastBreadAsync(2);
// 等待面包烤好并进行后续操作
Toast toast = await toastTask;
ApplyButter(toast); // 给面包涂黄油
ApplyJam(toast); // 给面包涂果酱
Console.WriteLine("Toast is ready"); // 面包准备好了
// 倒一杯橙汁
Juice oj = PourOJ();
Console.WriteLine("Oj is ready"); // 橙汁准备好了
// 等待鸡蛋煎好
Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready"); // 鸡蛋准备好了
// 等待培根煎好
Bacon bacon = await baconTask;
Console.WriteLine("Bacon is ready"); // 培根准备好了
// 早餐准备好了
Console.WriteLine("Breakfast is ready!");
异步准备的早餐大约花了20分钟,这次节省的时间是因为某些任务是同时运行的。
前面的代码效果更好。你同时启动所有异步任务,只有在需要结果时才等待每个任务。前面的代码可能类似于一个Web应用程序中的代码,该应用程序向不同的微服务发送请求,然后将结果组合成一个页面。你会立即发出所有请求,然后等待所有任务完成并组合网页。
任务的组合
除了面包之外,其他所有的早餐项目都准备好了。制作面包是一个异步操作(烤面包)和同步操作(加黄油和果酱)的组合。更新此代码说明了一个重要概念:
重要
异步操作后跟随同步工作的组合是一个异步操作。换句话说,如果操作的任何部分是异步的,则整个操作都是异步的。
前面的代码显示你可以使用Task或Task<TResult>对象来保存正在运行的任务。在使用任务结果之前等待每个任务。下一步是创建代表其他工作的组合的方法。在上早餐之前,你要等待代表烤面包的任务,然后再添加黄油和果酱。你可以使用以下代码表示该工作:
// 异步制作涂黄油和果酱的面包
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
// 异步烤面包并等待完成
var toast = await ToastBreadAsync(number);
// 给烤好的面包涂黄油
ApplyButter(toast);
// 给烤好的面包涂果酱
ApplyJam(toast);
// 返回涂好黄油和果酱的面包
return toast;
}
前面的方法在其签名中包含了 async
修饰符。这向编译器表明该方法包含一个 await
语句;它包含异步操作。这个方法表示烤面包,然后加黄油和果酱的任务。该方法返回一个 Task<TResult>
,它代表这三个操作的组合。现在,主代码块变成:
static async Task Main(string[] args)
{
// 倒一杯咖啡
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready"); // 咖啡准备好了
// 异步开始煎鸡蛋,并保存任务对象
var eggsTask = FryEggsAsync(2);
// 异步开始煎培根,并保存任务对象
var baconTask = FryBaconAsync(3);
// 异步开始制作涂黄油和果酱的面包,并保存任务对象
var toastTask = MakeToastWithButterAndJamAsync(2);
// 等待鸡蛋煎好
var eggs = await eggsTask;
Console.WriteLine("eggs are ready"); // 鸡蛋准备好了
// 等待培根煎好
var bacon = await baconTask;
Console.WriteLine("bacon is ready"); // 培根准备好了
// 等待面包烤好并涂上黄油和果酱
var toast = await toastTask;
Console.WriteLine("toast is ready"); // 面包准备好了
// 倒一杯橙汁
Juice oj = PourOJ();
Console.WriteLine("oj is ready"); // 橙汁准备好了
// 早餐准备好了
Console.WriteLine("Breakfast is ready!");
}
前面的更改展示了一种处理异步代码的重要技巧。通过将操作分离到一个新的返回任务的方法中来组合任务。你可以选择何时等待该任务。你可以并发启动其他任务。
异步异常处理
到目前为止,你已经隐含地假设所有这些任务都能成功完成。异步方法会抛出异常,就像它们的同步对应方法一样。异步对异常和错误处理的支持与一般的异步支持目标相同:你应该编写看起来像一系列同步语句的代码。当任务无法成功完成时会抛出异常。当等待已启动的任务时,客户端代码可以捕获这些异常。例如,假设烤面包时烤面包机着火了。你可以通过修改 ToastBreadAsync
方法来模拟这种情况,使其符合以下代码:
// 异步烤面包方法
private static async Task<Toast> ToastBreadAsync(int slices)
{
// 将每片面包放进烤面包机
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting..."); // 开始烤面包
// 模拟烤面包的时间
await Task.Delay(2000);
// 模拟烤面包时发生的异常
Console.WriteLine("Fire! Toast is ruined!"); // 火灾!面包烧焦了!
throw new InvalidOperationException("The toaster is on fire"); // 抛出异常
// 此行代码永远不会被执行,因为在前一行已经抛出异常
await Task.Delay(1000);
Console.WriteLine("Remove toast from toaster"); // 从烤面包机中取出面包
return new Toast(); // 返回面包对象
}