使用 async 和 await 进行异步编程

Task 异步编程模型(TAP)提供了一层抽象,以覆盖典型的异步编码。 在此模型中,将代码编写为语句序列,与往常相同。 关键区别是,在编译器处理每个语句的过程中以及开始处理下一个语句之前,您都可以读取基于任务的代码。 若要完成此模型,编译器会执行许多转换来完成每个任务。 某些语句可以启动工作并返回表示 Task 正在进行的工作的对象,编译器必须解析这些转换。 任务异步编程的目标是使代码看起来像一系列语句那样易读,但能够以更复杂的顺序执行。 执行基于外部资源分配以及任务完成时间。

任务异步编程模型类似于人们如何为包含异步任务的进程提供说明。 本文通过一个制作早餐说明的示例,展示了如何使用 asyncawait 关键字,使包含一系列异步指令的代码更容易理解。 提供早餐的说明可以作为列表提供:

  1. 倒一杯咖啡。
  2. 加热锅,然后炒两个鸡蛋。
  3. 煎三片熏肉。
  4. 烤两块面包。
  5. 将黄油和果酱涂抹在烤面包片上。
  6. 倒一杯橙汁。

如果你有烹饪经验,则可以 异步完成这些说明。 你开始加热煎锅来煎鸡蛋,然后开始煎熏肉。 你把面包放在烤箱里,然后开始煮鸡蛋。 在流程的每个步骤中,你启动一个任务,然后转向需要你注意的其他任务。

烹饪早餐是异步而非并行工作的一个很好的示例。 一个人(或线程)可以处理所有任务。 一个人可以通过在上一个任务完成之前启动下一个任务来异步做早餐。 不论是否有人在观看,每个烹饪任务都会继续进行。 一旦你开始为煎蛋加热锅,就可以开始煎培根了。 培根开始煮熟后,可以将面包放在烤箱中。

对于并行算法,需要多个厨师(或多个线程)。 一个人煮鸡蛋,另一个炸熏肉,等等。 每个人专注于他们的一个特定任务。 正在烹饪(或每个线程)的每个人都被同步阻止,等待当前任务完成:培根可以翻面了,面包在烤面包机里马上就要弹出来了,诸如此类。

此图显示了准备早餐的说明,其中列出了 30 分钟内完成的 7 个顺序任务。

请考虑相同的同步指令列表,以 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)
        {
            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分钟。 持续时间是单个任务时间的总和。 计算机会暂停处理每个语句,直到所有工作完成,然后继续执行下一个任务。 此方法可能需要很长时间。 在早餐示例中,计算机技术创建了一个令人不满意的早餐。 同步列表中的后续任务,比如烤面包,必须等到早期任务完成后才能开始。 一些食物在早餐准备好供应之前变冷。

如果希望计算机异步执行指令,则必须编写异步代码。 编写客户端程序时,希望 UI 能够响应用户输入。 从 Web 下载数据时,应用程序不应冻结所有交互。 编写服务器程序时,不希望阻止可能正在处理其他请求的线程。 异步替代项存在时使用同步代码会损害更经济地扩展系统能力。 你要为被阻塞的线程付费。

成功的新式应用需要异步代码。 如果没有语言支持,编写异步代码需要回调、完成事件或其他掩盖代码的原始意图。 同步代码的优点是分步作,便于扫描和理解。 传统的异步模型强制你专注于代码的异步性质,而不是关注代码的基本作。

不要阻塞,改为等待

前面的代码重点介绍了一种不幸的编程做法:编写同步代码以执行异步作。 代码阻止当前线程执行任何其他工作。 代码不会在运行任务时中断线程。 此模型的结果类似于放入面包后盯着烤面包机。 在面包弹出之前,你忽略任何中断,不会启动其他任务。 不要把黄油和果酱拿出冰箱。 你可能会看不到炉子上开始的火苗。 你想同时烤面包和处理其他问题。 你的代码情况也是如此。

可以先更新代码,这样线程就不会在任务运行时阻止。 关键字 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!");
}

代码更新了原始方法正文FryEggsFryBaconToastBread,使它们分别返回Task<Egg>Task<Bacon>Task<Toast>对象。 更新的方法名称包括“Async”后缀: FryEggsAsyncFryBaconAsyncToastBreadAsync。 即使该方法没有 return 表达式,但根据设计,它会返回 Task 对象。 有关详细信息,请参阅 对返回 void 的异步函数的评估

注释

更新的代码尚未利用异步编程的关键功能,这可能会导致完成时间缩短。 代码在与初始同步版本大致相同的时间内处理任务。 有关完整的方法实现,请参阅本文后面的 代码的最终版本

让我们将早餐示例应用于更新的代码。 线程在鸡蛋或培根煮熟时不会阻止,但在当前工作完成之前,代码也不会启动其他任务。 你仍然把面包放在烤箱里,盯着烤箱,直到面包弹出,但现在你可以回应中断。 在一家接到多个订单的餐厅里,厨师可以在一个订单正在烹饪的同时开始一个新订单。

在更新的代码中,处理早餐的线程在等待任何未完成的已启动任务时不会被阻塞。 对于某些应用程序,只需进行此更改。 可以在从 Web 下载数据时使应用支持用户交互。 在其他方案中,你可能希望在等待上一个任务完成时启动其他任务。

同时启动任务

对于大多数操作,你希望立即启动多个独立任务。 完成每个任务后,您会开始其他准备好的工作。 将此方法应用于早餐示例时,可以更快地准备早餐。 你也将一切同时准备好,这样你可以享受热腾腾的早餐。

System.Threading.Tasks.Task 和相关类型是可用于将这种推理样式应用于正在进行的任务的类。 此方法使你能够编写更类似于你在现实生活中创建早餐的方式的代码。 你开始同时煮鸡蛋、熏肉和烤面包。 每项食物都需要你去处理,届时你将注意力转向任务,以执行该操作,然后等待需要你注意的其他事物。

在你的代码中,首先启动一个任务,然后保留代表工作的 Task 对象。 使用 await 方法在任务上延迟处理工作,直到结果准备就绪。

将这些更改应用于早餐代码。 第一步是在操作开始时存储任务,而不是使用 await 表达式。

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 在启动后立即应用于所有任务。 下一步是将 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分钟。 烹饪总时间会减少,因为某些任务同时运行。

该图展示了准备早餐的说明,涉及 8 项可在大约 20 分钟内完成的异步任务,但不幸的是,鸡蛋和培根烤焦了。

代码更新通过减少烹饪时间来提高准备过程,但它们通过燃烧鸡蛋和培根来引入回归。 一次性启动所有异步任务。 仅当需要结果时,才等待每个任务。 该代码可能与 Web 应用程序中的程序类似,它向不同的微服务发出请求,然后将结果合并到单个页面中。 立即发出所有请求,然后将 await 表达式应用于所有这些任务,并构建网页。

支持任务组合

以前的代码修订有助于同时准备好早餐的所有部分,但吐司除外。 制作吐司的过程是异步操作(烤面包)与同步操作(在吐司上抹黄油和果酱)的组合。 此示例说明了有关异步编程的重要概念:

重要

异步操作与同步工作的组合仍然是一个异步操作。 换句话说,如果操作的任何部分是异步的,则整个操作都是异步的。

在以前的更新中,你了解了如何使用 TaskTask<TResult> 对象来保存正在运行的任务。 在使用任务结果之前,请等待每个任务。 下一步是创建表示其他工作组合的方法。 在上早餐之前,你需要等待面包烘烤的任务完成,然后再将黄油和果酱抹上去。

可以使用以下代码表示此工作:

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

该方法 MakeToastWithButterAndJamAsync 的签名中包含 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!");
}

此代码更改演示了处理异步代码的重要技术。 通过将操作分离到一个返回任务的新方法中来组织任务。 可以选择何时等待该任务。 可以同时启动其他任务。

处理异步异常

至此,代码隐式假定所有任务都成功完成。 异步方法会引发异常,就像其同步方法一样。 异步支持异常和错误处理的目标与一般异步支持相同。 最佳做法是编写类似于一系列同步语句的代码。 任务在无法成功完成时引发异常。 当表达式应用于启动的任务时, await 客户端代码可以捕获这些异常。

在早餐示例中,假设烤箱在烤面包时着火。 可以通过修改 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 对象保存属性中引发的 Task.Exception 异常。 任务在应用表达式 await 时出错,会引发异常。

有两个重要的机制可以了解此过程:

  • 如何在发生故障的任务中存储异常
  • 代码在出错的任务上等待 (await) 时如何解压缩并重新引发异常

当运行代码异步引发异常时,异常将存储在对象中 Task 。 该 Task.Exception 属性是一个 System.AggregateException 对象,因为异步工作期间可能会引发多个异常。 引发的任何异常将添加到 AggregateException.InnerExceptions 集合中。 如果该属性为 Exception null,则会创建一个新 AggregateException 对象,并且引发的异常是集合中的第一项。

出错任务最常见的情形是,该 Exception 属性仅包含一个异常。 当代码等待出错任务时,它会重新引发集合中的第一个 AggregateException.InnerExceptions 异常。 此结果是示例输出显示对象 System.InvalidOperationException 而不是 AggregateException 对象的原因。 提取第一个内部异常让异步方法的使用尽量与同步方法相似。 在方案可能生成多个异常时,您可以在代码中检查 Exception 属性。

小窍门

建议的做法是使任何参数验证异常在任务返回方法中 同步 出现。 有关详细信息和示例,请参阅 任务返回方法中的异常

在继续下一部分之前,请在您的ToastBreadAsync方法中注释掉以下两个语句。 你不想启动另一个火灾:

Console.WriteLine("Fire! Toast is ruined!");
throw new InvalidOperationException("The toaster is on fire");

有效地将 await 表达式应用于任务

可以通过使用Task类的方法改进上一个代码末尾的await系列表达式。 一个 API 是使用 WhenAll 方法,它会在其 Task 参数列表中的所有任务完成后返回一个完成的对象。 以下代码演示了此方法:

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 方法返回 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();
        }
    }
}

代码在大约 15 分钟内完成异步早餐任务。 由于某些任务同时运行,因此总时间会减少。 该代码同时监视多个任务,并仅根据需要执行作。

此图示了准备早餐的指示,分为六个异步任务,可以在约15分钟内完成,而代码则监控可能的中断。

最终代码是异步的。 它更准确地反映了一个人做早餐的方式。 将最终代码与本文中的第一个代码示例进行比较。 通过阅读代码,核心动作仍然清晰。 可以像阅读早餐说明列表一样阅读最终代码,如文章开头所示。 语言功能和asyncawait关键字的特点是帮助每个人按照书面说明进行翻译:尽可能开始任务,不要因为等待其他任务完成而阻塞。

后续步骤