《Windows核心编程系列》十谈谈同步设备IO与异步设备IO之异步IO

   同步设备IO与异步设备IO之异步IO介绍

 

     设备IO与cpu速度甚至是内存访问相比较都是比较慢的,而且更不可预测。虽然如此,通过使用异步设备IO我们仍然能够创造出更高效的程序。

 

     同步IO时,发出IO请求的线程会被挂起。而异步IO时发出请求的线程不会被挂起,而是可以继续执行。异步IO请求传给了设备驱动程序,被加入到驱动程序的请求队列中,驱动程序负责实际的IO操作。当设备驱动程序完成了对队列中IO请求的处理,无论成功与否都必须通知应用程序。

 

     异步IO非常关键的一点就是将IO请求加入驱动程序队列,这是设计高性能可伸缩应用程序的本质所在。

 

     为了以异步的方式来访问设备,首先在调用CreateFile时,必须传给dwFlagsAndAttributes参数FILE_FLAG_OVERLAPPED标志。这个标志告诉系统我们想要以异步的方式来访问设备。

 

     其次,还需要将IO请求加入设备驱动程序的队列。我们需要ReadFile和WriteFile两个函数。这两个函数会检查hFile参数标识的设备是否FILE_FLAG_OVERLAPPED标志打开的。如果指定了这个标志,那么函数会执行异步设备IO。此时,可以传NULL给CreateFile和WriteFile的pdwNumBytes参数,因为它们会立即返回,检查pdwNumBytes是没有意义的。

 

    再者,必须在pOverlapped参数总传入一个已初始化的OVERLAPPED结构。该结构定义如下:

    

[cpp] view plain copy
 
  1. typedef struct _OVERLAPPED  
  2.   
  3. {  
  4.   
  5.    DWORD Internal;//错误代码。  
  6.   
  7.    DWORD InternalHigh;//已传输字节数。  
  8.   
  9.    DWORD Offset;//低32位文件偏移。  
  10.   
  11.    DWORD OffsetHigh;//高32位文件偏移。  
  12.   
  13.    HANDLE hEvent;//事件对象句柄。  
  14.   
  15. }OVERLAPPED,*LPOVERLAPPED;  


 

  Offset,OffsetHigh,hEvent必须在调用ReadFile和WriteFile前初始化,Internal和InternalHigh有驱动程序设置。

Offset和OffsetHigh共同构成一个64位的偏移量。它表示在访问文件时应该从何处访问设备。

 

之所以会在此处提供偏移量是因为:在执行异步操作时系统会忽略文件指针。如果不再此处指定,那么系统将无法知道下次应该从哪里开始读取数据。为了防止在同一个对象上进行多个异步调用时出现混淆,所有的异步调用IO请求都必须在OVERLAPPED结构中指定起始偏移量。

 

    非文件设备必须为这两个成员都指定为0,否则IO请求将会失败。GetLastError返回ERROR_INVALID_PARAMETER。

 

    hEvent标识一个事件内核对象句柄。用来接收IO完成通知的4种方法(后面会有介绍)的最后一种:使用IO完成端口会使用到这个成员。

 

     Internal成员用来保存已处理的IO请求的错误码。一旦我们发出一个异步IO请求,设备驱动程序会立即将Internal设为STATUS_PENDING,表示没有错误。通过检查此值我们可以使用HasOverlappedIoCompeleted宏检查异步IO请求是否已经完成。

 

该宏定义为:

 

 

[cpp] view plain copy
 
  1. #define HasOverlappedIoCompeleted(pOverlapped)  
  2.   
  3.   ((pOverlapped)->Internal!=STATUS_PENDING)  


 

    InternalHigh用来保存已传输的字节数。

     我们可以创建一个派生自OVERLAPPED结构的C++类,来扩展OVERLAPPED结构。如为派生类添加成员变量等。

 

异步设备IO注意事项:

 

     一:设备驱动程序队列的异步设备IO请求不一定是以先入先出方式处理的。后被加入的请求也有可能先执行。

 

