Skip to main content

使用async和await进行异步编程

分类:  .Net技术 标签:  #异步编程 #.Net #基础 发布于: 2023-06-04 18:48:24

.Net的TAP(任务异步编程模式: Task Asynchronous programming Model)为编写异步代码提供了一个更高级的抽象层,使用这个编程模式,您的完成异步任务的代码和普通的同步顺序执行的代码看起来也没有什么区别,只需要遵守约定,大部分的工作由编译器根据外部资源的分配(例如Task, 线程,内存等等)来自动完成,编译器完成大部分的工作,大部分情况下会返回一个基于Task或者Task<T>类型的返回值。在这个模型中,用户只需要使用关键字AsyncAwait两个语言级别的关键字来定义异步方法、调用异步方法,从而达到异步编程的目的。

在我们这篇入门文章里,先以一个日常生活的例子简要的介绍大家如何将一个任务分拆为多个异步任务,同时也介绍如何使用asyncawait这两个关键字。

做一顿早餐

我们这里以日常生活中最为常见的场景 - 做一顿早餐 来向大家展示:

  • 我们如何将一个任务转化为异步任务
  • 如果在异步任务中使用关键字asyncawait

我们首先来看一下做一顿丰盛的早餐需要哪些步骤:

  • 做一杯咖啡
  • 用户平底锅煎两个鸡蛋
  • 炸三片培根
  • 烤两片面包
  • 在烤面包上放黄油和果酱
  • 做一杯橙汁

如果你自己做过早餐或者学习过小学的课文《统筹方法》你就已经有了异步编程的经验,而且做早餐这个实例也非常适合异步任务但是不需要并行的场景,例如当你开始等平底锅热的时候,可以放面包到烤面包机里。在炸培根的同时也可以给自己做一杯橙汁等等。在这里例子里,你 - 人 - 线程 , 也就是干活的。然后这里还有一个非常需要理解就是上述的这些需要完成的项目都是我们的Task, 对应到需要运行的代码。因此这里第一个需要理解的就是任务(代码)和线程之间的联系。

加入我们这里采用并发模式,那会怎么样? 也就是说上述这些步骤都要同时开始,结果只有一个,就是要多个厨师,然后大家一起来完成这个工作,但是任务模式可不一定需要很多个线程,就像你一个人也能把所有的早餐做完一样。

注意
这里有一个抽象的概念需要映射一下,对于当前这个做早餐的任务,做早餐的每一个子任务都是需要完成的工作,对应的是你需要运行的代码,这里的厨师对应的是用来运行早餐子任务的控制单元,在这里映射为系统的线程,所以TAP编程模式里,每一个需要完成的都是任务,任务的执行是交给背后的线程的,当然线程怎么管理是由我们CLR来根据程序的设计模式来决定,这个理解我们可以先放在这里,后期要理解在同步方法中使用异步方法为什么会造成线程饥渴的帮助很大。

C#同步代码的实现

根据我们上面的需求,我们先使用自然而然的同步代码来描述我们做一顿早餐所需要的整个步骤:

using System;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    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分钟左右。

注意
这里我们没有给出类CoffeEggBaconToastJuice的定义,这几个类只是为了用作演示,是空类,为了加深理解,您可以自己在自己的代码里添加这几个类的定义,全部定义为空类就好了。

这里我们需要理解一下计算的工作方式,在以上的同步代码里,计算机会完全遵守代码给出的顺序,一步一步的执行完所有的代码,在一个方法没有完成之前,是不会进行下一个方法的调用的,这种现象我们称为执行线程被block了。理解一下上面我们给出的那个提示:我们的方法里需要运行的代码就是我们的任务,执行这些代码的就是CLR分配的线程,同步模式里被block的是CLR给出的执行线程,理解这一点对于理解await不block这句话非常重要,因为在代码里的表现如果使用了await实际上await之前的代码和await之后的代码看起来也像是顺序下执行了,那么block和没有block到底指的是什么,从这里可以看到没有被block的实际上执行的线程。这一点非常重要。

