vlambda博客
学习文章列表

C#基础(二十):异步编程


  如果我看得比别人更远些,那是因为我站在巨人的肩膀上  


于任务的异步编程模型 (TAP) 提供了异步代码的抽象化。 你只需像往常一样将代码编写为一连串语句即可。 就如每条语句在下一句开始之前完成一样,你可以流畅地阅读代码。 编译器将执行许多转换,因为其中一些语句可能会开始运行并返回表示正在进行的工作的 Task。

这就是此语法的目标:支持读起来像一连串语句的代码,但会根据外部资源分配和任务完成时间以更复杂的顺序执行。这与人们为包含异步任务的流程给予指令的方式类似。在本文中,你将通过做早餐的指令示例来查看如何使用 async 和 await 关键字更轻松地推断包含一系列异步指令的代码。你可能会写出与以下列表类似的指令来解释如何做早餐:

  1. 倒一杯咖啡。

  2. 加热平底锅,然后煎两个鸡蛋。

  3. 煎三片培根。

  4. 烤两片面包。

  5. 在烤面包上加黄油和果酱。

  6. 倒一杯橙汁。

如果你有烹饪经验,便可通过异步方式执行这些指令。你会先开始加热平底锅以备煎蛋,接着再从培根着手。你可将面包放进烤面包机,然后再煎鸡蛋。在此过程的每一步,你都可以先开始一项任务,然后将注意力转移到准备进行的其他任务上。

做早餐是非并行异步工作的一个好示例。单人(或单线程)即可处理所有这些任务。继续讲解早餐的类比,一个人可以以异步方式做早餐,即在第一个任务完成之前开始进行下一个任务。不管是否有人在看着,做早餐的过程都在进行。在开始加热平底锅准备煎蛋的同时就可以开始煎了培根。在开始煎培根后,你可以将面包放进烤面包机。

对于并行算法而言,你则需要多名厨师(或线程)。一名厨师煎鸡蛋,一名厨师煎培根,依次类推。每名厨师将仅专注于一项任务。每名厨师(或线程)都在同步等待需要翻动培根或面包弹出时都将受到阻。

现在,通过业务模型转换为 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();}

运行结果如下:

C#基础(二十):异步编程

同步准备的早餐大约花费了15秒钟,因为总耗时是每个任务耗时的总和。

计算机不会按人类的方式来解释这些指令。计算机将阻塞每条语句,直到工作完成,然后再继续运行下一条语句。这将创造出令人不满意的早餐。后续任务直到早前任务完成后才会启动。这样做早餐花费的时间要长得多,有些食物在上桌之前就已经凉了。

如果你希望计算机异步执行上述指令,则必须编写异步代码。

C#基础(二十):异步编程

不要阻塞,而要 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("早餐准备好了!");

运行结果如下:

C#基础(二十):异步编程

C#基础(二十):异步编程

异步准备的早餐大约花费了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();}

备注

在编译前面的代码时,你将收到一个关于无法访问的代码的警告。这是故意的,因为一旦烤面包机着火,操作就不会正常进行。

执行这些更改后,运行应用程序,输出如下:

C#基础(二十):异步编程





END