一直以来我都没能完全理解操作系统是如何处理I/O这些操作的,这些操作也是容易浪费资源以及WPF等桌面应用程序响应不及时的根源之一。今天早上,读到了《CLR via C#》中关于这一主题的章节,让我茅塞顿开。

假设有一台装了Windows的PC,有一个硬盘连接着系统。

同步流程

上图是Microsoft Windows处理同步I/O的流程,下面做一些简单的解释:

在我们的应用程序中,试图打开一个硬盘上的文件,并且读取以返回它的字节流。程序发出读取文件的请求,最后将返回的字节流存储在一个变量中。

步骤1:当程序调用 FileStream 的 Read 方法时,线程从托管代码切换到用户模式原生代码,Read 方法在内部调用Win32的 ReadFile 函数。

步骤2:ReadFile 函数收集一个小型的数据结构IRP,这个IRP中包含了这个文件的句柄、文件流起始位置在文件内部的偏移量、文件流数组的地址、文件流的长度以及其他的一些信息。

步骤3:ReadFile 函数被Windows内核执行,这个时候线程从用户模式原生代码(native/user-mode code)转换为内核模式原生代码(native/kernel-mode code),同时将IRP数据结构传递给内核。

步骤4:从上面传来的IRP结构中,Windows内核读取设备的句柄,便知道这个I/O操作需要什么设备,然后内核将IRP传递给对应设备的IRP队列。每一个设备维护自己的IRP队列,这个队列中包含了计算机中各个处理器发来的请求。

步骤5:当有IRP包出现在队列中的时候,设备驱动就会把这些IRP信息传递给真实硬件(此处即是硬盘)的电路板。

步骤6:当硬件在处理I/O请求的时候,对应的线程就没有事情可以做了,所以Windows就将线程设置为sleep状态以避免浪费CPU资源(CPU time),但是这却依然不能避免浪费了其他资源,比如内存空间,诸如用户模式栈(user-mode stack)、内核模式栈(kernel-mode stack)、线程环境块(TEB: thread environment block)以及其他的一些数据结构。这些资源并没有被使用,但是却留在了内存中。另外,如果应用程序是GUI相关的( WPF, Windows Store app等等)就会造成假死的无响应状态。

步骤7, 8 , 9:最后硬件设备完成I/O的工作,Windows就会激活线程,分派给CPU,让它沿路返回到托管线程中。

相信你也看出同步I/O的瓶颈在哪里了,硬件真正在处理I/O请求的那段时期,线程就被block住了。假设有多个请求到来,线程池这个时候就会创建另一个线程将信息传送给IRP队列,新来的线程同样进入sleep状态,等待设备处理,这样消耗的资源越积越多却几乎得不到使用。

最糟糕的是,当设备处理完了这些请求,在CPU内核不够多的情况下,线程中最耗时的上下文切换将会严重拖累整个系统。

解决这一困境的最有利方法便是尽量让Windows处理异步的处理I/O请求。

异步流程

与同步的代码类似,从硬盘中打开一个文件,并且返回它的字节流。只是不同的是这次传递了 FileOptions.Asynchronous 标志位,以告诉Windows将读写操作都异步来执行。

步骤1:调用 FileStream 的 ReadAsync 方法来读取文件数据,ReadAsync 方法在内部调用Win32的 ReadFile 函数。

步骤2:ReadFile 函数收集IRP信息,就像在同步的那部分一样。

步骤3:将IRP信息传递给Windows内核。

步骤4:Windows将IRP添加到硬盘的IRP队列中。

步骤5, 6, 7:不同于同步操作中的流程,这个时候线程并没有被block住,线程立刻沿原路返回到了 ReadAsync 方法中。

这个时候,很明显IRP并没有被真实的硬件所处理,所以返回的数据其实并不是读取到的文件字节流,返回类型为一个 Task 对象。通过这个对象,你可以调用 ContinueWith 方法来注册一个回调函数来处理I/O真正被执行完后的数据,亦即读取到的文件内容,或者是中途出现的异常等等。或者你可以使用C# 5.0的一些 async/await 语法,通过这些语法你就可以像写同步代码那样来写异步方法。

步骤a → b → c:当硬件真正的完成了IRP的处理(a)后,它将会把完成的IRP放到CLR线程池中(b)。未来的某个时刻,线程池将会释放完成的IRP信息并且执行相应的代码来完成task,返回结果或者抛出一个异常信息等等。这个步骤结束后,你便可以使用真正的I/O结果了。

通过这种方式,当再有多个请求到来时,线程池不会创建多个线程来处理这些请求,相反的它只会维护一个线程。系统资源的利用率得到大幅提升,垃圾回收也会变得非常快(得益于更少的线程存在于进程中),最终上下文切换的问题也不复存在了。当然,你若使用多核的机器,线程池自然也会创建多个线程来处理大量的请求,但依然没有上下文切换的问题,这一切线程池都会做到最大程度的优化。

所以,当我们有10个同类型(比如都需要读取文件内容)的I/O请求到来时,假设每一个请求耗时5s,用同步的方式来执行就会花去50s的时间,用异步的方式则只会花去5s的时间,异步执行的时间遵循木桶原理,它等于耗时最多的操作所需要的时间。

如果你的应用程序是GUI类型的,那么用异步I/O无疑是最用户友好的。事实上Silverlight和Windows Store app必须使用异步I/O,这是框架所约束的,因为平时所使用的同步API它们均不提供。这帮助开发者养成良好的习惯,避免将GUI线程block住使得用户界面失去响应。

(本文完)