[cpp] view plain copy
 
  1. OVERLAPPED o1={0};  
  2.   
  3. OVERLAPPED o2={0};  
  4.   
  5. BYTE buff[1024];  
  6.   
  7. ReadFile(hFile,buff,100,NULL,&o1);  
  8.   
  9. WriteFile(hFile,buff,100,NULL,&o2);  


 

     二:我们以异步IO方式将IO请求添加到驱动程序队列中时,驱动程序会选择以同步的方式来处理请求。当我们从文件中读取数据时,系统会检查我们想要的数据是否在系统缓存中。如果数据已经在缓存中,系统就不会将我们的异步IO请求添加到设备驱动程序队列中。

 

    如果请求的IO操作是以同步方式执行的,那么ReadFile和WriteFile会返回非零值。如果请求的IO操作是以异步方式执行的或者在调用函数时出现错误,函数会返回false。必须调用GetLastError来检查到底发生了什么事。如果GetLastError 返回ERROR_IO_PENDING,那么IO请求已经被就加入到了队列。其他值则说明IO请求没有被加入到驱动程序队列中。此时GetLastError会返回一下几个错误码:

 

     ERROR_INVALID_USER_BUFFER或ERROR_NOT_ENOUGH_MEMORY.这两个错误码表示驱动程序请求队列已满,无法添加。

 

     三:在异步IO请求完成之前,一定不能移动或是销毁发出IO请求所使用的数据缓存和OVERLAPPED结构。由于传给CreateFile和WriteFile的是OVERLAPPED结构的地址,当系统将IO请求加入设备驱动程序会将地址传给驱动程序。否则将会导致内存访问违规。

 

如:

 

 

[cpp] view plain copy
 
  1. VOID ReadData(HANDLE hFile)  
  2.   
  3. {  
  4.   
  5.    OVERLAPPED o={0};  
  6.   
  7.    BYTE buff[100];  
  8.   
  9.    ReadFile(hFile,buff,100,NULL,&o);  
  10.   
  11. }  


 

    由于OVERLAPPED和buff是从栈中分配的,函数执行完毕栈被平衡,但异步IO请求可能还未执行完毕。此时再访问栈空间很容易导致访问违规。避免这种情况的方法可以从堆中分配内存。

有时候我们想要在设备驱动程序对一个已经加入加入队列的设备IO请求进行处理之前将其取消。

Windows为我们提供了多种方式:

 

一:调用CancelIo。该函数取消对该文件句柄的所有等待的I/O操作。 

 

 

[cpp] view plain copy
 
  1. BOOL CancelIo(HANDLE hFile);  


 

    也可以关闭设备句柄,来取消所有已经添加到队列中的所有IO请求。

 

     二:线程终止时,系统会自动取消该线程发出的所有IO请求。但如果请求的句柄具有与之相关联的IO完成端口,那么不在被取消之列。

 

     三:CancelIoEx

 

 

[cpp] view plain copy
 
  1. BOOL CancelIoEx(HANDLE hFile,LPOVERLAPPED pOverlapped);  


 

     CancelIoEx能够取消给定文件句柄的一个指定IO请求。它会将hFile设备待处理的IO请求中所有与pOverlapped相关联的请求都标记为已经取消。由于每个待处理的IO请求都应该有自己的OVERLAPPED结构,因此每次调用CancelIoEx只取消一个待处理的请求。如果pOverlapped为NULL,那么CancelIoEx会将hFile指定的设备的所有待处理IO请求都取消掉。

 

    被取消的IO请求会返回错误码ERROR_OPERATION_ABORTED。

 

接收IO请求完成通知。

 

 

    现在我们已经知道如何将异步设备IO添加到驱动程序队列中,但是我们还没有介绍驱动程序如何通知我们IO请求已经完成。

 

Windows提供了4中方式来接收IO请求已经完成的通知。

 

    一:触发设备内核对象。对向一个设备同时发出多个IO请求时,这种方法无用。

 

    二:触发事件内核对象。

 

    三:使用可提醒IO。

 

    四:使用IO完成端口。

 

 

 

一:触发设备内核对象。

 

    线程发出一个异步IO请求后,将继续执行。但即使如此,线程还需要在一点上等待设备内核对象被触发。

 

    ReadFile和WriteFile函数在将IO请求添加到队列之前,会将设备内核对象设为非触发状态。当设备驱动程序完成了请求,驱动程序将设备内核对象设为触发状态。线程可以通过调用WaitForSingleObject或WaitForMultipleOBjecs来检查一个异步IO请求是否已经完成。

 

 

