首页 > 技术文章 > C# 异步编程(async&await)

peterYong 2018-12-15 23:57 原文

什么是异步编程

每次启动程序时,系统会自动在内存中创建一个进程。进程是构成运行程序的资源的集合。这些资源包括虚地址空间、文件句柄和许多其他程序运行所需的资源。
在进程的内部,系统会创建一个称为线程的内核(Kernel)的对象,它代表了真正的运行程序。线程是执行线程的简称。当进程建立,系统就会由主程序的Main方法的第一行语句处开始了线程的执行。

  • 在默认情况下,一个进程只包含一个线程,即从程序的开始,一直执行到结束。
  • 线程是可以派生其他线程,在任意时刻,一个进程都可以包含不同状态的多个线程,来执行程序的不同部分。
  • 如果一个进程拥有多个线程,它们将共享进程的资源。
  • 系统为处理器规划的执行单元,是线程而非进程。

在很多时候,我们在进程中使用单一线程从头到尾地执行程序,这种简单模式会导致性能和用户体验令人难以接受。
比如程序向另外一台服务器发出请求,由于网络等外部原因,此种通信任务往往会耗费大量时间,进程如果在此期间仅仅只能等待网络或网络上其他机器的响应,将严重地降低了性能。程序不应该浪费等待的时间,而应该更加高效地利用,在等待的时间执行其他任务,回复到达后再继续执行第一个任务。
如果程序调用某个方法,等待其执行全部处理后才能继续执行,我们称其为同步的。相反,在处理完成之前就返回调用方法则是异步的。
我们在编程语言的流程中添加了异步控制的部分,这部分的编程可以称之为异步编程

总结:

同步~同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去

异步~异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理,这样可以提高执行的效率。
     异步是一种目的。手段有 异步委托、多线程、线程池等...

.NET异步编程的发展历程

异步编程四个发展过程,参考:[你必须知道的异步编程]

NET1.1 APM(异步编程模型):.net 1.0时期就提出的一种异步模式,基于IAsyncResult接口实现BeginXXX和EndXXX类似的方法。HttpWebRequest就实现了该模式(继承IAsyncResult接口并且实现BeginXXX和EndXXX方法)

NET2.0 EAP(基于事件的异步编程模型):实现了基于事件的异步模式的类将具有一个或者多个以Async为后缀的方法和对应的Completed事件,并且这些类都支持异步方法的取消、进度报告和报告结果。然而.net中并不是所有的类都支持EAP。当调用基于事件的EAP模式的类的XXXAsync方法时,就开始了一个异步操作,并且基于事件的EAP模式是基于APM模式之上的,而APM又是建立在委托之上的。

NET4.0 TAP(基于任务的异步编程模型):.net 4.0为我们带来了Task的异步,我们有以下4种方法创建Task:

1,Task.Factory.StartNew(),比较常用。

2,Task t1 = new Task(() => { Console.WriteLine("t1 start"); });    t1.Start();

3,Task.Run(),是.net 4.5中增加的。

4,Task.FromResult(),如果结果是已计算,就可以使用这种方法来创建任务。

NET4.5 异步和等待(async和await)

使用async 和await关键字来定义异步方法,使得代码就像定义同步方法一样简单。

注意:使用async 和await定义异步方法不会创建新线程,它运行在现有线程上执行多个任务.

综上:以上几种模型本质都是使用线程池和委托机制的。

线程池

托管线程池

1、如果有需要后台处理的短任务,托管的线程池则为利用多个线程的简便方法。 在 Framework 4 及更高版本中使用线程池容易得多,因为可以创建在线程池线程上执行异步任务的 Task 和 Task<TResult> 对象。

2、特征:线程池线程是后台线程。 

每个线程均使用默认的堆栈大小,以默认的优先级运行,并且位于多线程单元中。 一旦线程池中的线程完成任务,它将返回到等待线程队列中。 这时开始即可重用它。 通过这种重复使用,应用程序可以避免产生为每个任务创建新线程的开销。

每个进程只有一个线程池。

3、线程池最大线程数

