C#基础(二十):异步编程
这就是此语法的目标:支持读起来像一连串语句的代码,但会根据外部资源分配和任务完成时间以更复杂的顺序执行。这与人们为包含异步任务的流程给予指令的方式类似。在本文中,你将通过做早餐的指令示例来查看如何使用 async
和 await
关键字更轻松地推断包含一系列异步指令的代码。你可能会写出与以下列表类似的指令来解释如何做早餐:
倒一杯咖啡。
加热平底锅,然后煎两个鸡蛋。
煎三片培根。
烤两片面包。
在烤面包上加黄油和果酱。
倒一杯橙汁。
如果你有烹饪经验,便可通过异步方式执行这些指令。你会先开始加热平底锅以备煎蛋,接着再从培根着手。你可将面包放进烤面包机,然后再煎鸡蛋。在此过程的每一步,你都可以先开始一项任务,然后将注意力转移到准备进行的其他任务上。
做早餐是非并行异步工作的一个好示例。单人(或单线程)即可处理所有这些任务。继续讲解早餐的类比,一个人可以以异步方式做早餐,即在第一个任务完成之前开始进行下一个任务。不管是否有人在看着,做早餐的过程都在进行。在开始加热平底锅准备煎蛋的同时就可以开始煎了培根。在开始煎培根后,你可以将面包放进烤面包机。
对于并行算法而言,你则需要多名厨师(或线程)。一名厨师煎鸡蛋,一名厨师煎培根,依次类推。每名厨师将仅专注于一项任务。每名厨师(或线程)都在同步等待需要翻动培根或面包弹出时都将受到阻。
现在,通过业务模型转换为 C# 语句:
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("咖啡准备好了");
Egg eggs = FryEggs(2);
Console.WriteLine("鸡蛋准备好了");
Bacon bacon = FryBacon(3);
Console.WriteLine("培根准备好了");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("吐司准备好了");
Juice oj = PourOJ();
Console.WriteLine("橙汁准备好了");
Console.WriteLine("早餐准备好了!");
Console.Read();
}
private static Juice PourOJ()
{
Console.WriteLine("倒一杯橙汁");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("在吐司上放果酱");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("在吐司上涂黄油");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++){
Console.WriteLine("在烤面包机里放一片面包");
}
Console.WriteLine("开始烤...");
Task.Delay(3000).Wait();
Console.WriteLine("从烤面包机中取出吐司");
return new Toast();
}
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"在锅里放 {slices} 片培根");
Console.WriteLine("煎第一面培根...");
Task.Delay(3000).Wait();
for (int slice = 0; slice < slices; slice++){
Console.WriteLine("翻动一片培根");
}
Console.WriteLine("煎第二面培根...");
Task.Delay(3000).Wait();
Console.WriteLine("把培根放在盘子里");
return new Bacon();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("正在加热平底锅...");
Task.Delay(3000).Wait();
Console.WriteLine($"打 {howMany} 个鸡蛋");
Console.WriteLine("煎鸡蛋 ...");
Task.Delay(3000).Wait();
Console.WriteLine("把鸡蛋放在盘子里");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("倒一杯咖啡");
return new Coffee();
}
运行结果如下:
同步准备的早餐大约花费了15秒钟,因为总耗时是每个任务耗时的总和。
计算机不会按人类的方式来解释这些指令。计算机将阻塞每条语句,直到工作完成,然后再继续运行下一条语句。这将创造出令人不满意的早餐。后续任务直到早前任务完成后才会启动。这样做早餐花费的时间要长得多,有些食物在上桌之前就已经凉了。
如果你希望计算机异步执行上述指令,则必须编写异步代码。
不要阻塞,而要 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!");
}
//方法前面加上async,返回值加上Task。
//private static async Task<Egg> FryEggs(int howMany)
//方法体 Task.Delay(3000).Wait();改为await Task.Delay(3000);
总运行时间和最初同步版本大致相同。 此代码尚未利用异步编程的某些关键功能。
在煎鸡蛋或培根时,此代码不会阻塞。不过,此代码也不会启动任何其他任务。你还是会将面包放进烤面包机里,然后盯着烤面包机直到面包弹出。但至少,你会回应任何想引起你注意的人。在接受了多份订单的一家餐馆里,厨师可能会在做第一份早餐的同时开始制作另一份早餐。
现在,在等待任何尚未完成的已启动任务时,处理早餐的线程将不会被阻塞。对于某些应用程序而言,此更改是必需的。仅凭借此更改,GUI 应用程序仍然会响应用户。然而,对于此方案而言,你需要更多的内容。你不希望每个组件任务都按顺序执行。最好首先启动每个组件任务,然后再等待之前任务的完成。
同时启动任务
在许多方案中,你希望立即启动若干独立的任务。然后,在每个任务完成时,你可以继续进行已准备的其他工作。在早餐类比中,这就是更快完成做早餐的方法。你也几乎将在同一时间完成所有工作。你将吃到一顿热气腾腾的早餐。
System.Threading.Tasks.Task 和相关类型是可以用于推断正在进行中的任务的类。这使你能够编写更类似于实际做早餐方式的代码。你可以同时开始煎鸡蛋、培根和烤面包。由于每个任务都需要操作,所以你会将注意力转移到那个任务上,进行下一个操作,然后等待其他需要你注意的事情。
启动一项任务并等待表示运行的 Task 对象。你将首先 await
每项任务,然后再处理它的结果。
让我们对早餐代码进行这些更改。第一步是存储任务以便在这些任务启动时进行操作,而不是等待:
Coffee cup = PourCoffee();
Console.WriteLine("咖啡准备好了");
Egg eggs = await FryEggs(2);
Console.WriteLine("鸡蛋准备好了");
Bacon bacon = await FryBacon(3);
Console.WriteLine("培根准备好了");
Toast toast = await ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("吐司准备好了");
Juice oj = PourOJ();
Console.WriteLine("橙汁准备好了");
Console.WriteLine("早餐准备好了!");
接下来,可以在提供早餐之前将用于处理培根和鸡蛋的 await
语句移动到此方法的末尾:
Coffee cup = PourCoffee();
Console.WriteLine("咖啡准备好了");
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("吐司准备好了");
Juice oj = PourOJ();
Console.WriteLine("橙汁准备好了");
Egg eggs = await eggsTask;
Console.WriteLine("鸡蛋准备好了");
Bacon bacon = await baconTask;
Console.WriteLine("培根准备好了");
Console.WriteLine("早餐准备好了!");
运行结果如下:
异步准备的早餐大约花费了6秒,由于一些任务并发运行,因此节约了时间。
上述代码效果更好。你可以一次启动所有的异步任务。你仅在需要结果时才会等待每项任务。上述代码可能类似于 Web 应用程序中请求各种微服务,然后将结果合并到单个页面中的代码。你将立即发出所有请求,然后 await
所有这些任务并组成 Web 页面。
异步异常
至此,已隐式假定所有这些任务都已成功完成。异步方法会引发异常,就像对应的同步方法一样。对异常和错误处理的异步支持通常与异步支持追求相同的目标:你应该编写读起来像一系列同步语句的代码。当任务无法成功完成时,它们将引发异常。当启动的任务为 awaited
时,客户端代码可捕获这些异常。例如,假设烤面包机在烤面包时着火了。可通过修改 ToastBreadAsync
方法来模拟这种情况,以匹配以下代码:
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("在烤面包机里放一片面包");
}
Console.WriteLine("开始烤...");
await Task.Delay(3000);
Console.WriteLine("着火了!吐司全毁了!");
throw new InvalidOperationException("烤面包机着火了");
Console.WriteLine("从烤面包机中取出吐司");
return new Toast();
}
备注
在编译前面的代码时,你将收到一个关于无法访问的代码的警告。这是故意的,因为一旦烤面包机着火,操作就不会正常进行。
执行这些更改后,运行应用程序,输出如下: