任务异步编程模型(TAP)提供了对异步代码的抽象。你可以像往常一样,将代码写成一系列语句。在阅读代码时,就好像每条语句都在下一条语句开始之前完成一样。编译器会执行许多转换,因为其中一些语句可能会开始工作,并返回一个代表正在进行的工作的任务。
这就是这种语法的目标:使代码读起来像一连串语句,但执行顺序要复杂得多,这取决于外部资源分配和任务完成的时间。这类似于人们为包含异步任务的进程下达指令的方式。在本文中,我们将以制作早餐的指令为例,说明 async 和 await 关键字如何使包含一系列异步指令的代码更易于推理。如果要解释如何做早餐,你可以写出类似下面列表的指令:
- 倒一杯咖啡。
- 热锅,煎两个鸡蛋。
- 煎三片培根。
- 烤两片面包。
- 在烤面包中加入黄油和果酱。
- 倒一杯橙汁。
如果你有烹饪经验,就会异步执行这些指令。你会开始热锅煎鸡蛋,然后开始煎培根。把面包放进烤面包机,然后开始煎蛋。在这个过程中的每一步,你都会开始执行一项任务,然后将注意力转移到已经准备好的任务上。
做早餐就是一个很好的非并行异步工作的例子。一个人(或线程)可以处理所有这些任务。继续早餐的类比,一个人可以在第一个任务完成之前开始下一个任务,从而异步完成早餐。无论是否有人在看,烹饪都会继续进行。一旦开始热锅煮蛋,就可以开始煎培根。一旦开始煎培根,就可以把面包放进烤面包机。
对于并行算法,你需要多个厨师(或线程)。一个负责煎鸡蛋,一个负责煎培根,以此类推。每个厨师(或线程)都只专注于一项任务。每个厨师(或线程)都将被同步阻塞,等待培根准备好翻转或吐司弹出。
现在,将这些指令写成 C# 语句:
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// These classes are intentionally empty for the purpose of this example. They are simply marker classes for the purpose of demonstration, contain no properties, and serve no other purpose.
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
DateTime time_start = DateTime.Now;
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!");
DateTime time_stop = DateTime.Now;
Console.WriteLine($"cost time={(time_stop - time_start).TotalSeconds}s");
}
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...//==3000ms");
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...//==3000ms");
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...//==3000ms");
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...//==3000ms");
Task.Delay(3000).Wait();
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...//==3000ms");
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 分钟,因为总计是每项任务的总和。(程序实际运行15s)
计算机对这些指令的解读方式与人类不同。计算机会阻塞在每条语句上,直到工作完成后才进入下一条语句。这就造成了一顿令人不满意的早餐。在前面的任务完成之前,后面的任务不会启动。制作早餐需要的时间会更长,而且有些食物在端上桌之前就已经凉了。
如果想让计算机异步执行上述指令,就必须编写异步代码。
这些问题对于您现在编写的程序非常重要。在编写客户端程序时,您希望用户界面能对用户输入做出响应。在从网络下载数据时,您的应用程序不应该让手机出现卡顿。在编写服务器程序时,您不希望线程被阻塞。这些线程可能正在处理其他请求。在存在异步替代方案的情况下使用同步代码,会降低扩展成本。你要为这些阻塞的线程付出代价。
成功的现代应用程序需要异步代码。在没有语言支持的情况下,编写异步代码需要回调、完成事件或其他掩盖代码原意的方法。同步代码的优势在于它的分步操作易于扫描和理解。传统的异步模型迫使你关注代码的异步性质,而不是代码的基本操作。
1、不要阻塞,而是等待
前面的代码展示了一种糟糕的做法:构造同步代码来执行异步操作。按照写法,这段代码会阻塞执行它的线程,使其无法执行任何其他工作。当任何任务正在进行时,它都不会被中断。这就好比你把面包放进面包机后盯着面包机。在吐司爆开之前,你不会理睬任何和你说话的人。
让我们先更新这段代码,使线程在任务运行时不会阻塞。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!");
}
重要
总耗时与最初的同步版本大致相同。代码尚未利用异步编程的一些关键特性。
提示
FryEggsAsync、FryBaconAsync 和 ToastBreadAsync 的方法体已全部更新为分别返回 Task<Egg>、Task<Bacon> 和 Task<Toast>。这些方法的名称与原来的版本相比,增加了 “Async ”后缀。它们的实现将作为最终版本的一部分在本文稍后部分展示。
备注
尽管 Main 方法没有返回表达式,但它会返回 Task--这是设计所致。有关详细信息,请参阅评估 void 返回的异步函数。
在烹饪鸡蛋或培根时,这段代码不会阻塞。但这段代码不会启动任何其他任务。你还是会把吐司放进烤面包机,然后盯着它直到它爆开。但至少,你会对任何想引起你注意的人做出回应。在一家有多个订单的餐厅里,厨师可以在烹饪第一份早餐时开始另一份早餐。
现在,做早餐的线程在等待尚未完成的任务时不会被阻塞。对于某些应用程序来说,只需做出这样的改变即可。图形用户界面应用程序只需做出这样的改变,就能对用户做出响应。但是,在这种情况下,您需要的更多。你不希望每个组件任务都按顺序执行。最好是在等待前一个任务完成之前启动每一个组件任务。
2、同时启动任务
在很多情况下,您都希望立即启动多个独立任务。然后,当每项任务完成后,您就可以继续其他准备就绪的工作。在早餐的比喻中,这就是如何更快地完成早餐的方法。你还能在接近同一时间完成所有工作。你会吃到热腾腾的早餐。
System.Threading.Tasks.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!");
前面的代码不会让你的早餐准备得更快。所有任务在启动后都会被 “等待”。接下来,你可以将培根和鸡蛋的 “await ”语句移到方法的末尾,然后再供应早餐:
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 分钟,之所以能节省时间,是因为有些任务是并发运行的。(程序实际运行6s)
前面的代码效果更好。你可以一次性启动所有异步任务。只有在需要结果时,才等待每个任务。前面的代码可能类似于网络应用程序中的代码,它向不同的微服务发出请求,然后将结果合并到一个页面中。您将立即发出所有请求,然后等待所有这些任务并组成网页。
3、用任务组成
除了烤面包,早餐的一切都同时准备好了。制作烤面包是异步操作(烤面包)和同步操作(添加黄油和果酱)的组合。更新这段代码说明了一个重要的概念:
重要
异步操作与同步操作的组合就是异步操作。换一种说法,如果操作的任何部分是异步的,那么整个操作就是异步的。
前面的代码向我们展示了可以使用 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!");
}
前面的更改说明了处理异步代码的一个重要技巧。你可以通过将操作分离到一个返回任务的新方法中来组成任务。您可以选择何时等待该任务。你可以同时启动其他任务。
4、异步异常
到此为止,你已经隐含地假定所有这些任务都会成功完成。异步方法会抛出异常,就像同步方法一样。异步支持异常和错误处理的目标与一般异步支持的目标相同: 您编写的代码应该读起来像一系列同步语句。任务在无法成功完成时会抛出异常。客户端代码可以在等待已启动任务时捕获这些异常。例如,假设烤面包机在烤面包时着火了。您可以通过修改 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();
}
备注
当你编译前面的代码时,你会收到一个关于无法访问代码的警告。这是故意的,因为一旦烤面包机着火,操作将无法正常进行。
做完这些改动后运行应用程序,您将看到与下面文本类似的输出结果:
Pouring coffee
Coffee is ready
Warming the egg pan...
putting 3 slices of bacon in the pan
Cooking first side of bacon...
Putting a slice of bread in the toaster
Putting a slice of bread in the toaster
Start toasting...
Fire! Toast is ruined!
Flipping a slice of bacon
Flipping a slice of bacon
Flipping a slice of bacon
Cooking the second side of bacon...
Cracking 2 eggs
Cooking the eggs ...
Put bacon on plate
Put eggs on plate
Eggs are ready
Bacon is ready
Unhandled exception. System.InvalidOperationException: The toaster is on fire
at AsyncBreakfast.Program.ToastBreadAsync(Int32 slices) in Program.cs:line 65
at AsyncBreakfast.Program.MakeToastWithButterAndJamAsync(Int32 number) in Program.cs:line 36
at AsyncBreakfast.Program.Main(String[] args) in Program.cs:line 24
at AsyncBreakfast.Program.<Main>(String[] args)
你会注意到,在烤面包机着火和观察到异常之间,有很多任务已经完成。当异步运行的任务抛出异常时,该任务就会发生故障。任务对象会在 Task.Exception 属性中保存抛出的异常。故障任务在等待时会抛出异常。
有两个重要机制需要了解:异常如何存储在故障任务中,以及代码在等待故障任务时如何解包并重新抛出异常。
当异步运行的代码抛出异常时,异常会存储在任务中。Task.Exception 属性是 System.AggregateException,因为在异步工作期间可能会抛出不止一个异常。抛出的任何异常都会添加到 AggregateException.InnerExceptions 集合中。如果 Exception 属性为空,就会创建一个新的 AggregateException,而抛出的异常就是集合中的第一个项目。
故障任务最常见的情况是 Exception 属性正好包含一个异常。当代码等待故障任务时,AggregateException.InnerExceptions 集合中的第一个异常会被重新抛出。这就是为什么本例的输出显示的是 InvalidOperationException 而不是 AggregateException。提取第一个内部异常使异步方法的工作尽可能与同步方法相似。当您的应用场景可能产生多个异常时,您可以检查代码中的 Exception 属性。
提示
我们建议任何参数验证异常都应同步出现在任务返回方法中。如需了解更多信息和示例,请参阅任务返回方法中的异常。
在继续之前,请注释掉 ToastBreadAsync 方法中的这两行。你可不想再引起一场火灾:
Console.WriteLine("Fire! Toast is ruined!");
throw new InvalidOperationException("The toaster is on fire");
5、高效地等待任务
使用任务类的方法可以改进前面代码末尾的一系列 await 语句。其中一个 API 是 WhenAll,它可以在参数列表中的所有任务都完成后返回一个任务,如下代码所示:
await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("Eggs are ready");
Console.WriteLine("Bacon is ready");
Console.WriteLine("Toast is ready");
Console.WriteLine("Breakfast is ready!");
另一种方法是使用 WhenAny,它会返回一个任务<Task>,当其任何参数完成时,该任务<Task>也会完成。您可以等待返回的任务,因为您知道它已经完成。下面的代码展示了如何使用 WhenAny 等待第一个完成的任务,然后处理其结果。处理完已完成任务的结果后,您将从传递给 WhenAny 的任务列表中删除已完成的任务。
var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("Eggs are ready");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("Bacon is ready");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("Toast is ready");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
在接近尾声时,你会看到 await finishedTask;这一行。这行 await Task.WhenAny 并不等待已完成的任务。它等待的是 Task.WhenAny 返回的任务。Task.WhenAny 的结果就是已完成(或发生故障)的任务。你应该再次等待该任务,即使你知道它已经运行完毕。这样才能获取其结果,或确保导致其故障的异常被抛出。
经过所有这些修改后,代码的最终版本如下所示:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// These classes are intentionally empty for the purpose of this example. They are simply marker classes for the purpose of demonstration, contain no properties, and serve no other purpose.
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
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 breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("eggs are ready");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("bacon is ready");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("toast is ready");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
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 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(3000);
Console.WriteLine("Remove toast from toaster");
return new Toast();
}
private static async Task<Bacon> FryBaconAsync(int slices)
{
Console.WriteLine($"putting {slices} slices of bacon in the pan");
Console.WriteLine("cooking first side of bacon...");
await Task.Delay(3000);
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("flipping a slice of bacon");
}
Console.WriteLine("cooking the second side of bacon...");
await Task.Delay(3000);
Console.WriteLine("Put bacon on plate");
return new Bacon();
}
private static async Task<Egg> FryEggsAsync(int howMany)
{
Console.WriteLine("Warming the egg pan...");
await Task.Delay(3000);
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
await Task.Delay(3000);
Console.WriteLine("Put eggs on plate");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("Pouring coffee");
return new Coffee();
}
}
}
异步准备早餐的最终版本大约花了 6 分钟,因为有些任务同时运行,代码同时监控多个任务,只在需要时才采取行动。
这个最终代码是异步的。它更准确地反映了一个人如何烹制早餐。将前面的代码与本文的第一个代码示例进行比较。通过阅读代码,核心操作依然清晰可见。您可以像阅读本文开头的早餐制作说明一样阅读这段代码。async 和 await 的语言特性提供了每个人遵循这些书面说明所做的翻译:尽可能地启动任务,不要阻塞等待任务完成。
6、下一步工作
原文链接
Asynchronous programming - C# | Microsoft Learn
本文翻译自Microsoft官方网页,仅限个人学习交流,禁止商业使用