可以排队到线程池中的操作数仅受可用内存限制。 但是,线程池会限制进程中可同时处于活动状态的线程数。 如果所有线程池线程都处于忙碌状态,则其他工作项将进行排队,直到要执行它们的线程空闲。 从 .NET Framework 4 开始,进程的线程池的默认大小取决于若干因素,例如虚拟地址空间的大小。 进程可以调用 ThreadPool.GetMaxThreads 方法,以确定线程数。

可以使用 ThreadPool.GetMaxThreads 和 ThreadPool.SetMaxThreads 方法来控制最大线程数。

任务Task

Task是.NET4.0加入的,跟线程池ThreadPool的功能类似,用Task开启新任务时,会从线程池中调用线程,而Thread每次实例化都会创建一个新的线程。

注意:用Task方式启动的代码 不能控制其优先级,因为是采用的线程池。用Thread才可以设置优先级。

可查看官网:何时不使用线程池线程

创建Task的一般方法有:
1、直接实例化:必须手动去Start

             var task1 = new Task(() =>
             {
                  //TODO you code
              });
              task1.Start();
2、工厂模式创建,直接执行
              var task2 = Task.Factory.StartNew(() =>
              {
                  //TODO you code
              });
3、Run方法创建,直接执行
              var task2 = Task.Run(() =>
              {
                    //TODO you code
               });

Task的生命周期:

方法名                  说明
Created               表示默认初始化任务,但是“工厂创建的”实例直接跳过。
WaitingToRun      这种状态表示等待任务调度器分配线程给任务执行。
RanToCompletion    任务执行完毕。

Task的任务控制:
方法名            说明
Task.Wait task1.Wait();       就是等待任务执行(task1)完成,task1的状态变为Completed。
Task.WaitAll           待所有的任务都执行完成:
Task.WaitAny           等待任何一个任务完成就继续向下执行
Task.ContinueWith        第一个Task完成后自动启动下一个Task,实现Task的延续
CancellationTokenSource       通过cancellation的tokens来取消一个Task。

1、Task Wait方法不会等待子Task完成

所谓的子Task,就是在一个Task中再创建一个Task,也就是嵌套Task。

在主线程中等待后台线程执行完毕,可以使用Wait方法(会以同步的方式来执行)。不用Wait则会以异步的方式来执行。

测试:

private void  ParentTask()
        {
            Task.Factory.StartNew(() =>
                {
                    SonTask();
                });
            Thread.Sleep(100);
            Console.WriteLine("主Task执行完毕!");
        }
         private void  SonTask()
         {
            Thread.Sleep(1000);
            Console.WriteLine("子Task执行完毕!");
         }
        [STAThread]
        static void Main()
        {
            Task parentTask = Task.Factory.StartNew(() => {
                ParentTask(); 
            });
           parentTask.Wait(); //等待parentTask完成
           Console.WriteLine("主进程执行");
           Console.ReadLine();
        }
View Code

图片

从测试结果看出,Wait方法会同步主Task,等待主Task完成,但不会等待子Task完成。

2、Task.Wait() 和 await Task 的区别

Task.Wait() 会阻止线程,在等待期间,系统不会对其它操作进行反应。

await Task 只是等待当前任务完成,在等待期间可以响应系统的其它操作。

3、Task 等待任务完成

  • Task.Wait ,既有实例方法也有静态方法,都带有超时参数,但它是阻塞当前线程的
  • Task.When , 则是真正的异步等待,不阻塞线程的,可以节省一个线程资源。但是它没有超时参数。

Task 有一个 Delay 静态方法,我们是否可以利用这个方法来间接实现异步非阻塞的等待呢?答案是肯定的,

Task.Delay和Task任务,放在Task.WhenAny里面,要么超时的Delay先完成,要么任务先完成

参考:.NET 中让 Task 支持带超时的异步等待

4、Task的异常捕获

多线程之旅(8)_Task的异常捕获和处理方法——附C#源码

通过ContinueWith设置TaskContinuationOptions参数来捕获异常(推荐)