在现代的程序设计中,如果要优化上述同步的代码,对于Java程序原来说第一想法就是写一个多线程的代码,每个线程执行一个任务,然后返回Task对象,最后等待Task对象运行完成,整个过程也就结束了,这个方案当然是对的,但是程序员就不得不自己处理多线程的管理以及可能发生的同步管理等等,对于.Net程序员,没有使用异步编程模型的情况下也可以和Java程序员一样的选择,或者使用基于事件,或者回调等方法来完成这个任务,但是C#5以后推出的TAP异步模型以及关键字asyncawait大大简化了这个编程场景,并且在语言的层面就对这个应用场景进行了支持,更多的把重心放在本身需要完成的任务上。

下面我们使用异步编程模型来一步一步的改进我们上述的例子

使用await,不block执行线程

上面我们解释了执行线程和你需要运行代码之间的关系,这一节我们详细的学习一下如何理解和使用await, 下面我先改造我们的main方法,将main方法里的调用全部改成异步方法。

static void 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!");
}

上述这个代码里,FryEggsAsyncFryBaconAsyncToastBreadAsync, 这三个方法我们已经改为了异步方法。

提示
在异步编程中,作为惯例我们在方法后面加上后缀Async以表示这个方法是一个异步方法,同时我们在方法定义上也加上关键字async, 这个后面继续讨论。

但是上面这个方法给了我们一个非常坏的例子,即在同步方法里调用异步方法,这里方法Main是一个同步方法,但是其方法体里调用了很多异步方法,这很容易发生线程饥渴的问题,因为我们需要将Main方法也定义为一个异步方法

提示
不要在同步方法里调用异步方法,任何时候都应该记住,至于原因后期我会写一篇文章详细的分析原因。

改造后的代码如下:

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!");
}

思考一个问题:我们将Main方法改成了异步方法,同时在调用的时候也将几个方法改成了异步方法,如果按照这个流程走下来,我们整个流程花费的事件有减少吗?

重要提示
虽然我们已经将Main方法改成了异步,另外也将其方法体调用的几个方法也改成了异步调用,但是并没有让整个流程花费的时间减少,实际上这个代码的运行时间和之前的同步方法是一摸一样的,我们只是在这里解决了执行线程不被block, 但是没有把异步编程模式的高级特性应用起来。

如何理解执行线程不被block?

当CLR开始执行Main方法时,CLR分配一个线程1给Main方法,因为Main方法是一个异步方法,CLR心里有数,当代码运行到第一个await, CLR会在启动第一个await之后,会让线程1立即返回,同时可能会启动线程2其执行第一个await, 但是线程1并不block, 会被CLR拿去做其他的事情,由于这里遇到了await, 虽然线程1没事了,但是整个Main方法还是不得不等待第一个await返回,才会去执行后面的代码,等待第一个await返回后,CLR会使用一个新的线程(或者就是执行await的线程)继续执行await后面的代码, 但是这里线程1并没有一直在等待,它可能被CLR释放了,可能去做其他的事情,这就是对await的理解,一定要认真理解await和block之间的真正关系。

异步方法体改变
因为我们上面更改了几个方法成为异步方法,必须记得将这几个方法加上关键字async和返回值变为Task

现在执行Main方法的线程不会被block了,虽然我们还没有使用更多的特性让这个整个过程花费的时间更少,至少Main的执行线程不被block,这个在很多应用中就已经可以了,例如在GUI应用中,我们知道GUI应用的UI线程永远都只有一个,如果是在UI线程里启动其他的方法,那么UI线程就会立即返回,不会被block, 这样虽然后台的任务没有完成,但是UI线程还是可以立即对用户的操作做出反馈。

开始并行执行Task

对于做早餐这个任务,我们上面定义了三个异步方法:FryEggsAsyncFryBaconAsyncToastBreadAsync, 这三个异步方法完成煎蛋,炸培根,烤面包。
很多时候,我们希望立即开始一个或者多个任务,然后不需要等这些任务完成我们可以做其他的事情,例如我们列出这几个任务,煎鸡蛋,我只要把鸡蛋放到锅里,然后让它加热剪就好了,炸培根和烤面包也是,放进去就可以了,然后人就可以空出来做其他的事了。

