前言

上一篇我们着重讲解了TPL任务并行库,可以看出TPL已经很符合现代API的特性:简洁易用。但它的不足之处在于,使用者难以理解程序的实际执行顺序。

为了解决这些问题,在C# 5.0中,引入了新的语言特性,被称为异步函数(asynchronous function)。对应的.Net版本为.Net Framework 4.5。

最后一个异步编程模型:异步函数

概述

由于异步函数为语言特性的实现,因此它的本质依然属于TPL模型,但提供了更高级别的抽象,真正简化了异步编程。抽象可以隐藏主要的实现细节,使得开发人员无需考虑许多重要的事情,从而达到简化的效果。

在本文中,我们主要会讲解异步函数的声明和使用方式,以及在多种场景下使用异步函数,处理异常等。

声明异步函数

声明异步函数的方法很简单,只需使用async关键字标注任意一个方法即可。需要注意的是,如果只使用了async标注方法,而方法内部未使用await,会导致编译警告,如图所示:

另一个重要的事实是,异步函数必须返回Task或Task<T>类型。也可使用async void,但不推荐,若使用async void方式, 异常处理及跟踪将不由TPL模型处理,而是会直接在SynchronizationContext上引发,这样会引起整个进程的崩溃。因此通常会在UI层处理事件时,才会使用async void方式。

改写后相关代码示例如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;namespace asyncDemo
{public class Utils{public async Task<string> GetStringAsync(){await Task.Delay(TimeSpan.FromSeconds(2));return "Hello World!";}}
}

这里我们执行完await调用的代码行后,会立即返回,而不是阻塞两秒,如果是同步执行则结果相反。当执行完await操作后,TPL会立即将工作线程放回线程池,我们的程序会进行异步等待。直到2秒后,我们又一次从线程池中得到工作线程,并继续运行其中剩余的异步方法。这样就允许我们在等待2秒时,可以重用工作线程来做其他事,提升了应用程序的可伸缩性。

事实上,异步函数在编译器后台会被编译成复杂的程序结构,一般称之为迭代器。迭代器的内部是一种状态机,由于状态机的概念理解较为复杂,因此这里不再赘述。所以我们在日常编写代码时,并不需要将每一个方法都标记为async,尤其是并不需要使用异步的方法。通过上述概念可知,滥用async会导致编译器编译时生成大量的迭代器,会有显著的性能损失。

获取异步任务结果

既然我们已经了解了async-await本质上依然为TPL模型,那么在使用TPL和await操作符获取异步结果中有什么不同呢?此处我们可以通过实验来探究。

如图所示,我们分别使用Task和await执行:

二者都调用了同一个异步函数打印当前线程的Id和状态。

在第一个中启动了一个任务,运行2秒后返回关于工作线程的信息。我们还定义了一个后续操作,用于在异步操作完成后,打印出操作结果;另一个后续操作用于有错误发生时,打印异常信息。最终返回一个代表其中一个后续操作任务的任务,并在Main中等待其执行完成。

而在第二个中,我们直接使用await对任务进行操作,获取异步执行的结果,同时使用try-catch代码块来捕获可能发生的异常,这和我们编写同步方法的代码风格是一致的,简化了程序编写的复杂度。实际上在await之后编译器创建了一个任务及后续操作,并处理了可能发生的异常信息。

相关代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;namespace asyncDemo
{class Program{static void Main(string[] args){Task t = AsyncTPL();t.Wait();t = AsyncAwait();t.Wait();Console.Read();}static Task AsyncTPL(){Task<string> t = GetInfoAsync("任务1");Task t2 = t.ContinueWith(x => Console.WriteLine(t.Result), TaskContinuationOptions.NotOnFaulted);Task t3 = t.ContinueWith(x => Console.WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted);return Task.WhenAny(t2, t3);}async static Task AsyncAwait(){try{string result = await GetInfoAsync("任务2");Console.WriteLine(result);}catch (Exception ex){Console.WriteLine(ex);}}async static Task<string> GetInfoAsync(string name){await Task.Delay(TimeSpan.FromSeconds(2));return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}}
}

运行后,如图所示:

从结果中我们可以看出,两种操作的方式在概念上是等同的,但是第二种方式中编译器隐式处理了异步相关的代码,背后的逻辑更为复杂,我们在后续小节中会借助示例再详细说明这些内容。

多个连续的await

我们已经得知了使用await的代码行将会异步执行,那么如果我们在同一个async方法中使用多个连续的await,它们会并行异步执行吗?我们不妨一试。