#region 通过ContinueWith设置TaskContinuationOptions参数来捕获异常(推荐)

        public static void ContinueWithException(int x, int y)
        {
            Task<string> t = Task.Run<string>(() =>
            {
                Thread.Sleep(3000);
                Console.WriteLine("我是线程还在异步执行");
                return Sumt(x, y).ToString();
            });

            //NotOnFaulted表示如果没有异常,才会执行ContinueWith内部的代码,但此时线程不会阻塞
            //t.ContinueWith(r => 
            //{
            //    string Exception = Convert.ToString(t.Exception); 
            //    Console.WriteLine("异常信息1:" + Exception);
            //}, TaskContinuationOptions.NotOnFaulted);
            //Console.WriteLine("继续异步执行1");

            //OnlyOnFaulted表示如果有异常,才会执行ContinueWith内部的代码【写我们的异常处理】,但此时线程不会被阻塞
            t.ContinueWith(r =>
            {
                //Thread.Sleep(3000);
                string Exception = Convert.ToString(t.Exception);
                Console.WriteLine("异常信息2:" + Exception);
            }, TaskContinuationOptions.OnlyOnFaulted);

            Console.WriteLine("继续异步执行2");


            //askContinuationOptions.OnlyOnFaulted表示:指定只应在延续任务前面的任务引发了未处理异常的情况下才安排延续任务。 此选项对多任务延续无效。【即:只有在发生异常的情况下才将异常信息记录到日志】
        }
        private static int Sumt(int x, int y)
        {
            return x / y;
        }


        #endregion
View Code

async/await

1、在语法上,异步方法具有如下的特点:

  • 方法头中包含async方法修饰符。
  • 包含一个或多个await表达式,表示可以异步完成的任务。
  • 必须具备以下三种返回类型:
    • void
    • Task
    • Task<T>
  • 异步方法的参数可以为任意类型任意数量,但不能为out和ref参数。
  • 安装预定,异步方法的名称应该以Async为后缀。
  • 除了方法以外,Lambda表达式和匿名方法也可以作为异步对象。

2、异步方法的返回类型

  • Task<T>:如果调用方法要从调用中获取一个T类型的值,异步方法的返回值就必须是Task<T>。调用方法将通过读取Task的Result属性【它是阻塞性的,尽量不要用】来获取这个T类型的值 或者用await 来获取结果
Task<int> value = DoStuff.CalculateSumAsync(5, 6);
...
Console.WriteLine( “Value: {0}”, value.Result);

任何返回Task<T>类型的异步方法其返回值必须为T类型或可以隐式转换为T的类型。

  • Task:如果调用方法不需要从异步方法在返回值,但需要坚持异步方法的状态,那么异步方法可以返回一个Task类型的对象。这时,即使异步方法中出现return语句,也将不会返回任何东西
  • void:如果调用方法仅仅想执行异步方法,而不需要与它做进一步的交互时【这称为“调用并遗忘”(fire and forget)】,异步方法可以返回void类型。这时与上一种情况类似,即使异步方法中包含任何return语句,也不会返回任何东西。

更多参考:async 的三大返回类型

await与Result

建议使用await,.Result容易造成死锁

await 时是释放线程,线程能去执行其它任务;.Result 就是让线程暂停,等待结果。

关于不要用Result属性,可以参考:小心C# 5.0 中的await and async模式造成的死锁

测试代码如下:

/// <summary>
    /// 测试 用await和.Result有啥不同
    /// </summary>
    public class TestTaskResult
    {
        public static void Test()
        {
            Console.WriteLine("开始");
            test1();
            Console.WriteLine($"第一个任务结束了{Environment.NewLine}");

            test2();
            Console.WriteLine($"第二个任务结束了{Environment.NewLine}");

            test3Async();
            Console.WriteLine($"第三个任务结束了{Environment.NewLine}");
        }

        public static void test1()
        {
            Console.WriteLine("第一个任务开始了");
            Task.Run(() =>
            {
                Console.WriteLine(1111);
                Thread.Sleep(5000);

            }).GetAwaiter().GetResult(); //GetAwaiter().GetResult() 会阻塞调用线程
            Console.WriteLine("第一个任务快完了");
            Thread.Sleep(2000);
        }

        public static void test2()
        {
            var a = test2Async().Result;  //Result会阻塞调用线程
            Console.WriteLine("第二个任务快完了");
            Thread.Sleep(2000);
        }
        public static async Task<bool> test2Async()
        {
            Console.WriteLine("第二个任务开始了");
            await Task.Delay(5000);
            Console.WriteLine(2222);
            return true;
        }


        public static async Task test3Async()
        {
            Console.WriteLine("第三个任务开始了");
            await Task.Run(() =>
            {
                Thread.Sleep(5000);
                Console.WriteLine(3333);
            });   //await不会阻塞调用它的线程
            Console.WriteLine("第三个任务快完了");
            Thread.Sleep(2000);
        }


    }
View Code

结论:await不会阻塞调用它的线程,而GetAwaiter().GetResult()和.Result则会阻塞调用它的线程。

调用

TestTaskResult.Test();
Console.WriteLine("测试结束!");

若将Test方法改为下面的

 public static async void Test()
        {
            Console.WriteLine("开始");
            test1();
            Console.WriteLine($"第一个任务结束了{Environment.NewLine}");

            test2();
            Console.WriteLine($"第二个任务结束了{Environment.NewLine}");

            await test3Async();
            Console.WriteLine($"第三个任务结束了{Environment.NewLine}");
        }
View Code

则第三个输出为

CancellationToken与CancellationTokenSource

原来.net framework使用异步的不是很多,而.net core首推异步变成,到处可以看到Task的影子,而CancellationToken正好是异步Task的一个控制器。

为什么需要CancellationToken?因为Task没有方法支持在外部取消Task,只能通过一个公共变量存放线程的取消状态,在线程内部通过变量判断线程是否被取消,当CancellationToken是取消状态,Task内部未启动的任务不会启动新线程。

取消令牌(CancellationToken) ,正确并合理的使用 CancellationToken 可以让业务达到简化代码、提升服务性能的效果;当在业务开发中,需要对一些特定的应用场景进行深度干预的时候,CancellationToken 将发挥非常重要的作用。

  • CancellationToken

CancellationToken有一个构造函数,可以传入一个bool类型表示当前的CancellationToken是否是取消状态。另外,CancellationToken是一个结构体。

常用方法:  

    //往CancellationToken中注册回调
    public CancellationTokenRegistration Register(Action callback);
    ...
    //当CancellationToken处于取消状态是,抛出System.OperationCanceledException异常
    public void ThrowIfCancellationRequested();

CancellationToken可以使用构造函数直接构造,同时可以传入一个参数,表示当前的状态,需要注意的是,CancellationToken的状态最多可以改变一次,也就是从未取消变成已取消。

如果构造时传入true,也就是说CancellationToken是已取消状态,这个时候注册的回调都会立即执行:  

CancellationToken cancellationToken = new CancellationToken(true);
    cancellationToken.Register(() =>
    {
        Console.WriteLine("Canceled");//这里会执行输出Canceled
    });

通过Register方法注册的服务只会执行一次!

但一般的,如果传入false构造出来的CancellationToken,可以认为是不会触发的,因为它没有触发的方法!所以一般的,我们都不会直接使用构造函数创建CancellationToken,而是使用CancellationTokenSource。

  • CancellationTokenSource

CancellationTokenSource可以理解为CancellationToken的控制器,控制它什么时候变成取消状态的一个对象,它有一个CancellationToken类型的属性Token,只要CancellationTokenSource创建,这个Token也会被创建,同时Token会和这个CancellationTokenSource绑定。  

CancellationToken状态只能改变一次(从未取消变成已取消),当CancellationToken时已取消状态时,每次往其中注册的回调都会立刻执行!当处于未取消状态时,注册进去的回调都会等待执行。

需要注意的是,当在未取消状态下注册多个回调时,它们在执行时是一个类似栈的结构顺序,先注册后执行。

 

