异步函数允许线程池在多个CPU内核上调度任务,使多个线程能并发工作,从而高效率的使用系统资源,同时提升应用程序的吞吐能力。
首先来看一下同步执行IO的情况
如上图,程序通过构造一个FileStream对象来打开磁盘文件,然后调用Read方法从文件中读取数据,
在调用Read方法时,线程从托管代码转变为本机/用户模式代码,Read内部调用WIn32ReadFile函数(①)。Read分配一个称为IO请求包(IO Request Packet,IRP)(②)的数据结构,
然后ReadFile将线程从本机/用户模式代码转变为本机内核代码,并向内核传递IRP数据结构,从而调用Windows内核(③)
Window将IRP传递给对应的设备驱动程序的IRP队列(④),然后设备执行请求的IO操作(⑤)
在硬件执行IO操作期间,发出IO请求的线程将无事可做,Windows会将线程编程睡眠状态,防止浪费CPU时间(⑥)(但它仍然浪费了内存空间,因为它的用户栈、内核模式栈、线程环境块和其他数据结构都还在内存中)
最终,硬件设备完成IO操作,Windows唤醒线程并调度给一个CPU,使它从内核模式返回用户模式,再返回至托管代码(⑦,⑧和⑨),FileStream的Read方法返回一个Int32,指明从文件中读取的实际字节数。
下面讨论一下Windows如何执行异步IO操作,见下图:
如上图,和同步IO示意图相比,构造函数增加传递了一个FIleOptions.Asynchronous标志,告诉Windows希望文件的读写操作以异步方式执行。
在调用ReadAsync方法读取数据时,函数内部会分配一个Task<Int32>对象来代表用于完成读取操作的代码,
然后ReadAsaync方法调用Win32 ReadFile函数(①),ReadFIle分配IRP,同前面一样初始化它(②),然后传递给Windows内核(③)Windows把IRP添加到硬盘驱动程序的IRP队列中(④),但此时线程不再阻塞,而是允许返回至代码,所以线程立即从ReadAsync调用中返回(⑤,⑥和⑦),此时,IRP可能尚未处理好,所以不能在ReadAsync之后的代码中访问传递的Byte[]中的字节。
硬件设备处理好IRP后(a),会将完成的IRP放到CLR的线程池队列中(b)。将来某个时刻,某个线程池线程会提取完成的IRP并执行完成任务的代码,
最终,要么设置异常(如果发生错误),要么返回结果(c),这样,Task对象就知道操作什么时候完成,代码可以开始运行并安全访问Byte[]重点额数据。
可以发现,使用异步函数编程,有以下优点:
1.减少系统资源的使用
2.减少线程上下文的切换
3.提高GC的速度
4.GUI程序中,使用户界面不会被挂起
同时,异步函数存在以下限制:
- 不能将应用程序的Main方法转变成异步函数,另外,构造器、属性访问器方法和事件访问器方法不能转变成异步函数
- 异步函数不能使用任何out或ref参数
- 不能在catch,finally或者unsafe块中使用await操作符
- 不能在await操作符之前获得一个支持线程所有权或递归的锁,并在await操作符之后释放它。
- 在查询表达式中,await操作符只能在初始from子句的第一个集合表达式中使用,或者在join子句的集合表达式中使用。
在C#中,异步函数通过async和await关键字来标识,某个方法一旦被标识为async,编译器就会将方法的代码转换成实现了状态机的一个类型。
编译器如何将异步函数转换成状态机并实现异步调用:
首先编译器会创建状态机实例并初始化它,状态机的初始状态为-1;然后开始执行状态机,并返回状态机的Task。
在状态机的内部,状态机通过操作数来选定执行路径,在初始状态-1,使用await操作符,将会调用方法的GetAwaiter方法,获取对象的awaiter(等待者),正是它将被等待的对象与状态机粘合起来;
状态机获得awaiter后会查询其IsCompleted属性,
- 如果操作已经以同步方式完成,属性将返回true,然后状态机继续执行并调用awaiter.GetResult方法,该方法要么抛出异常(操作失败),要么返回结果(操作成功)。
- 如果操作以异步方式完成,属性返回false,状态机调用awaiter的OnCompleted方法并传递一个委托(引用状态机的MoveNext方法)。现在状态机允许它的线程回到原地以执行其他代码。将来某个时候,封装了底层任务的awaiter会在完成时调用委托继续执行MoveNext,从它当初离开的位置继续执行,这时,代码调用awaiter的GetResult方法,继续处理后续逻辑。
异步函数的返回类型一般为Task或Task<Result>,他们代表着函数的状态机完成,但是异步函数使可以返回void的(除特殊情况外不推荐),这种特殊情况是在返回void的事件处理方法中执行IO操作,编译器会返回void的异步函数创建状态机,但不再创建Task对象。所以无法得知void的异步函数状态机在什么时候运行完毕。
FileStream特有的问题
创建FileStream对象时,可通过FileOptions.Asynchronous标志指定以同步还是异步方式进行通信。
如果不指定这个标志,Windows将以同步方式执行所有文件操作,即使调用ReadAsync方法,操作表面上异步执行,但FileStream类是在内部用另一个线程模拟异步行为(这个额外线程纯属浪费,而且影响性能)。
但如果指定这个标志,使用Read方法执行一个同步操作,在FileStream内部,仍会开始一个异步操作,然后立即使调用线程进入睡眠状态,直到操作完成被唤醒,从而模拟同步行为。
所以,在使用FileStream时,要先想好是同步还是异步执行IO,并通过FileOptions.Asynchronous来标识自己的选择。如果选择异步,则调用ReadAsync,如果选择同步则使用Read,这样可以获得最佳的性能。