如图所示,我们依然定义TPL和Async函数进行对比:

我们在定义AsyncAwait方法时,依然使用同步代码的方式进行书写,唯一的不同之处是连续使用了两个await声明。

而在TPL方法中,则使用了一个容器任务,来处理所有相互依赖的任务。然后启动主任务,并为其添加一系列的后续操作。当该任务完成时,会打印出其结果,然后再启动第二个任务,并抛出一个异常,打印出异常信息。

相关代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;namespace asyncDemo
{class Program{static void Main(string[] args){Task t = AsyncTPL();t.Wait();t = AsyncAwait();t.Wait();Console.Read();}static Task AsyncTPL(){var continueTask = new Task(() =>{Task<string> t = GetInfoAsync("TPL1");t.ContinueWith(task =>{Console.WriteLine(t.Result);Task<string> t2 = GetInfoAsync("TPL2");t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Result),TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);},TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);t.ContinueWith(task =>Console.WriteLine(t.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);});continueTask.Start();return continueTask;}async static Task AsyncAwait(){try{string result = await GetInfoAsync("Async1");Console.WriteLine(result);result = await GetInfoAsync("Async2");Console.WriteLine(result);}catch (Exception ex){Console.WriteLine(ex);}}async static Task<string> GetInfoAsync(string name){Console.WriteLine($"{name} 开始执行!");await Task.Delay(TimeSpan.FromSeconds(2));if (name == "TPL2"){throw new Exception("发生异常!");}return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}}
}

运行后,执行结果如图所示:

我们从结果中可以看出,TPL的后续依赖任务会按照我们的书写顺序依次执行,让人讶异的是await,它并没有并行执行,而也是顺序执行的。Async2任务只有等Async1任务完成后才会开始执行,但它为什么是异步程序呢?

事实上,它并不总是异步的,当使用await时,如果一个任务已经完成,我们会异步地得到相应的任务结果。否则,在看到await声明时,通常的行为是方法执行到await代码行应立即返回,且剩下的代码会在一个后续操作任务中执行。因此等待操作结果时,并没有阻塞程序执行,这是一个异步调用。当AsyncAwait方法中的代码在执行时,除了可以在Main中执行t.Wait外,我们可以执行其他任何任务。但主线程必须等待直到所有异步操作完成,否则主线程完成后会停止所有异步操作的后台线程。

这两段代码中,如果要比较TPL和await,那么则是TPL方法的书写更容易阅读和理解,调用层次更为清晰,请记住一点,异步并不总是意味着并行执行。

并行执行的await

现在我们已经得知了,异步并不总是并行的,那么它能不能通过某种手段或方式进行并行操作呢?答案是可以的,我们一起看一下如何实现:

这里我们定义了2个不同的Task分别运行3秒和5秒,然后使用Task.WhenAll来创建另一个任务,该任务只有在所有底层任务完成后才会执行,之后我们等待所有任务的结果。