在某些场景中,我们需要请求外部的第三方资源,比如请求天气预报信息;但是,由于网络等原因,可能会造成长时间的等待以致业务超时退出,这种情况可以使用 CancellationToken 来进行优化

/// <summary>
        /// 测试 CancellationToken  取消Token,即超时任务则取消
        /// </summary>
        /// <returns></returns>
        public async static Task TestCancel()
        {
            CancellationTokenSource cts = new CancellationTokenSource();
            cts.Token.Register(() => { Console.WriteLine("线程被取消"); });  //任务被取消时执行某个操作
            cts.CancelAfter(100);  //超时时间, 时间改大点试试

            HttpClient client = new HttpClient();
            var res = await client.GetAsync("http://xxxx:5004/index.html", cts.Token);
            Console.WriteLine("正常执行了请求");
            var result = await res.Content.ReadAsStringAsync();
            Console.WriteLine(result);

            cts.Dispose();
            client.Dispose();
        }

参考:C#中CancellationToken和CancellationTokenSource用法

  • 为什么CancellationToken与CancellationTokenSource分开

简单说是:“关注点分离”

将两个关键操作(发起取消请求与观察并响应取消)完全分开。特别是,仅CancellationToken可以观察到取消请求,但不能发起一个取消请求。

链接:Cancellation in Managed Threads

CancellationToken只能观察状态而不能改变状态的事实非常关键。您可以像糖果一样分发令牌,而不必担心除您之外的其他人会取消令牌,它可以保护您免受恶意第三方代码的侵害。

学习参考

1、探索c#之Async、Await剖析

使用Async标记Test为异步方法,用Await标记的方法 一般是需要耗时的操作。主线程碰到await时会立即返回,继续以非阻塞形式执行主线程下面的逻辑。当await耗时操作完成时,继续执行await后面的逻辑:

 Test();
        Console.WriteLine("A逻辑");
        static async void Test()
        {
            await Task.Run(() => { Thread.Sleep(1000); Console.WriteLine("B逻辑"); });
            Console.WriteLine("C逻辑"); 
        }

Async、Await的加入让原先这种混乱的步骤,重新拨正了,执行步骤是:A逻辑->B逻辑->C逻辑。

主线程调用Test(),await可能会开辟一个Task任务线程。

注意:这3个步骤是有可能会使用同一个线程的,也可能会使用2个,甚至3个线程。 可以用Thread.CurrentThread.ManagedThreadId测试下得知。

关于async和await常问的问题

问题一:是不是写了async关键字的方法就代表该方法是异步方法,不会堵塞线程呢?

  答: 不是的,对于只标识async关键字的(指在方法内没有出现await关键字)的方法,调用线程会把该方法当成同步方法一样执行,所以然而会堵塞GUI线程,只有当async和await关键字同时出现,该方法才被转换为异步方法处理。

问题二:“async”关键字会导致调用方法用线程池线程运行吗?

  答: 不会。被async关键字标识的方法不会影响方法是同步还是异步运行并完成,而是,它使方法可被分割成多个片段,其中一些片段可能异步运行,这样这个方法可能异步完成。这些片段界限就出现在方法内部显示使用”await”关键字的位置处。所以,如果在标记了”async”的方法中没有显示使用”await”,那么该方法只有一个片段,并且将以同步方式运行并完成。在await关键字出现的前面部分代码和后面部分代码都是同步执行的(即在调用线程上执行的,也就是GUI线程,所以不存在跨线程访问控件的问题),await关键处的代码片段是在线程池线程上执行。总结为——使用async和await关键字实现的异步方法,此时的异步方法被分成了多个代码片段去执行的,而不是像之前的异步编程模型(APM)和EAP那样,使用线程池线程去执行一整个方法。

其他学习 

C#语法——await与async的正确打开方式

async & await 的前世今生(Updated)

关于async与await的FAQ

async、await在ASP.NET[ MVC]中之线程死锁的故事

C#中 Thread,Task,Async/Await,IAsyncResult 的那些事儿!

异步编程系列

 

推荐阅读