[cpp] view plain copy
 
  1. HANDLE hFile=CreateFile(...,FILE_FLAG_OVERLAPPED,...);  
  2.   
  3. BYTE buff[1000];  
  4.   
  5. OVERLAPPED o={0};  
  6.   
  7. o.Offset=345;  
  8.   
  9.   
  10. BOOL bReadDone=ReadFile(hFile,bBuff,100,NULL,&o);//异步则返回false.  
  11.   
  12. DWORD ret=GetLastError();//异步则返回ERROR_IO_PENDIN。  
  13.   
  14. if(!bReadDone&&(ret==ERROR_IO_PENDING))//以异步IO执行。  
  15.   
  16. {  
  17.   
  18.    WaitForSingleObject(hFile,INFINITE);  
  19.   
  20.  }  


 

    上述代码中首先发出了一个异步设备IO请求,然后立即等待请求完成。这就与同步设备IO请求无异,枉费了异步设备IO的设计意图。实际上并不怎么有用。如果向一个设备同时发出多个IO请求,这种方法是不行的。

 

二:触发事件内核对象

 

     OVERLAPPED结构的最后一个成员hEvent来标识一个事件内核对象。我们需要调用CreateEvent来创建这个内核对象。当一个异步IO请求完成的时候,设备驱动程序会检查OVERLAPPED结构的hEvent成员是否为NULL。如果hEvent不为NULL,那么驱动程序会调用SetEvent来触发事件。为了检查异步IO是否完成可以等待事件内核对象被触发。

 

    如果同时执行多个IO请求,就需要创建不同的事件对象,并初始化每个请求的OVERLAPPED结构中的hEvent成员,然后再调用ReadFile或WriteFile。

 

三:可提醒IO

 

    虽然Windows花费数年开发出来的第三种用来接收IO完成通知的方法--可提醒IO。但是它非常的糟糕,但是辅助可提醒IO的基础设施还是非常有用的。应把主要精力放在这些基础设施上,不要纠缠与IO有关的方面。

   

    当系统创建线程时会同时创建一个与线程相关联的队列。这个队列被称为异步过程调用队列(Asynchronous procedure call)。

 

     当线程发出一个IO请求时,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。这需要调用一下两个函数:

 

 

[cpp] view plain copy
 
  1. BOOL ReadFileEx(  
  2.   
  3.       HANDLE hFile,  
  4.   
  5.       PVOID pvBuffer,  
  6.   
  7.       DWORD nNumBytesToRead,  
  8.   
  9.       OVERLAPPED*pOverlapped,  
  10.   
  11.       LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);  
  12.   
  13. BOOL WriteFileEx(  
  14.   
  15.       HANDLE hFile,  
  16.   
  17.       PVOID pvBuffer,  
  18.   
  19.       DWORD nNumBytesToRead,  
  20.   
  21.       OVERLAPPED*pOverlapped,  
  22.   
  23.       LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);  


 

    它们在将IO请求发给设备驱动程序后会立即返回。*Ex要求我们传入一个函数地址,这个回调函数被称为完成函数。

 

 

[cpp] view plain copy
 
  1. VOID WINAPI CompletionRoutine(  
  2.   
  3.      DWORD dwError,  
  4.   
  5.      DWORD dwNumBytes,  
  6.   
  7.      OVERLAPPED*po);  


 

    当我们用ReadFileEx和WriteFileEx发出一个IO请求时,这两个函数会将回调函数地址传给设备驱动程序。当设备驱动程序完成IO请求时,会在发出IO请求的线程的APC队列中添加一项。该项包括完成函数地址,以及OVERLAPPED结构地址。

 

     回调函数并不会立即被调用。为了对线程APC队列中的项进行处理,线程必须将自己置为可提醒状态。Windows提供了六个函数可以将线程置为可提醒状态。

 

 

[cpp] view plain copy
 
  1. DWORD SleepEx(  
  2.   
  3.      DWORD dwMilliSeconds,  
  4.   
  5.      BOOL bAlertable);  
  6.   
  7. DWORD WaitForSingleObjectEx(  
  8.   
  9.      HANDLE hObject,  
  10.   
  11.      CONST HANDLE*phObjects,  
  12.   
  13.      BOOL bWaitAll,  
  14.   
  15.      BOOL dwMilliseconds,  
  16.   
  17.      BOOL bAlertable);  
  18.   
  19. BOOL GetQueuedCompletionStatusEx(  
  20.   
  21.      HANDLE hCompPort,  
  22.   
  23.      LPOVERLAPPED_ENTRY pCompPortEntries,  
  24.   
  25.      DWORD dwMilliSeconds,  
  26.   
  27.      BOOL aAlertable);  
  28.   
  29. BOOL GetQueuedCompletionStatusEx(  
  30.   
  31.      HANDLE hCompPort,  
  32.   
  33.      LPOVERlAPPEDENTRY pCompPortEntries,  
  34.   
  35.      ULONG ulCount,  
  36.   
  37.      PULONG pulNumNumEntriesRemoved,  
  38.   
  39.      BOOL bAlertable);  
  40.   
  41. DWORD MsgWaitForMultipleObjectEx(  
  42.   
  43.      DWORD nCount,  
  44.   
  45.      CONST HANDLE *pHandles,  
  46.   
  47.      DWORD dwMilliseconds,  
  48.   
  49.      DWORD dwWakeMask,  
  50.   
  51.      DWORD dwFlags);  


 

    前五个函数的最后一个参数是布尔变量,用来表示调用线程是否应该将自己置为可提醒状态。对最后一个函数MsgWaitForMultipleObjectEx来说,需要使用MWMO_ALERTABLE标志来让线程进入可提醒状态。

 

    当线程由于调用上述六个函数即将挂起并处于可提醒状态时,系统会检查它的APC队列,对队列中的每一项系统会调用完成函数,并传给该函数IO错误码(OVERLAPPED结构的Internal成员),已传输字节数