.Net里我们用一个抽象的类来表示所有我们在执行的代码,System.Threads.Tasks.Task以及它定义的各种对象来表示我们需要执行的任务,我们注意到任何一个异步方法都是以返回Task或者Task<T>类型表示我们执行的任务的,我们前面的例子都是调用异步方法之后,会立即await, 虽然我们没有block主要的执行线程,但是实际上由于await, 整个方法的代码执行时间并没有减少。

我们可以使用一个Task的变量保存调用异步方法返回的Task对象,在需要的时候进行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!");

这里我们使用了eggsTaskbaconTasktoastTask来保存异步返回的task, 然后我们将这些task 更改到需要等待的地方,代码更改如下:

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!");

我们在代码里先调用了三个异步方法,然后再去await, 这里我们再回顾一下await, 当调用方法await toastTask的时候,Main线程会立即返回是吗?这里还有一个很重要的概念,那就是异步方法是什么时候开始执行的?是在调用异步方法的时候?还是在await的时候?很多文档都是在讲await是执行一个分离点,是让执行线程不block, 但是都没有谈到一个重要的概念,就是异步方法到底是什么时候开始执行的?我的理解是异步方法是在调用异步方法的时候就已经执行了,不管你是在调用异步方法的时候使用了await还是在调用之后再await, 因为这才是符合逻辑的,await确实会不block主要的调用线程,但是并不是要等到await才开始执行异步方法!

按照这个理解,我们再配一个图,就更好的理解上述代码更改带来的改变:


从图中可以看到煎鸡蛋(Fry Eggs), 炸培根(Fry bacon), 烤面包(Toast bread)这三个任务是在并行执行的,对照代码看一下,会理解得更清楚。

经过上面的更改,我们节省了更多的时间,大约只需要20分钟了,那还能不能进一步使用.Net的异步编程模型优化呢?

进一步分析烤面包

我们上面的代码已经很好了,但是我们会发现这里还有一个很耗时的任务,那就是烤面包,这是因为虽然烤面包是一个异步方法,但是考完面包之后,还要在面包上放上黄油(Butter)和放上果酱(Jam)这两个方法是同步方法,也就是面包这个任务实际上包括一个异步方法,两个同步方法。

这里我们先放一个重要的异步概念:任何一个流程或者动作或者方法,只要它其中的一部分是异步的,那么实际上这个整个动作或者流程或者方法都是异步的

怎么理解和应用?我们还是从我们的烤面包开始:

做成一个能吃的烤面包我们前面的代码需要三个方法,一个异步的,两个同步的,应用上面的理论,做面包这事实际上整个流程就应该是异步的,因此我们重新定义一个方法来表示烤面包这事:

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

    return toast;
}

将面包这个步骤全部组合到一个方法中去后,我们可以更改Main方法如下:

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!");
}

对比之前的Main方法,发现我们组合了烤面包这个任务之后,在Main的线程里会更快的进行下一个动作。请仔细对比一下这个Main和组合烤面包动作之前的Main

异步方法的异常

异步方法在执行的过程中会产生异常,但是异常一直等到await的时候才会抛出,在执行的过程中,会保存在Task.Excepton这个属性中,这个属性类型是System.AggregateException, 同时该类型有一个AggregateException.InnerExceptions的集合,因为异步方法中有可能会产生多个异常,在await时会默认抛出第一个异常。因此如果要在调用方法中抓取异步方法的异常,只需要在await时使用try-catch就可以了,另外在异步方法中,和使用同步方法一样定义和抛出异常就可以了。

使wait更有效率

可以利用Task.WhenAll或者WhenAny这两个方法大大提高await的效率,我们直接看代码:

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");
    }
    breakfastTasks.Remove(finishedTask);
}

我们使用一个列表来运行所有的异步方法,经过所有的更改之后,实例代码如下:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    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");
                }
                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();
        }
    }
}

经过更改后示例图如下:


为了更好的理解如何使用async以及await改善您的异步编程的场景,需要仔细的理解一下早餐这个实例,可以总结一下:

  • 使用async和await定义异步方法
  • await在合适的地方
  • 组合异步任务的多个步骤
  • 使用WhenAny或者WhenAll改善效率