[第五节]接收I/O请求的完成通知

此时你已经了解如何产生一个异步设备I/O请求队列,现在讨论设备驱动在I/O请求完成之后是如何通知你的。

Windows提供四种不同的方法(表2-9是简短描述)来接收I/O完成通知,这一节覆盖了这些内容。这些方法按复杂程度排序,从易于理解和实现(触发设备内核对象)到难于理解和实现(I/O完成端口)。

技术 特点
设备内核对象 不能用于对单个设备的多重并发I/O请求。允许一个线程产生I/O请求,另一个线程进行处理。
事件内核对象 允许针对单个设备的多重并发I/O请求。允许一个线程产生I/O请求,另一个线程进行处理。
通知式I/O 允许针对单个设备的多重并发I/O请求。I/O请求必须由产生它的线程处理。
完成端口 允许针对单个设备的多重并发I/O请求。允许由不同的线程产生和处理I/O请求。该技术具有高度可伸缩性和最大的灵活性。

正如在本章开始处所说,I/O完成端口是眼下接收I/O完成通知的最好方法。通过学习以上四个方法,将会了解微软为什么要将I/O完成端口加入到Windows,以及I/O完成端口是如何解决其他方法所存在的问题。

设备内核对象

在线程产生一个异步I/O请求后,线程会继续运行,做其他工作。但最终,线程需要在I/O操作完成后与之同步。换句话说,在线程代码中一定有某一点,在没有完全将来自设备的数据装载到本地缓存之前,无法继续运行。
Windows中的设备内核对象可用于线程同步,该对象可以是触发态(signaled)或非触发态(unsignaled)。ReadFile和WriteFile函数在进行I/O请求排队之前会将设备内核对象设置成非触发态。当设备驱动完成请求,驱动会将设备内核对象置成触发态。
线程可以调用WaitForSingleObject或WaitForMultipleObjects来检查异步I/O请求是否已经完成。下面是示例:

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bBuffer[100];
OVERLAPPED o = { 0 };

o.Offset = 345;

BOOL fReadDone = ReadFile(hfile, bBuffer, 100, NULL, &o);
DWORD dwError = GetLastError();

if (!fReadDone && (dwError == ERROR_IO_PENDING)) {
   // The I/O is being performed asynchronously; wait for it to complete
   WaitForSingleObject(hfile, INFINITE);
   fReadDone = TRUE;
}

if (fReadDone) {
   // o.Internal contains the I/O error
   // o.InternalHigh contains the number of bytes transferred
   // bBuffer contains the read data
} else {
   // An error occurred; see dwError
}

这段代码产生一个异步I/O请求,然后立即等待该请求完成,违背了异步I/O的初衷。显然不应该在现实中写出像这样的代码,但是这段代码描述一个重要的概念,对其总结如下:

  1. 设备必须使用FILE_FLAG_OVERLAPPED标志打开以进行异步I/O
  2. OVERLAPPED结构的Offset, OffsetHigh, hEvent成员必须初始化。在上面的例子中,除Offset外,它们都被置为0。Offset被置为345,这样ReadFile将从文件偏移346处开始读取数据。
  3. ReadFile的返回值保存在fReadDone中,用于表示是否I/O请求被同步执行。
  4. 如果I/O请求没有被同步执行,检查是否有错误产生或者是否I/O被异步执行。通过将GetLastError的结果与ERROR_IO_PENDING相比较,可以得到这个信息。
  5. 了等待数据,调用WaitForSingleObject,将设备内核对象的句柄传入。该函数将挂起线程直到内核对象被触发。设备驱动在完成I/O后会触发内核对象。WaitForSingleObject返回后,I/O完成了,因此,将置fReadDone为TRUE。
  6. 完成读取后,可以检查bBuffer中的数据,OVERLAPPED结构的Internal成员保存了错误代码,读取的字节数在InternalHigh成员中。
  7. 如果真的产生了错误,可以从dwError得到更多信息。

事件内核对象

刚才描述的接收I/O完成通知的方法相当简单和直接,但因为不支持多个I/O请求,因此也没有多大用处的。例如,无法在同一时间内针对单个文件进行多重异步操作,或者像现在的代码那样,在读取10字节的同时写入10字节。

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bBuffer[10];
OVERLAPPED oRead = { 0 };

oRead.Offset = 0;

ReadFile(hfile, bBuffer, 10, NULL, &oRead);

OVERLAPPED oWrite = { 0 };

oWrite.Offset = 10;

WriteFile(hfile, "Jeff", 5, NULL, &oWrite);
WaitForSingleObject(hfile, INFINITE);
// 不知道谁会先完成:读?写?全部?

该段代码无法使用等待设备被触发来进行同步,因为无论哪个操作完成,设备都会被触发。如果在调用WaitForSingleObject传入设备句柄,那么在其返回时,将无法确定究竟是读操作先完成,还是写操作先完成,或者两个都完成了。很明显,需要更好的方法来进行多重的、并发的异步I/O,以免陷入困境。

OVERLAPPED结构的最后一个成员hEvent,定义为一个事件内核对象。必须使用CreateEvent来创建该事件对象。在异步I/O完成后,设备驱动检查OVERLAPPED结构的hEvent成员是否为NULL,如果不是,驱动将调用SetEvent来触发事件。驱动也会像前一方法那样将设备对象置成触发态。但是,如果使用事件来检测何时完成设备操作,则不应该等待设备对象被触发,而应使用等待事件被触发。

如果要并发执行多个异步设备I/O请求,需要分别为每个请求创建一个事件对象,初始化每个请求的OVERLAPPED结构的hEvent成员,然后再调用ReadFile或WriteFile。在代码中需要与I/O请求的完成进行同步的地方,简单的调用WaitForMultiPleObjects即可,将与每个I/O请求的OVERLAPPED结构相关联的事件句柄作为参数传入即可。使用这种模式,可以很容易和可靠的使用同一设备对象进行并发的多重异步设备I/O。以下代码描述这个方法:

HANDLE hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
BYTE bBuffer[10];
OVERLAPPED oRead = { 0 };

oRead.Offset = 0;

oRead.hEvent = CreateEvent(...);

ReadFile(hfile, bBuffer, 10, NULL, &oRead);

OVERLAPPED oWrite = { 0 };

oWrite.Offset = 10;

oWrite.hEvent = CreateEvent(...);

WriteFile(hfile, "Jeff", 5, NULL, &oWrite);

HANDLE h[2];

h[0] = oRead.hEvent;

h[1] = oWrite.hEvent;

DWORD dw = WaitForMultipleObjects(2, h, FALSE, INFINITE);

switch (dw _ WAIT_OBJECT_0) {
   case 0:   // Read completed
      break;
   case 1:   // Write completed
      break;
}

这段代码很费了一些心思,而且也与现实的应用程序不太符合,不过它能够描述清楚上面的观点。现实中的典型情况是应用程序将使用循环来等待I/O完成。在每个I/O请求完成后,线程将执行想要的任务,对另一个异步I/O请求进行排队,然后继续下一个循环,等待下一个I/O请求完成。

GetOverlappedResult

回想当初微软不愿意公开OVERLAPPED结构的Internal和InternalHigh成员,意味着微软需要提供其它方法来获取传输的字节数和I/O错误代码。为了提供这些信息,微软创建了GetOverlappedResult函数:

BOOL GetOverlappedResult(
   HANDLE      hfile,
   OVERLAPPED* pOverlapped,
   PDWORD      pdwNumBytes,
   BOOL        fWait);

微软现在公开了Internal和InternalHigh成员,因此GetOverlappedResult并不十分有用。但是,在我开始学习异步I/O时,我决定对这个函数进行逆向工程以帮助深化我头脑中的概念。以下代码展示了GetOverlappedResult的内部实现细节:

BOOL GetOverlappedResult(
   HANDLE hfile,
   OVERLAPPED* po,
   PDWORD pdwNumBytes,
   BOOL fWait) {

   if (po->Internal == STATUS_PENDING) {
      DWORD dwWaitRet = WAIT_TIMEOUT;

      if (fWait) {
         // Wait for the I/O to complete
         dwWaitRet = WaitForSingleObject(
            (po->hEvent != NULL) ? po->hEvent : hfile, INFINITE);
      }

      if (dwWaitRet == WAIT_TIMEOUT) {
         // I/O not complete and we're not supposed to wait
         SetLastError(ERROR_IO_INCOMPLETE);
         return(FALSE);
      }

      if (dwWaitRet != WAIT_OBJECT_0) {
         // Error calling WaitForSingleObject
         return(FALSE);
      }
   }

   // I/O is complete; return number of bytes transferred
   *pdwNumBytes = po->InternalHigh;

   if (SUCCEEDED(po->Internal)) {
      return(TRUE);   // No I/O error
   }
   
   // Set last error to I/O error
   SetLastError(po->Internal);
   return(FALSE);
}

通知式I/O

第三个用于接收I/O完成通知的方法称为通知式I/O。微软宣称通知式I/O是创建高性能,可伸缩应用的不二之选。但是一旦开发者开始使用警告式I/O,很快就会发现其言不符实。

我稍微使用了一下通知式I/O,然后就成为告诉你的第一人:通知式I/O超烂并且应该避免使用。不过,为了使警告式I/O能够干活,微软在操作系统中加了一些架构性的东西,我发现非常有利用价值。当你阅读本节时,注意应该全神贯注于这些架构而不要被I/O方面的东西搞昏。

当线程被创建时,系统就会创建一个与该线程关联的队列,该队列称为异步处理调用(APC)队列。当I/O请求产生时,设备驱动被告知需要在APC队列中添加一项。要将I/O完成通知添加到线程的APC队列中,需要调用ReadFileEx和WriteFileEx函数。

BOOL ReadFileEx(
   HANDLE      hfile,
   PVOID       pvBuffer,
   DWORD       nNumBytesToRead,
   OVERLAPPED* pOverlapped,
   LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);
BOOL WriteFileEx(
   HANDLE      hfile,
   CONST VOID  *pvBuffer,
   DWORD       nNumBytesToWrite,
   OVERLAPPED* pOverlapped,
   LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);

类似于ReadFile和WriteFile,ReadFileEx和WriteFileEx向设备驱动发布I/O请求,然后立即返回。ReadFileEx和WriteFileEx具有与ReadFile和WriteFile一样的参数,不过多加了个异常。首先,Ex后缀的函数不会传入一个DWORD指针,用来填充传输后的字节数;该信息仅能通过回调得到。其次,Ex后缀的函数要求传入被称为“完成例程(completion routine)”的回调函数的地址。该回调函数的原型如下:

VOID WINAPI CompletionRoutine(
   DWORD       dwError,
   DWORD       dwNumBytes,
   OVERLAPPED* po);

在使用ReadFileEx和WriteFileEx发布异步I/O请求时,它们将回调函数的地址传递给设备驱动。在设备驱动完成I/O请求后,会在线程的APC队列中添加一项。该项包含了“完成例程”的地址和初始化该I/O请求的OVERLAPPED结构的地址。


注意

顺便说一下,当一个警告式I/O完成时,设备驱动不会尝试触发事件对象。事实上,设备驱动根据不会用到OERVERLAPPED结构的hEvent成员!因此,可以将hEvent成员用于你自己的意图。

当线程处于可警告(alertable)状态(很快将会讨论),系统会检测它的APC队列,并且,对于队列中每一项,系统都会调用完成例程,并传入I/O错误代码,传输的字节数,以及OVERLAPPED结构的地址。注意,错误代码和传输的字节数也可以由OVERLAPPED结构的Internal和InternalHigh成员得到。(如先前所说,微软最开始不想公开它们,因此将它们做为参数传入完成例程)

