探索Windows:同步设备IO与异步设备IO
让我们与星光探索者一起,探索Windows吧!
线程是我们最好的工具,可以用来对工作进行划分。然而当线程发出一个异步设备I/O请求时,他被临时挂起,知道设备完成I/O请求为止。此类挂起会损害性能。为了不让线程闲下来,我们需要让各个线程就他们正在执行的操作相互通信。Microsoft开发出了一个非常好的机制进行这类通信–I/O完成端口(I/O completion port)他可以帮助我们创建高性能而且伸缩性好的应用程序。通过使用I/O完成端口,我们可以让线程在读取设备和写入设备时不必等待设备的响应,从而显著提高吞吐量。
打开和关闭设备
以下为常见的可称为设备的东西,一般设备使用CreateFile打开,CloseHandle关闭。这些函数都返回一个标识设备的句柄,我们可以将句柄传给许多函数与设备进行通信,如调用SetCommConfig设置串口的波特率,SetMailslotInfo在等待读取数据时,设置超时值。如果我们想知道设备的类型,可通过调用GetFileType函数获得
设备 | 常见用途 |
---|---|
文件 | 永久储存任何数据 |
目录 | 属性和文件压缩的设置 |
逻辑磁盘驱动器 | 格式化驱动器 |
物理磁盘驱动器 | 访问分区表 |
串口 | 通过电话线传输数据 |
并口 | 将数据传输到打印机 |
邮箱槽 | 一对多数据传输,通常是通过网络传到另一台运行Windows的机器上 |
命名管道 | 一对一数据传输,通常是通过网络传到另一台运行Windows的机器上 |
匿名管道 | 单机上一对一数据传输,绝对不会跨网络 |
套接字 | 报文或数据流的传输,通常是通过网络传到任何支持套接字的机器上,机器不一定要运行Windows操作系统 |
控制台 | 文本窗口的屏幕缓存 |
以下是打开各种设备的函数
设备 | 用于打开设备的函数 |
---|---|
文件 | CreateFile(lpFileName为路径名或UNC路径名) |
目录 | CreateFile(lpFileName为路径名或UNC路径名),在调用时指定FILE_FLAG_BACKUP_SEMATICS标志,Windows允许我们打开一个目录。打开目录可以使我们更改他的属性和时间戳 |
逻辑磁盘驱动器 | CreateFile(lpFileName为“\.{x}:”的形式),{x}为驱动器的盘符。打开驱动器可以使我们能格式化驱动器或检测驱动器媒介大小 |
物理磁盘驱动器 | CreateFile(lpFileName为“\.\PHYSICALDRIV{x}”的形式),此时{x}为物理驱动器号,这样windows允许我们打开一个物理驱动器。如为了读写用户的第一个物理驱动器的扇区,我们可指定”.\PHYSICALDRIV0”。如果错误写入数据,会导致操作系统无法访问磁盘的内容 |
串口 | CreateFile(lpFileName为“COMx”) |
并口 | CreateFile(lpFileName为“LPTx”) |
邮件槽服务器 | CreateMailslot(lpFileName为“\\.\mailslot\mailslotname”) |
邮件槽客户端 | CreateFile(lpName为 “\\servername\mailslot\mailslotname”) |
命名管道服务器 | CreateNamedFile(lpName为“\.\pipe\pipename”) |
命名管道客户端 | CreateFile(lpFileName为”\\servername\pipe\pipename”) |
匿名管道 | CreatePipe创建服务端或客户端 |
套接字 | socket,accept或AcceptEx |
控制台 | CreateConsoleScreenBuffer或GetStdHandle |
下面深入探讨CreateFile函数。CreateFile不仅可以创建和打开磁盘上的文件,还可以打开其他设备。
1 | HANDLE CreateFile( |
lpFileName为文件路径,文件路径长度不能超过MAX_PATH(260)。如果调用的是Unicode版本,可在路径名加“\\?\”前缀,我们可以超越这个限制,但此时传入的路径不再能是相对路径。更多详细请看官网解释。
使用文件设备
Windows的设计允许我们处理非常大的文件。Microsoft最初设计者选择了64位值表示文件大小,这意味着理论上一个文件最大可以达到16EB。在32位操作系统处理64位值不太方便,因为Windows函数要求我们将一个64为的值分成两个32位值传入。但处理这个并不难。
我们可调用GetFileSizeEx 函数获得文件的逻辑大小,GetCompressedFileSize函数获得文件的物理大小,SetFilePointerEx设置文件指针,调用SetEndOfFile 函数设置文件尾
对于I/O操作时,我们可以进行同步设备IO也可以异步设备IO。下图为具体的说明
同步设备I/O
最方便最常用对设备数据进行读写的函数是ReadFile,WriteFile 。
1 | BOOL ReadFile( |
在使用同步I/O时,我们打开设备时不能指定FILE_FLAG_OVERLAPPED标志。否则系统认为我们想要与设备执行异步I/O。当然只有在用读模式打开设备才能读设备数据。写模式同理。
在CreateFile的时候,我们可以传一些标志来改变系统对设备数据进行缓存的方式。如果我们想要强制把缓存数据写入到设备,调用FlushFileBuffers 。当然,只有在用写模式打开的设备才会起作用。
1 | BOOL FlushFileBuffers(HANDLE hFile /*设备句柄*/); |
然而,在进行同步设备I/O时,线程由于正在等待CreateFile返回而被阻塞,那么窗口消息将无法得到处理,该线程创建的所有窗口都会停滞在那里。我们总是看到应用程序已停止响应,多半是因为等待同步I/O操作。因而为了创建响应性好的应用程序,我们应该尽可能执行异步操作。
不幸的是,某些Windows API没有提供任何方法来完成异步调用。我们可调用CancelSynchronousIo来取消正在进行的同步I/O操作。
异步设备I/O
与计算机执行的其他操作相比,IO是最慢不可预测的操作之一。CPU从文件中或跨网络读取数据的速度,以及CPU向文件或跨网络写入数据的速度,比他执行算数运算的速度,甚至比他绘制屏幕的速度都要慢很多。但是,使用异步设备I/O使我们能够更好的使用资源。
假设线程向设备发出异步I/O请求,这个请求被传给设备驱动程序,后者负责完成实际的I/O操作。当驱动程序在等待设备响应时,应用程序的线程并没有因为要等待I/O请求而挂起,而是继续执行其他任务。
为了以异步方式访问设备,我们在CreateFile时指定 FILE_FLAG_OVERAPPED 标志,这样就可以异步方式访问设备。对于异步方式访问设备,在ReadFile和WriteFile调用时,需要传第五个参数。lpOverlapped,即我们需要初始化一个OVERLAPPED 结构,他的Offset,OffsetHigh,hEvent必须初始化,默认可全部初始化为0。对于不同的I/O请求,OVERLAPPED结构要初始化不同的。
1 | typedef struct _OVERLAPPED { |
在执行异步I/O时,我们应意识到,设备驱动程序不必以先进先出的方式来处理I/O请求。不按顺序执行I/O在某些方面上会提高性能。当我们试图添加一个异步I/O请求时,设备驱动程序可能会选择以同步方式处理请求。如我们从文件中读取数据时,系统会检查我们想要的数据是否在系统缓存,这是就很可能发生这种情况。如果数据在缓存中,系统就不把这个I/O请求添加到设备驱动程序队列中了,而是将高速缓存中的数据复制过来。对于NTFS文件压缩,增大文件长度,向文件追加信息,通常驱动程序以同步方式操作。
最重要的一点,在进行I/O操作时,一定不能移动或释放发出I/O请求时使用的数据缓存和OVERLAPPED结构,否则内存可能会遭到破坏。例如下面的代码:
1 | void ReadData(HANDLE hFile) |
调用了ReadData时,似乎没什么问题,但是真的吗?由于这里调用ReadFile时,如果hFile句柄是用异步方式打开的,异步I/O请求加入队列之后,此函数就返回了。这就导致局部变量o,b都被释放,但设备驱动程序无法意识到。I/O完成之时,设备驱动程序就去修改线程栈中内存,从而破坏数据。
如果在调用ReadFile或WriteFile,同步方式执行,那么这两个函数返回值为非零值;如果异步方式执行,发生了错误,这两个函数返回FALSE。调用GetLastError来检查到底发生了什么,如果GetLastError返回ERROR_IO_PENDING,那么I/O请求已加入队列,晚些时候完成;返回ERROR_IO_PENDING以外的值,表明I/O请求无法添加到设备驱动队列。
取消队列中的I/O请求
我们可能想在设备驱动程序对一个已经加入队列的设备I/O请求进行处理之前将其取消,我们可以:
调用CancelIo取消给定句柄所标识的线程加入到队列中的所有I/O请求(除非该句柄具有与之相关联的I/O完成端口)
关闭设备句柄,不管是哪个线程添加的
当线程终止时,系统自动取消该线程发出的所有I/O请求,但如果请求被发往的设备句柄具有与之关联的I/O完成端口,那么它们不再被取消之列。
调用CancelIoEx取消一个指定的I/O请求,这个函数要求你传入lpOverlapped,也就是调用ReadFile或WriteFile时的参数。如果lpOverlapped为NULL,那么将取消指定设备所有待处理的I/O请求
接收I/O请求完成通知
Windows提供了下列4种方法来接收I/O请求已经完成的通知。
技术 | 简介 |
---|---|
触发设备内核对象 | 这种方法允许一个线程发出I/O请求,另一个线程处理结果(和之前探索的线程同步时做法有相似之处)。当向一个设备同时发出多个I/O请求时,这种方式没什么用 |
触发事件内核对象 | 这种方法允许一个线程发出I/O请求,另一个线程处理。这种方法允许我们像同一个涉笔同时发出多个I/O请求。 |
使用可提醒I/O | 发出I/O请求的线程必须对结果进行处理。这种方法允许我们向一个设备同时发出多个请求。 |
使用I/O完成端口 | 他允许一个线程发出I/O请求,另一个线程处理结果。这种技术具有高度伸缩性和最佳灵活性。这种方法允许我们向一个设备同时发出多个请求。 |
触发设备内核对象
虽然在执行异步I/O请求时线程可以干其他的,但是最终线程还是与I/O请求完毕进行同步。设备内核对象可以进行线程同步,在ReadFile和WriteFile函数将I/O请求添加到队列之前,设备内核对象处于未触发状态,请求完成时设备内核对象处于触发状态。看下放代码例子:
1 | // ... 处省去了一些参数 |
这段代码先发出了一个异步I/O请求,然后立即等待请求完成,异步I/O设计来可不是为了这么用的。但是这段代码有利于各位巩固I/O请求的相关内容。
触发事件内核对象
假如我们想在文件中读取10个字节并同时向文件写入10字节(读和写的位置不同),那么:
1 | HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); |
我们不能通过等待设备内核对象被触发来对线程进行同步,这是因为任何一个操作完成,内核对象就被触发。我们需要一个更好的方式来同时执行多个异步I/O操作。
OVERLAPPED结构的hEvent用来标识一个事件内核对象。我们必须通过CreateEvent来创建他。当一个异步I/O请求完成时,设备驱动程序会检查OVERLAPPED结构hEvent成员是否为NULL,如果不为NULL,驱动程序调用SetEvent触发事件。在“触发事件内核对象”这个技术中,我们就不该等待设备对象触发,而是等待事件对象。
为了略微提高性能,我们可以使Windows在操作完成时不要触发文件对象。调用SetFileCompletionNotificationModes(hFile, FILE_SKIP_SET_EVENT_ON_HANDLE)来完成我们的目的。
可提醒I/O
最初Microsoft开发人员吹捧说,可提醒I/O(或叫可警告I/O,alertable I/O)是创建高性能且伸缩性好的引用的最佳途径,后来事实证明这并不能达到Microsoft的承诺。
当系统创建一个线程时,会同时创建一个与线程相关的队列,即异步过程调用(asynchronous procedure call, APC)队列。当发出一个I/O请求时,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。为了将I/O完成通知添加到线程APC队列中,我们应该调用ReadFileEx或WriteFileEx
1 | BOOL ReadFileEx( |
与ReadFile和WriteFile相似,ReadFileEx和WriteFileEx在I/O请求发给设备驱动程序后,会立即返回。但是,这两个Ex结尾的函数没有一个指向DWORD的指针保存已传输的字节数,只有回调函数lpComplectionRoutine看得到。这个回调函数称为完成函数(complection routine)
1 | VOID WINAPI ComplectionRoutine( |
当我们用ReadFileEx和WriteFileEx发送I/O请求时,这两个函数会将回调函数的地址传给设备驱动程序。设备驱动程序完成I/O请求时,会在发出I/O请求时的APC队列中添加一项(并不一定按顺序添加),该项包含了完成函数地址和发I/O请求时OVERLAPPED地址。
当一个可提醒I/O完成时,设备驱动程序不会使用OVERLAPPED的hEvent成员,因此如果需要,我们可以将hEvent据为己用,这就是上方所说在hEvent存放C++对象地址原因。
当线程处于可提醒状态时,系统会检查他的APC队列,对队列中的每一项调用完成函数,并正确传入相关参数。我们通过调用SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectEx、SignalObjectAndWait、GetQueuedCompletionStatusEx、MsgWaitForMultipleObjectEx,将本线程设为可提醒状态。当调用这些函数时,系统会首先检查APC队列,如果队列中至少有一项,那线程不会进入睡眠状态,而是取出APC队列的那一项,调用回调函数。当回调函数返回时,就取出APC队列下一项如此类推。直至队列为空,然后可提醒函数返回。如果调用可提醒函数时,APC队列是空的,则线程才会将自己挂起,进入可提醒的睡眠状态,当APC队列出现一项或正在等待的那个内核对象被触发或超时,线程被唤醒,然后函数立即返回。
可提醒I/O的优劣:
回调函数:必须创建回调函数,使代码变复杂。而且回调函数不能带额外的信息,使用不得不大量使用全局变量,幸运的是这些回调函数是被同一线程调用的,所以不需要同步。
线程问题:发出I/O请求的线程必须同时对完成通知进行处理。可能使这个线程负载过大,而其他线程处于空闲状态却无事可做。
此外,我们可以调用QueueUserAPC 函数来手动添加一项到APC队列中。这个函数的第一个函数需要我们传入一个APC函数的地址,必须满足下面的函数原型:
1 | VOID WINAPI APCFunc(ULONG_PTR); |
上文提到了APC队列的一些性质,我们通常调用QueueUserAPC进行搞笑的线程间通信甚至能跨过进程界限,但遗憾的是我们只能传递一个值。我们也可以用来使线程强行退出等待状态,甚至还可以使一个线程杀死自己。
I/O完成端口
Windows设计目标是一个安全的健壮的操作系统。回顾历史,我们可以采用以下两种模型之一构架一个服务应用程序。
串行模式(serial model):一个线程等待一个用户(通常是通过网络)发出请求,当线程到达时,线程被唤醒并对客户请求进行处理
并发模式(concurrent model):一个线程等待一个客户请求,并创建一个新线程来处理请求。当新线程正在处理客户请求的时候,原来的线程进入下一次循环并等待另一个客户请求。当处理客户请求的线程完成整个处理过程时,该线程终止。
串行模型的问题在于他不能很好同时处理多个请求,因而并发模式极其受欢迎。Microsoft意识到并行模式下需要打造Windows一个出色的服务器环境,它设计出了I/O完成端口对象。