(OVERLAPPED的InternalHigh成员)以及OVERLAPPED结构地址。

 

    当线程调用上述任何一个函数等待一个内核对象被触发并将线程置为可提醒状态时,系统会首先检查线程的APC队列。如果APC队列有项,那么系统不会让线程进入睡眠状态。系统会将APC队列中的项取出,让线程调用回调函数,并传入相应参数。直到所有的项都被处理,此时等待函数(上面介绍的6个函数)才会让线程挂起。当APC队列中没有项时,线程将会被挂起直到等待传入的内核对象被触发。一旦APC出现新项,线程会立即被唤醒。因为线程处于可提醒状态,可以随时被唤醒。

 

综上我们可以知道,导致线程被唤醒有三种情况:

 

    1:等待的内核对象被触发。

 

    2:APC队列出现一项。

 

    3:超出了等待时间。

 

可提醒IO的优劣:

 

 

    可提醒IO要求我们必须创建一个回调函数,这使得代码变得很复杂。我们必须在全局空间中加入大量信息。

 

     发出IO请求的线程,必须同时对完成通知进行处理。这使得程序的伸缩性不太好。

基于以上两个问题,作者不推荐使用可提醒IO来接收IO完成通知。

Windows提供一个函数允许手动的添加项到APC队列中

 

 

[cpp] view plain copy
 
  1. DWORD QueueUserAPC(  
  2.   
  3.      PAPCFUNC pfnAPC,  
  4.   
  5.      HANDLE hThread,  
  6.   
  7.      ULONG_PTR dwData);  


 

     pfnAPC是一个指向APC函数指针。它必须符合一下原型:

    

[cpp] view plain copy
 
  1. VOID WINAPI APCFUNC(ULONG_PTR dwParam);  


 

    hThread是线程句柄,用来告诉系统想要将项添加到那个线程队列

 

    dwData是传给回调函数的参数。

 

    我们可以使用QueueUserAPC来进行非常高效的线程间通信,甚至能够跨进程通信。我们可以手动的向另一线程的APC队列中添加APC 调用项,并传入参数,实现线程间通信。

 

    QueueUserAPC还可以强制线程退出等待状态。当线程一由于调用WaitForSingleObjectEx,并将其置为可提醒状态时。主线程可以调用QueueUserAPC将一个APC项添加到线程一的队列中。此时线程就被唤醒,并调用APCFunc函数。当APC队列中没有项被处理时WaitForSingleObjectEx函数返回。返回值为WAIT_IO_COMPLETIO。线程一需要检查这个返回值才知道自己应该退出。

 

 

[cpp] view plain copy
 
  1. VOID WINAPI APCFunc(ULONG_PTR dwParam)  
  2.   
  3. {}  
  4.   
  5. UINT WINAPI ThreadFunc(PVOID pvParam)  
  6.   
  7. {  
  8.   
  9.     HANDLE hEvent=(HANDLE)pvParam;  
  10.   
  11.   DWORD dw=WaitForSingleObjectEx(hEvent,INFINITE,true);  
  12.   
  13.    if(dw==WAIT_OBJEC_0)//事件内核对象被触发。  
  14.    {  
  15.   
  16.    }  
  17.   
  18.     if(dw==WAIT_IO_COMPLETION)//线程退出。  
  19.     {  
  20.   
  21.       return 0;  
  22.   
  23.     }  
  24.   
  25. }  


 

        下一节将会介绍Windows完成端口。 
原文地址:https://www.cnblogs.com/weekbo/p/9054509.html