相关实现代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;namespace asyncDemo
{class Program{static void Main(string[] args){Task t = AsyncProcessing();t.Wait();Console.Read();}async static Task AsyncProcessing(){Task<string> t1 = GetInfoAsync("任务1", 3);Task<string> t2 = GetInfoAsync("任务2", 5);string[] results = await Task.WhenAll(t1, t2);foreach (string result in results){Console.WriteLine(result);}}async static Task<string> GetInfoAsync(string name, int seconds){await Task.Delay(TimeSpan.FromSeconds(seconds));//await Task.Run(() => Thread.Sleep(TimeSpan.FromSeconds(seconds)));return $"{name}的线程Id为:{Thread.CurrentThread.ManagedThreadId},是否为线程池线程:" +$"{Thread.CurrentThread.IsThreadPoolThread}";}}
}

运行后,结果如图所示:

根据程序运行的结果我们可以看到,5秒之后,我们获取到了所有的结果,说明这些任务是同时运行的。这里还有一个有趣的现象是,两个任务是被同一个线程池中的工作线程执行的,为什么会这样呢?这时候我们可以注释掉Task.Delay这行代码,并取消对Task.Run的注释,再次运行后,结果如图所示:

此时我们会发现,两个任务会被不同的工作线程执行。

造成这种情况的原因是Task.Delay在幕后使用了一个计时器,它的执行过程如下:

1、从线程池中获取工作线程,它将等待Task.Delay返回结果;

2、Task.Delay方法启动计时器,并指定一块代码,该代码会在计时器到了Task.Delay中指定的时间后进行调用,之后立即将工作线程返回线程池中;

3、当计时器事件运行时(类似于Timer类),我们会再次从线程池中获取一个可用的工作线程并运行计时器给它的代码(可能会是我们之前使用过的工作线程)。

而Task.Run方法则不同,它的执行过程如下:

1、从线程池中获取工作线程,并将其阻塞几秒钟;

2、获取第二个工作线程,也将其阻塞几秒钟。

在此过程中,两个工作线程并无法做其他事,只能进行等待操作,因此在某种程度上,这两个工作线程是被浪费掉了。

所以我们在实际使用时,尽量使用Task.Delay的方式进行并行操作,而不是使用Task.Run。

处理异常

在异步函数中,处理异常可以像同步代码那样使用try-catch去处理,但是在不同的场景下,也有不同的使用方式,下面我们一起来看看有哪些常见的使用场景,如图所示:

我们分别定义了三种场景:单个异常、多个异常及多个异常的异常集合。相关实现代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;namespace asyncDemo
{class Program{static void Main(string[] args){Task t = AsyncProcessing();t.Wait();Console.Read();}async static Task AsyncProcessing(){Console.WriteLine("1、单个异常");try{string result = await GetInfoAsync("任务1", 2);Console.WriteLine(result);}catch (Exception ex){Console.WriteLine($"异常内容:{ex}");}Console.WriteLine("-----------------------------------------------------");Console.WriteLine("2、多个异常");Task<string> t1 = GetInfoAsync("任务1", 3);Task<string> t2 = GetInfoAsync("任务2", 2);try{string[] results = await Task.WhenAll(t1, t2);Console.WriteLine(results.Length);}catch (Exception ex){Console.WriteLine($"异常内容:{ex}");}Console.WriteLine("-----------------------------------------------------");Console.WriteLine("3、多个异常的异常集合");t1 = GetInfoAsync("任务1", 3);t2 = GetInfoAsync("任务2", 2);Task<string[]> t3 = Task.WhenAll(t1, t2);try{string[] results = await t3;Console.WriteLine(results.Length);}catch{var ae = t3.Exception.Flatten();var exceptions = ae.InnerExceptions;Console.WriteLine($"异常发生数量:{exceptions.Count}");}}async static Task<string> GetInfoAsync(string name, int seconds){await Task.Delay(TimeSpan.FromSeconds(seconds));throw new Exception($"异常来自于:{name}");}}
}

执行后的结果如图所示:

从执行结果我们可以看出,如果在可能发生多个异常的场景下,仍直接使用try-catch的方式处理异常,那么只能从底层的AggregateException中获取到第一个异常。

为了得到所有的异常信息,我们需要使用await任务的Exception属性。在第三种场景中,我们使用了AggregateException的Flatten方法,将层级异常放入一个列表,从而达到获取所有异常的效果,在实际使用时应多加注意。

小结

至此为止,关于异步函数的特性及使用方式就已经介绍完毕。通过异步模型的发展历程我们可以看出,为了应对不同时期的需求,异步模型也经历了由复杂到简单的过程。最终我们使用的异步函数模式,可以使得程序在编写代码时,能用编写同步代码的方式来实现异步,大大降低了复杂度,也提升了代码可读性。由于该思想和语法相当简洁,在其他语言中也借鉴了类似的语法,如JavaScript在ES6标准中也引入了async-await的写法来实现异步,避免了多个回调嵌套的尴尬方式。

但关于async-await本身,C#编译器在背后通过及其复杂的原理为我们屏蔽了底层的细节,包括为何不能使用async void等等,这些原理还是建议大家有时间的话进行一些挖掘和探究,学习背后的设计思想,会对我们的程序设计思维大有裨益。

.Net异步编程系列的文章,到此也暂时告一段落了。我个人在后面的日子中也会将主要精力投入到架构设计和微服务等前沿技术中,同时会总结一些个人的心得与体会形成其他系列的分享,请大家拭目以待。也感谢所有阅读此系列文章的读者,感谢大家的反馈,陪伴我度过一段难忘的时光,我们下一期再会!

参考

  1. 1.避免 Async Void https://docs.microsoft.com/zh-cn/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming

浅谈.Net异步编程的前世今生----异步函数篇(完结)相关推荐

  1. 浅谈.Net异步编程的前世今生----APM篇

    前言 在.Net程序开发过程中,我们经常会遇到如下场景: 编写WinForm程序客户端,需要查询数据库获取数据,于是我们根据需求写好了代码后,点击查询,发现界面卡死,无法响应.经过调试,发现查询数据库 ...

  2. 浅谈阻塞/非阻塞、同步/异步——从linux read()系统调用出发

    浅谈阻塞/非阻塞.同步/异步 –从linux IO系统调用出发 阻塞与非阻塞主要从进程/线程的角度出发: 阻塞(blocking):教科书年年考的概念--调用方(主线程)发起调用之后挂起直到被调用方法 ...

  3. python编写函数_浅谈Python 函数式编程

    匿名函数lambda表达式 什么是匿名函数? 匿名函数,顾名思义就是没有名字的函数,在程序中不用使用 def 进行定义,可以直接使用 lambda 关键字编写简单的代码逻辑.lambda 本质上是一个 ...

  4. python异步编程视频_asyncio异步编程【含视频教程】

    Python Python开发 Python语言 asyncio异步编程[含视频教程] 不知道你是否发现,身边聊异步的人越来越多了,比如:FastAPI.Tornado.Sanic.Django 3. ...

  5. python采用函数编程模式_浅谈Python 函数式编程

    匿名函数lambda表达式 什么是匿名函数? 匿名函数,顾名思义就是没有名字的函数,在程序中不用使用 def 进行定义,可以直接使用 lambda 关键字编写简单的代码逻辑.lambda 本质上是一个 ...

  6. 【转】1.6异步编程:IAsyncResult异步编程模型 (APM)

    传送门:异步编程系列目录-- 大部分开发人员,在开发多线程应用程序时,都是使用ThreadPool的QueueUserWorkItem方法来发起一次简单的异步操作.然而,这个技术存在许多限制.最大的问 ...

  7. java socket 异步回调函数,分享nodejs异步编程基础之回调函数用法

    nodejs异步编程基础之回调函数用法分析 本文实例讲述了nodejs异步编程基础之回调函数用法.分享给大家供大家参考,具体如下: Node.js 异步编程的直接体现就是回调. 异步编程依托于回调来实 ...

  8. python采用函数式编程模式-浅谈Python 函数式编程

    匿名函数lambda表达式 什么是匿名函数? 匿名函数,顾名思义就是没有名字的函数,在程序中不用使用 def 进行定义,可以直接使用 lambda 关键字编写简单的代码逻辑.lambda 本质上是一个 ...

  9. 异步编程-线程实现异步编程

    异步编程-线程实现异步编程 使用线程实现异步 第一种方式 第二种方式 问题 在日常开发中我们经常会遇到这样的情况,即需要异步地处理一些事情,而不需要知道异步任务的结果.比如在调用线程里面异步打日志,为 ...

最新文章

  1. python工作好找吗-python工作好找吗
  2. C#编译器选项(目标平台)
  3. 解决多个pts/*在线登录问题
  4. ssh白名单_阿里云服务器ssh白名单
  5. 为Mac安装homebrew
  6. Python3 Ocr 初探
  7. 【英语学习】【Level 07】U06 First Time L4 Lost in Shanghai
  8. struts2+json(3)
  9. codevs 5965 [SDOI2017]新生舞会
  10. Git最基本入门,只是个感想总结啊啊啊不要搜到我0.0
  11. 系统整理 精讲 swift 泛型
  12. 各种电子元器件介绍与电路基础作用
  13. 数组-滑动窗口(直接套模板完事儿)
  14. 怎么退出磁贴桌面回到传统桌面?
  15. Soul 网关(一)---- 架构设计简介、soul-admin、soul-bootstrap
  16. Solution to no ADO.NET in VS2019 VS里没有ADO的解决办法
  17. android 轮播图
  18. 2D游戏知识点二、Unity 2D游戏主角基本功能和动画
  19. linux怎么刷机教程,Ubuntu手机版来了 附刷机教程
  20. SQL Saturday活动再起

热门文章

  1. Linux命令-网络命令:wall
  2. .NET Regular Expressions
  3. C/C++语言的特点
  4. win32 注册表操作
  5. 查询表结构的语句总结
  6. 关于iPhone的UIView刷新(转)
  7. C# 生成私钥和公钥
  8. 【小安翻唱】凉宫春日的忧郁--冒険でしょでしょ第五届外语歌曲大赛助兴节目~绫魂论坛送aya的生日礼物筹备开始~...
  9. 为DataList和GridView内容项添加序号
  10. airdroid黑屏_如何使用AirDroid从PC控制Android设备