第 28 章 I/O 限制的异步操作

第 28 章 I/O 限制的异步操作

本章内容:

28.1 Windows 如何执行 I/O 操作

程序通过构造一个 FileStream 对象来打开磁盘文件,然后调用 Read 方法从文件中读取数据。调用 FileStreamRead 方法时,你的线程从托管代码转变为本机/用户模式代码,Read 内部调用 Win32 ReadFile 函数(①)。

ReadFile 分配一个小的数据结构,称为 I/O 请求包(I/O Request Packet, IRP)(②)。

IRP 结构初始化后包含的内容有:文件句柄,文件中的偏移量,一个 Byte[] 数组的地址(数组用读取的字节来填充),要传输的字节数以及其他常规性内容。

然后,ReadFile 将你的线程从本机/用户模式代码,向内核传递 IRP 数据结构,从而调用 Windows 内核(③)。

根据 IRP 中的设备句柄, Windows 内核知道 I/O 操作要传送给哪个硬件设备。因此,Windows 将 IRP 中的设备句柄,Windows 内核知道 I/O 操作要传送给哪个硬件设备。因此,Windows 将 IRP 传送给恰当的设备驱动程序的 IRP 队列(④)。

每个设备驱动程序都维护着自己的 IRP 队列,其中包含了机器上运行的所有进程发出的 I/O 请求。IRP 数据包到达时,设备驱动程序将 IRP 信息传给物理硬件设备上安装的电路板。现在,硬件设备将执行请求的 I/O 操作(⑤)。

但要注意一个重要问题;在硬件设备执行 I/O 操作期间,发出了 I/O 请求的线程将无事可做,所以 Windows 将线程变成睡眠状态,防止它浪费 CPU 时间(⑥)。

这当然很好。但是,虽然线程不浪费时间,但它仍然浪费了空间(内存),因为它的用户模式栈、内核模式栈、线程环境块(thread environment block,TEB)和其他数据结构都还在内存中,而且完全没有谁去访问这些东西。这当然就不好了。

最终,硬件设备会完成 I/O 操作。然后,Windows 会唤醒你的线程,把它调度给一个 CPU,使它从内核模式返回用户模式,再返回至托管代码(⑦,⑧和⑨)。FileStreamRead 方法现在返回一个 Int32,指明从文件中读取的实际字节数,使你知道在传给 ReadByte[] 中,实际能检索到多少个字节。

当每个客户端请求在Web服务器上发出时,同步方式的数据库请求会导致线程阻塞和潜在的系统资源浪费。当数据库返回结果时,大量线程同时执行,导致频繁的上下文切换,进一步损害性能。这与实现可伸缩应用程序的初衷背道而驰。

Windows 如何执行异步 I/O 操作。引入了 CLR 的线程池,打开磁盘文件的方式仍然是通过构造一个 FileStream 对象,但现在传递了一个 FileOptions.Asynchronous 标志,告诉 Windows 我希望文件的读/写操作以异步方式执行。

ReadAsync 使用任务对象来处理读取操作。该方法调用 Win32 函数 ReadFile,将 IRP 添加到硬盘驱动程序的 IRP 队列中,并立即返回结果。 读取完成后,完成的 IRP 将被放置在CLR的线程池中,等待异步函数的回调方法进行处理和访问 Byte[] 中的数据。

注意,调用 ReadAsync 返回的是一个 Task 对象。可在该对象上调用 ContinueWith 来登记任务完成时执行的回调方法,然后在回调方法中处理数据。当然,也可利用 C# 的异步函数功能简化编码,以顺序方式写代码(感觉就像是执行同步 I/O)

以异步方式执行 I/O 操作还有其他许多好处

  1. 资源利用率降到最低
  2. 减少上下文切换
  3. 垃圾回收速度变得更快
  4. 增强调试性能
  5. 提高应用程序异步下载速度
  6. 为GUI应用程序提供更好的用户体验

28.2 C# 的异步函数

异步函数存在以下限制。

  1. 不能将应用程序的 Main 方法转变成异步函数。另外,构造器、属性访问器方法和事件访问器方法不能转变成异步函数。
  2. 异步函数不能使用任何 outref 参数。
  3. 不能在 catch,finally 或 unsafe 块中使用 await 操作符。
  4. 不能在 await 操作符之前获得一个支持线程所有权或递归的锁,并在 await 操作符之后释放它。
  5. 在查询表达式中,await 操作符只能在初始 from 子句的第一个集合表达式中使用,或者在 join 子句的集合表达式中使用。

28.3 编译器如何将异步函数转换成状态机

28.4 异步函数扩展性

28.5 异步函数和事件处理程序

异步函数通常返回 Task 或 Task 代表函数的状态机已经完成。但可以返回 void,尤其用于异步事件处理程序中进行 I/O 操作。然而,C# 编译器不会为返回 void 的异步函数创建 Task 对象,而是利用 await 操作符执行不阻塞的 I/O 操作。但由于无法知道返回 void 异步函数的状态机何时运行完毕,将程序入口方法(Main)标记为 async 会导致编译器报错。

28.6 FCL 的异步函数

WinRT 方法遵循相同的命名规范并返回一个 IAsyncInfo 接口。幸好,.NET Framework 提供了能将 IAsyncInfo 转换为 Task 的扩展方法。异步函数可通过添加”Async” 后缀区分,许多 I/O 操作类型提供 Async 方法。旧版异步编程模型已过时,Task 是首选。某些类缺乏 Async 方法,但可通过辅助方法转换至基于 Task 的新模型。

28.7 异步函数和异常处理

Windows处理异步I/O请求可能出错并通知应用程序异常。当设备驱动程序向CLR线程池提交IRP并引发异常时,await操作符会抛出第一个内部异常而不是AggregateException,从而提供更好的编程体验。如果异步函数返回void而未处理异常,代码会使用调用者的同步上下文重新抛出异常,可能导致整个进程终止。

28.8 异步函数和其他功能

异步函数需要先执行密集的、计算限制的处理,再发起异步操作。如果通过 GUI 线程来调用函数,UI 就会失去响应,这时就可以使用 Task 的静态 Run 方法从其他线程中执行异步函数。为了在 lambda 表达式中添加 await 操作符,需要在 lambda 前面添加 async。

1
2
3
4
5
6
7
// Task.Run 在 GUI 线程上调用
Task.Run(async () => {
// 这里的代码在一个线程池线程上运行
// TODO:在这里执行密集的、计算限制的处理...
await XXXAsync(); // 发起异步操作
// 在这里执行更多处理...
});

28.9 应用程序及其线程处理模型

.NET Framework有多种应用程序模型,其中GUI应用程序引入了线程处理模型。这个模型中,UI元素只能由创建它的线程更新。为解决这个问题,FCL定义了一个名为System.Threading.SynchronizationContext的基类,它将应用程序模型连接到线程处理模型。无需了解太多关于SynchronizationContext类的信息,等待一个Task时会获取调用线程的SynchronizationContext对象,以确保使用正确的线程处理模型。对于ASP.NET应用程序,await后面的代码保证在客户端线程池线程上执行。SynchronizationContext派生类使用方法如Control、BeginInvoke、Dispatcher.BeginInvoke和Windows.UI.Core.CoreDispatcher.RunAsync等来恢复GUI线程的状态机。