5. 用来接收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中,设备内核对象可以用来进行线程同步,因此对象既可能处于触发状态,也可能处于未触发状态.ReadFile和WriteFile函数在将I/O请求添加到队列之前,会先将设备内核对象设为未触发状态.当设备驱动程序完成了请求之后,驱动程序会将设备内核对象设为触发状态.
代码示例:
//创建一个以异步方式打开的文件.
HANDLE hFile = CreateFile( ...,FILE_FLAG_OVERLAPPED,... );
BYTE bBuffer[100];
OVERLAPPED o = {0};
o.Offset = 345;
//想要从文件的345个字节处开始读取数据
BOOL bReadDone = ReadFile( hFile,bBuffer,100,NULL,&o );
DWORD dwError = GetLastError();
if ( !bReadDone &&( dwError == ERROR_IO_PENDING ) )
{
//等待数据读取完毕..
WaitForSingleObject( hFile,INFINITE );
bReadDone = TRUE;
}
if( bReadDone )
{
//可以查看o的成员变量:
//Internal包含了I/O错误
//InternalHigh 包含传输的字节数
//bBuffer包含读取的数据
//...
}
else
{
//当错误发生,我们可以查看错误码
//...
}
总结:
◆ 设备必须使用FILE_FLAG_OVERLAPPED标志,以异步方式打开的
◆ 必须对OVERLAPPED结构的Offset,OffsetHigh和hEvent成员进行初始化.
◆ ReadFile的返回值保存在bReadDone中,他表示该I/O请求不是以同步方式完成的
◆ 如果该I/O请求不是以同步方式完成的,我们将GetLastError的返回值与ERROR_IO_PENDING进行比较来得到这一信息.
◆ 为了等待数据,代码调用了WaitForSignalObject并传入设备内核对象的句柄.当设备驱动程序完成I/O的时候,会触发该内核对象.
◆ 在读取完成之后,我们可以查看bBuffer中的数据,也可以查看保存在OVERLAPPED结构的Internal成员中的错误码.还可以查看保存在OVERLAPPED结构的InternalHigh成员中已传输的字节数.
◆ 如果真的发生错误,那么dwError中保存的错误码可以给出更多信息.
■ 触发事件内核对象
OVERLAPPED结构的最后一个成员hEvent用来标示一个事件内核对象,我们必须通过CreatEvent来创建这个对象.当异步I/O请求完成的时候,设备驱动程序会检查OVERLAPPED结构的hEvent成员是否为NULL.如果不为NULL,那么驱动程序会调用SetEvent来触发事件.驱动仍然会向原先一样,将设备对象设置为触发状态.但是如果我们使用时间来检查一个设备操作是否已经完成,那么我们就不应该待等待设备被触发,而应该等待的是事件对象.
另外,如果想要提高性能,我们可以告诉操作系统在操作完成的时候不要触发文件对象.这就要使用一个系统函数来完成:
BOOL SetFileCompletionNotificationMode(HANDLE hFile,UCHAR uFlags);
其中,hFile是文件句柄,
uFlag用来告诉我们希望对windows在I/O操作完成时的正常行为进行何种方式的定制.如果不想触发文件句柄,我们可以传入FILE_SKIP_SET_EVENT_ON_HANDLE标志.
如果想要同时执行多个异步设备I/O请求,我们必须为每个请求创建不同的事件对象,并初始化没有请求的OVERLAPPED结构中的hEvent成员,然后再调用ReadFile或WriteFile.再利用前面说道的WaitForMultipleObject方法来等待事件的到来.
在微软没有公开OVERLAPPED之前,可以利用GetOverlappedResult来获取他的一些信息.
BOOL GetOvelappedResult(
HANDLE hFile,
OVERLAPPED* pOverlapped,
PDWORD pdwNumBytes,
BOOL bWait);
其内部原理大致如下:
BOOL GetOverlappedResult( HANDLE hFile,
LPOVERLAPPED lpOverlapped,
LPDWORD lpNumberOfBytesTransferred,
BOOL bWait )
{
if ( lpOverlapped->Internal == STATUS_PENDING )
{
DWORD dwWaitRet = WAIT_TIMEOUT;
if ( bWait )
{
dwWaitRet = WaitForSingleObject( (lpOverlapped->hEvent != NULL)?lpOverlapped->hEvent:hFile,INFINITE );
}
//等待超时
if ( dwWaitRet == WAIT_TIMEOUT )
{
SetLastError( ERROR_IO_INCOMPLETE );
return FLASE;
}
//没有成功
if ( dwWaitRet != WAIT_OBJECT_0 )
{
return FALSE;
}
}
*lpNumberOfBytesTransferred = lpOverlapped->InternalHigh;
if ( SUCCEEDED( lpOverlapped->Internal ) )
{
return TRUE;
}
//设置错误码
SetLastError( lpOverlapped->Internal );
return FALSE;
}
■ 可提醒I/O
当系统创建一个线程的时候,会同时创建一个与线程相关联的队列.这个队列被称为异步过程调用队列(APC).当发出一个I/O请求的时候,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项.这需要使用另外两个函数来完成此操作:
◆ BOOL ReadFileEx(
HANDLE hFile,
PVOID pvBuffer,
DWORD nNumBytesToRead,
OVERLAPPED* pOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);
◆ BOOL WriteFileEx(
HNALDE 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函数要求我们传入一个回调函数的地址.这个函数称作为完成函数.其形式如下:
VOID WINAPI CompletionRoutine(
DWORD dwError,
DWORD dwNumBytes,
OVERLAPPED* po);
大致原理:当我们调用ReadFileEx和WriteFileEx发出一个I/O请求的时候,这两个函数会将回调函数的地址传给设备驱动程序.当设备驱动程序完成I/O请求的时候,会在发出I/O请求的线程的APC队列中添加一项.该项包括了完成函数的地址,以及在发出I/O请求时所使用的OVERLAPPED结构的地址.
注意:当一个可提醒I/O完成之后,设备程序不会试图去触发一个事件对象.事实上,设备根本就没有用到OVERLAPPED结构的hEvent成员.有时我们可以将其据为己有.
当I/O请求完成的时候,系统会将他们添加到线程的APC队列中--回调函数并不会立即被调用,这是因为可能还在忙于处理其他事情,不能被打断.为了对线程APC队列中的项进行处理,线程必须将自己置为可提醒状态.这只不过意味着我们的线程在执行过程中已经达到了一个点(在这个点上它能够处理被中断的情况.下面的一些函数可以将线程置为可提醒状态:
DWORD SleepEx( DWORD dwMilliseconds,
BOOL bAlertable );//是否将自己置为可提醒状态
DWORD WaitForSingleObjectEx( HANDLE hHandle,
DWORD dwMilliseconds,
BOOL bAlertable );//是否将自己置为可提醒状态
DWORD WaitForMultipleObjectsEx( DWORD nCount,
CONST HANDLE *lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds,
BOOL bAlertable );//是否将自己置为可提醒状态
BOOL SignalObjectAndWait( HANDLE hObjectToSignal,
HANDLE hObjectToWaitOn,
DWORD dwMilliseconds,
BOOL bAlertable );//是否将自己置为可提醒状态
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort, // handle to completion port
LPDWORD lpNumberOfBytes, // bytes transferred
PULONG_PTR lpCompletionKey, // file completion key
LPOVERLAPPED *lpOverlapped, // buffer
DWORD dwMilliseconds, // optional timeout value
BOOL bAlertable); //是否将自己置为可提醒状态
DWORD MsgWaitForMultipleObjectsEx(DWORD nCount,
LPHANDLE pHandles,
DWORD dwMilliseconds,
DWORD dwWakeMask,
DWORD dwFlags);//需设置MWMO_ALERTABLE标志
当我们调用刚才提到的6个人函数之一,并将线程置为可提醒状态时,系统会首先检查线程的APC队列.如果队列中至少有一项,那么系统将不会让线程进入睡眠状态.系统会将APC队列中的那一项取出,让线程调用回调函数,并传入已完成I/O请求的错误码,已传输字节数,以及OVERLAPPED结构的地址.
当回调函数返回时,系统会检查APC队列中是否还有其他项,如果还有,那么继续处理,否则我们对可提醒函数的调用会返回.需要牢记在心的是,调用这些函数的任何一个时,只要线程的APC队列中至少有一项,线程就不会进入睡眠状态.在调用这些函数的时候,当且仅当线程的APC队列中一项都没有时,这些函数才会将线程挂起.
线程挂起以后,如果我们正在等待的那个内核对象被触发(或APC出现新项)时,此时线程将被唤醒
6个函数的返回值:如果返回WAIT_IO_COMPLETION或者GetLastError返回WAIT_IO_COMPLETION),我们可以得知线程得以继续执行的原因是线程至少处理APC队列中的一项.
可提醒I/O的优劣:
● 回调函数
● 发出I/O请求的线程必须同时完成通知进行处理.如果一个线程发出了多个请求,那么即便是其他线程完全处于空闲状态,该线程也必须对没有请求的完成通知做出响应.由于不存在符合均衡机制,因此伸缩性不是很好.
Windows提供一个函数允许我们手动将一项添加到APC队列中:
DWORD QueueUserAPC(
PAPCFUNC pfnAPC,//指定一个APC函数的指针:
//VOID WINAPI APCFunc(ULONG_PTR dwParam);
HANDLE hThread,//线程句柄(告诉系统想将该项添加到哪个线程队列中)
ULONG_PTR dwData);//传给回调函数的值
可以利用这个函数来杀死自己.(也就是使得当某个线程处于等待状态的时候,可以快速响应本身,并做一些"清理"工作)
■ I/O完成端口
◆ 构造服务应用程序的两种模型:
● 串行模型:一个线程等待一个客户(通常是网络)发出请求.当请求到达的时候,线程会被唤醒并对客户请求进行处理
● 并发模型:一个线程等待一个客户请求,并创建一个新的线程来处理请求.当新线程正在处理客户请求的时候,原来的线程会进入下一次循环并等待另一个客户端请求.当处理客户请求的线程完成整个处理过程的时候,改线程就会终止.
并发模型的缺点:当可运行的线程数量大于可用CPU数量,系统就必须花时间来执行上下文切换(这是很耗CPU的周期的),另外每创建一个线程都需要额外的开销的.
◆ I/O完成端口的一系列函数
● HANDLE CreateIoCompletionPort(
HANDLE hFile,
HANDLE hExistingCompletionPort,
ULONG_PTR CompletionKey,
DWORD dwNumberOfConCurrentThreads);
主要作用:不仅会创建一个I/O完成端口,而且会将一个设备与一个I/O完成端口关联起来.如果我们只想创建I/O完成端口.我们可以使用作者封装好的一个函数:
//dwNumberOfConcurrentThreads:告诉I/O完成端口在同一个时间最多能有多少线程 //处于可运行状态如果为0,表示线程的数量等于主机CPU数量
HANDLE CreateNewCompletionPort( DWORD dwNumberOfConcurrentThreads)
{
Return ( CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,
0,dwNumberOfConcurrentThreads) );
}
◆ 当我们创建一个I/O完成端口时,系统内核实际上会创建5个不同的数据结构.
● 设备列表:表示该端口系那个关联的一个或多个设备.同样我们可以使用作者封装好的另外一个函数:
BOOL AssociateDeviceWithCompletionPort(
HANDLE hCompletionPort,//由CreateNewCompletionPort调用返回
HANDLE hDevice, //设备句柄(可能是文件、套接字、邮件槽、管道)
DWORD dwCompletionKey){//完成键(对我们而言有意义即可)
HANDLE h = CreateIoCompletionPort(hDevice,
hCompletionPort,
dwCompletionKey,
0);
Return (h == hCompletionPort);
}
使用示例:
#define CK_FILE 1
HANDLE hFile = CreateFile( ... );
HANDLE hCompletionPort = CreateIoCompletionPort( hFile,NULL,CK_FILE,2 );
● I/O 完成队列(我的理解就是记录当前的状态)
当设备的一个异步I/O请求完成时,系统会检查设备是否与一个I/O完成端口相关联,如果设备与一个I/O完成端口相关联,那么系统会将该项已完成的I/O请求追加到I/O完成端口的I/O完成队列的末尾.这个队列中的每一项包含的信息有:已传输的字节数、最初将设备与端口关联在一起的时候所设的完成键的值、一个指向I/O请求的OVERLAPPED结构的指针以及一个错误码.
为了发出一个在完成的时候不需要被添加到队列中的I/O请求,我们必须在OVERLAPPED结构的hEvent成员中保存一个有效的事件句柄,并将它与1按位或起来:
overlapped.hEvent = CreateEvent( NULL,TRUE,FALSE,NULL );
overlapped.hEvent = ( HANDLE )( ( DWORD_PTR )overlapped.HEVENT | 1 );
● 等待线程队列.
线程池中的所有线程应该执行同一个函数.一般来说,这个线程会先进行一些初始化工作,然后进入一个循环,当服务进程被告知要停止的时候,这个循环也应该就此终结.在循环内部,线程将自己切换到睡眠状态,来等待设备I/O请求完成并进入完成端口.我们可以使用GetQueuedCompletionStatus可以完成此功能:
函数的基本功能:当线程调用该函数后,调用线程的线程标示符会被添加到这个等待线程队列,这使得I/O完成端口内核对象始终知道,那些线程处于等待状态.然后将调用线程切换到睡眠状态,直到指定的完成端口的I/O完成队列中出现一项,或者等待的时间已经超出了指定的时间.
移除I/O完成队列中的各项是以先入先出的方式来进行的.但是,唤醒那些调用了GetQueuedCompletionStatus的线程是以后入先出的范式进行的.
Windows Vista中,如果预计会收到大量的I/O请求,那么可以调用GetQueuedCompletionStatusEx函数来同时取得多个I/O请求的结果,而不必让许多线程等待完成端口,从而避免产生不必要的上下文切换:
BOOL WINAPI GetQueuedCompletionStatusEx(
__in HANDLE CompletionPort,//需要进行监视的完成端口
__out LPOVERLAPPED_ENTRY lpCompletionPortEntries,//各个完成端口的详细信息
__in ULONG ulCount, //lpCompletionPortEntire的最大长度
__out PULONG ulNumEntriesRemoved,//实际的大小
__in DWORD dwMilliseconds, //等待时间
__in BOOL fAlertable //标志
);
其中OVERLAPP_ENTRY结构如下:
typedef struct _OVERLAPPED_ENTRY {
ULONG_PTR lpCompletionKey;
LPOVERLAPPED lpOverlapped;
ULONG_PTR Internal;
DWORD dwNumberOfBytesTransferred;
} OVERLAPPED_ENTRY, *LPOVERLAPPED_ENTRY;
这个结构是不是和前面所说的I/O完成端口的结构很像.
如果bAlertable为FALSE,那么函数会一直等待一个已完成的I/O请求被添加到完成端口,直到超出指定的等待时间.如果其为TRUE而且队列中没有已完成的I/O请求,那么线程将进入可提醒状态.
需要注意的是:如果一个设备有完成端口与之相关联,那么当我们向他发出一个异步I/O请求时,Windows会将结果添加到完成端口的队列中.即使异步请求是以同步方式进行的,这是windows为了保持一致性.
● 释放线程列表
当完成端口唤醒一个线程的时候,会将该线程的线程标示符保存在与完成端口相关联的释放线程列表中.(这就使得完成端口能够记住哪些线程已经被唤醒,并监视他们的执行情况.
● 已暂停线程列表
当一个以释放的线程调用任何函数使得该线程切换到了等待状态.那么完成端口会检测到,并更新内部的数据结构,将该线程的线程标示符从已释放线程列表中移除,并将其添加到暂停线程列表.
一旦一个线程调用了GetQueuedCompletionStatus,该线程会被"指派"给指定的完成端口.系统假定所有被指派的线程都是以该完成端口的名义来工作的.只有当指派给完成端口的正在运行的线程数量小于它最大允许的并发线程数量时,完成端口才会从线程池中唤醒线程.
我们可以使用以下三种方式之一来结束线程/完成端口的指派:
◆ 让线程退出
◆ 让线程调用GetqueuedCompletionStatus并传入另一个不同的I/O完成端口的句柄.
◆ 销毁线程当前被指派的I/O完成端口
I/O完成端口体系结构假定可运行的线程的数量只会在很短一段时间内高于最大允许的线程数量,一旦线程进入下一次循环并调用GetQueuedCompletionStatus,可运行线程的数量就会迅速下降.
6. 模拟已完成的I/O请求
BOOL PostQueuedCompletionStatus(
HANDLE hCompletionPort, //完成端口句柄
DWORD dwNumBytes, //以下这几项和前面的变量类似(返回什么值给线程).
ULONG_PTR CompletionKey,
OVERLAPPED* pOverlapped);
当线程从I/O完成队列中得到一个模拟项的时候,GetQueuedCompletionStatus会返回TRUE,表示I/O请求已成功执行.
在Vista中,当我们调用CloseHandle并传入一个完成端口句柄时,兄台那个会将所有正在等待GetQueuedCompletionStatus返回的线程唤醒,并返回FALSE给他们.此时调用GetLastError会返回ERROR_INVALID_HANDLE.线程可以通过这个种方式来知道自己应该退出了.