先不管完成例程。先来看看系统是如何管理异步I/O请求的。以下代码产生包含三个不同的异步操作的队列:

hfile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
ReadFileEx(hfile, ...);    // Perform first ReadFileEx
WriteFileEx(hfile, ...);   // Perform first WriteFileEx
ReadFileEx(hfile, ...);    // Perform second ReadFileEx

SomeFunc();

如果SomeFunc执行比较耗时,则系统会在SomeFunc调用返回之前就完成了三个操作。当线程正在执行SomeFunc函数时,设备驱动正在将已完成的I/O请求添加到线程的APC队列中。APC队列可能有点像下面这样:

first WriteFileEx completed
second ReadFileEx completed
first ReadFileEx completed

APC队列由系统内部进行维护。从上面的列表可以注意到,系统可能会以任意的顺序来处理I/O请求队列,有可能最后产生的反而最先完成。线程APC队列的每一项都包含了回调函数的地址以及传递给该函数的值。

I/O请求完成后,仅仅被加入到线程的APC队列中,回调函数并不会立即被调用,因为此时线程可能正忙于做其他的事情且不能被中断。要处理APC队列,线程必须将自己置为可警告状态。这意味着线程中某处在执行过程中可以被中断。Windows提供了5个可以将线程置为可警告状态的函数:

DWORD SleepEx(
   DWORD dwTimeout,
   BOOL  fAlertable);
DWORD WaitForSingleObjectEx(
   HANDLE hObject,
   DWORD  dwTimeout,
   BOOL   fAlertable);
DWORD WaitForMultipleObjectsEx(
   DWORD   cObjects,
   PHANDLE phObjects,
   BOOL    fWaitAll,
   DWORD   dwTimeout,
   BOOL    fAlertable);
BOOL SignalObjectAndWait(
   HANDLE hObjectToSignal,
   HANDLE hObjectToWaitOn,
   DWORD  dwMilliseconds,
   BOOL   fAlertable);
DWORD MsgWaitForMultipleObjectsEx(
   DWORD   nCount,
   PHANDLE pHandles,
   DWORD   dwMilliseconds,
   DWORD   dwWakeMask,
   DWORD   dwFlags);

前四个函数的最后一个参数都是布尔值,用于指示调用线程是否要将自己置为可警告状态。对于MsgWaitForMultipleObjectsEx,必须使用MWMO_ALERTABLE标志来使线程进入可警告状态。如果熟悉Sleep,WaitForSingleObject, WaitForMultipleObjects,会毫不惊讶的发现,没有Ex后缀的函数内部正是以将fAlertable参数置为FALSE的方式来调用Ex后缀的函数的。

如果调用上面5个函数之一将线程置于可警告状态,系统首先将检查线程的APC队列。如果队列中至少有一项,则系统是不会将线程休眠的,而是将APC队列中取出一项,然后调用回调函数,将已经完成的I/O请求的错误代码,传递的字节数,以及OVERLAPPED结构的地址传入。在回调函数返回后,系统接着检查APC队列中是否还有其他项,如果有,就接着处理。如果没有,线程对于警告式函数(上面的函数)的调用将会返回。要注意如果在调用上面的任一个函数时,线程的APC队列中存在任何项,线程将绝不会休眠!

这些函数仅会在线程的APC队列中没有项时挂起。在线程被挂起后,会在等待的内核对象(或对象)被触发或在线程的APC队列中有项时被唤醒。自线程处于可警告状态起,一旦APC队列有一项,系统将唤醒线程并清空队列(通过调用完成例程)。然后这些函数将立即返回给调用者--线程,而不是回头继续等待内核对象被触发。

这5个函数的返回值指示返回原因。如果返回值是WAIT_IO_COMPLETION,表示线程将继续运行,因为APC队列中至少有一个项已经被处理。如果返回其他值,表示线程被唤醒,因为休眠周期到了,或指定的内核对象(或对象)被触发了,或互斥变量被丢弃了。


已注销
6 声望2 粉丝

A babysitter.