您的位置:首页 > 编程语言 > C语言/C++

《Windows via C/C++》学习笔记 (七) 设备I/O之“I/O请求完成”

2013-11-13 15:57 676 查看


《Windows via C/C++》学习笔记 —— 设备I/O之“接收I/O请求完成通知”

上一篇,讨论了如何发送I/O请求。在异步的设备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请求,另一个线程处理之。

该方法伸缩性好,而且性能高。
  本篇主要讨论前3种。


通知一个设备内核对象

  在Windows中,一个设备内核对象可以处于“已通知”或“未通知”状态。ReadFile和WriteFile在发送I/O请求之前让指定的设备内核对象处于“未通知”状态。当设备驱动程序完成了I/O请求,驱动程序将设备内核对象设置为“已通知”状态。

  一个线程可以查看一个异步的I/O请求是否完成,通过等待函数即可实现:WaitForSingleObject或WaitForMultipleObject等。这就意味着,这种实现的方式不是完完全全的“异步”,最终有点“同步”的味道,因为这些等待函数可能会导致线程进入阻塞状态。

  可以如下地编码来使用这种方法:



//创建或打开设备内核对象,注意使用FILE_FLAG_OVERLAPPED旗标

HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);

BYTE bBuffer[100]; //I/O缓冲区

OVERLAPPED o = { 0 }; //重叠结构,不要忘记初始化

o.Offset = 345; //偏移量

BOOL bReadDone = ReadFile(hFile, bBuffer, 100, NULL, &o); //读取数据

DWORD dwError = GetLastError();

//ReadFile返回FLASE,但是错误码dwError表明I/O即将开始

if (!bReadDone && (dwError == ERROR_IO_PENDING))

{

//等待I/O请求完成

WaitForSingleObject(hFile, INFINITE);

bReadDone = TRUE;

}

if (bReadDone)

{

// 操作成功,可以查看OVERLAPPED结构中的各个字段和缓冲区中的数据

// o.Internal 包含了I/O错误码

// o.InternalHigh 包含了I/O传输字节数

// 缓冲区包含了读取的数据

}

else

{

// 错误发生,bReadDone为FLASE,且错误码dwError指明一个错误

}



  这种方法是十分简单的,实现起来十分容易,但是有一个明显的缺点,就是无法处理多个I/O请求。因为一旦一个I/O请求完成,等待函数就会返回,无法识别是哪个I/O请求完成了。


通知一个事件内核对象

  这种方法可以处理多个同时的I/O请求。

  记得OVERLAPPED结构中有一个hEvent成员吧,该成员是一个事件内核对象。使用这种方法,你必须使用CreateEvent函数来创建一个事件内核对象,并初始化那个hEvent成员。当一个异步I/O请求完成设备驱动程序查看OVERLAPPED中的hEvent是否为NULL,如果不是,驱动程序通过SetEvent通知该事件内核对象,同时也使得设备内核对象进入“已通知”状态。但是,你应该等待在该事件内核对象上。

  你可以让Windows不通知“文件内核对象”,这样可以少许提高一点性能,通过呼叫函数SetFileCompletionNotificationModes即可,传递一个设备内核对象句柄和FILE_SKIP_SET_EVENT_ON_HANDLE旗标:

BOOL SetFileCompletionNotificationModes(HANDLE hFile, UCHAR uFlags);

  为了处理多个I/O请求,你必须为每个I/O请求创建一个独立的事件内核对象,并将之初始化OVERLAPPED结构中的hEvent。然后可以通过WaitForMultipleObject来等待这些事件内核对象。这种方法可以实现一个设备上的多个I/O请求的处理。可以如下编码:



//创建或打开设备,注意使用FILE_FLAG_OVERLAPPED

HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);

BYTE bReadBuffer[10]; //读缓冲区

OVERLAPPED oRead = { 0 }; //定义OVERLAPPED结构,并初始化之

oRead.Offset = 0;

oRead.hEvent = CreateEvent(...); //创建事件内核对象,与读操作相关

ReadFile(hFile, bReadBuffer, 10, NULL, &oRead);

BYTE bWriteBuffer[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

OVERLAPPED oWrite = { 0 };

oWrite.Offset = 10;

oWrite.hEvent = CreateEvent(...); //另一个事件内核对象,与写操作相关

WriteFile(hFile, bWriteBuffer, _countof(bWriteBuffer), 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: //读操作完成

break;

case 1: //写操作完成

break;

}



  当然,也可以把上面代码拆分成两个线程执行,上面半段为发送I/O请求的放在一个线程中,下面处理I/O请求完成的放在另一个线程中。

  在I/O请求完成之后,收到通知之后,可以得到有关OVERLAPPED结构的信息,通过函数GetOverlappedResult:

BOOL GetOverlappedResult(

HANDLE hFile, //设备对象句柄

OVERLAPPED* pOverlapped, //OVERLAPPED结构指针,返回OVERLAPPED

PDWORD pdwNumBytes, //返回传输的字节数

BOOL bWait); //是否等到I/O结束才返回


告警I/O

  当一个线程被创建的时候,系统也创建一个与该线程关联的队列,这个队列称为“异步过程调用”(APC)队列。当发送一个I/O请求的时候,你可以告诉驱动程序在APC队列中加入一个记录。当I/O请求完成之后,如果线程处于“待命状态”,则该记录中的回调函数可以被调用。

  让I/O请求完成的通知进入线程的APC队列,即在APC队列中添加一个I/O请求完成通知的记录,可以使用如下两个函数:



BOOL ReadFileEx(

HANDLE hFile, //设备对象句柄

PVOID pvBuffer, //数据缓冲区

DWORD nNumBytesToRead, //预期传输的数据

OVERLAPPED* pOverlapped, //OVERLAPPED结构指针

LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);//回调函数指针





BOOL WriteFileEx(

HANDLE hFile,

CONST VOID *pvBuffer,

DWORD nNumBytesToWrite,

OVERLAPPED* pOverlapped,

LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);



  注意一下函数的最后一个参数pfnCompletionRoutine,是一个函数指针,接受一个回调函数,这个函数就是被记录到APC队列的函数,函数头必须按如下格式书写:

VOID WINAPI CompletionRoutine( //函数名可以任意

DWORD dwError, //错误码

DWORD dwNumBytes, //传输的数据

OVERLAPPED* po); //OVERLAPPED结构

  当使用ReadFileEx和WriteFileEx函数的时候,传递回调函数的地址,当驱动程序完成I/O请求之后,它在线程APC队列中添加一个记录,这个记录包含这个回调函数的地址和起初发送I/O请求时候的OVERLAPPED结构地址。

  当线程进入“待命状态”,系统检测线程APC队列,然后调用回调函数,并设置其3个参数。

  当I/O请求完成,系统不会马上调用记录在APC队列中的回调函数,因为线程可能没有进入“待命状态”。为了调用回调函数,你必须让线程进入“待命状态”,可以通过一些带“Ex”的等待函数来完成:



DWORD SleepEx(

DWORD dwMilliseconds,

BOOL bAlertable);

DWORD WaitForSingleObjectEx(

HANDLE hObject,

DWORD dwMilliseconds,

BOOL bAlertable);

DWORD WaitForMultipleObjectsEx(

DWORD cObjects,

CONST HANDLE* phObjects,

BOOL bWaitAll,

DWORD dwMilliseconds,

BOOL bAlertable);

BOOL SignalObjectAndWait(

HANDLE hObjectToSignal,

HANDLE hObjectToWaitOn,

DWORD dwMilliseconds,

BOOL bAlertable);

BOOL GetQueuedCompletionStatusEx(

HANDLE hCompPort,

LPOVERLAPPED_ENTRY pCompPortEntries,

ULONG ulCount,

PULONG pulNumEntriesRemoved,

DWORD dwMilliseconds,

BOOL bAlertable);

DWORD MsgWaitForMultipleObjectsEx(

DWORD nCount,

CONST HANDLE* pHandles,

DWORD dwMilliseconds,

DWORD dwWakeMask,

DWORD dwFlags); //使用MWMO_ALERTABLE使线程进入“待命状态”



  除了MsgWaitForMultipleObjectEx函数之外,上面其余5个函数的最后一个参数bAlertalbe,指明了是否要线程进入“待命状态”,如果需要,请传递TRUE。

  当你调用上面这些等待函数,并让线程进入“待命状态”,系统首先查看线程的APC队列,如果至少有一个记录在APC队列中,系统不会让你的线程进入阻塞状态,而是调用回调函数,并提供其3个参数。当回调函数返回给系统,系统再次检查APC队列中的记录,如果存在,继续调用回调函数。否则,回调函数返回给用户(即普通的返回)。

  注意,如果APC队列中存在记录,那么调用上述等待函数,不会让你的线程进入阻塞状态。只有当APC队列中没有记录,调用这些函数的时候才会让线程进入阻塞状态,直到等待的内核对象为“已通知”状态或APC队列中出现记录。由于线程处于“待命状态”,因此一点APC队列中出现一个记录,那么系统唤醒你的线程,呼叫回调函数,清空APC队列,回调函数返回,线程继续执行。

  这6个等待函数返回的值说明了它们是因为什么原因而返回的。如果返回WAIT_IO_COMPLETION,那么说明了你的线程继续执行,因为至少一个APC记录被处理。如果返回其他的值,那么说明这些等待函数等待的内核对象为“已通知”状态(也可能是互斥内核对象被抛弃)或者等待超时。

  还有需要注意的是,系统调用APC回调函数,不是按FIFO的顺序,而是随意的。注意如下代码:

hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...);

ReadFileEx(hFile, ..., ReadRoutine1); //第一次读,回调函数ReadRoutine1

WriteFileEx(hFile, ..., WriteRoutine1); //第一次写,回调函数WriteRoutine1

ReadFileEx(hFile, ..., ReadRoutine2); //第二次读,回调函数ReadRoutine2

SomeFunc(); //其他一些操作

SleepEx(INFINITE, TRUE); //等待,线程进入“待命状态”

  线程发起了3次I/O请求,并给出了3个回调函数ReadRoutine1、WriteRoutine1、ReadRoutine2。然后线程执行SomeFunc函数,执行完成之后进入无限等待,当I/O请求结束,会调用3个APC队列中的回调函数。

  需要注意的是,如果3个I/O请求都在SomeFunc函数执行的时候完成,那么回调函数的调用顺序可能不是ReadRountine1、WriteRoutine1、ReadRoutine2,这个顺序是任意的。

  Windows提供了一个函数可以手动在一个线程的APC队列加入一个记录(即加入一个回调函数):

DWORD QueueUserAPC(

PAPCFUNC pfnAPC, //APC回调函数指针

HANDLE hThread, //线程对象句柄

ULONG_PTR dwData); //传递给参数pfnAPC所对应的回调函数的参数

  其中第1个参数是一个函数指针,是一个回调函数,被记录到线程的APC队列,其函数头格式如下:

VOID WINAPI APCFunc(ULONG_PTR dwParam);

  QueueUserAPC函数的第2个参数指明了你想要设置的哪个线程的APC队列。第3个参数dwData就是传递给回调函数APCFunc的参数。QueueUserAPC可以让你的线程摆脱阻塞状态,此时上述等待函数返回码为WAIT_IO_COMPLETION。

  最后要讲的就是告警I/O的缺点:

告警I/O的回调函数所提供的参数较少,因此处理上下文内容只能通过全局变量来实现。
使用告警I/O,意味着发送I/O请求和处理I/O完成通知只能放在同一个线程中,如果发送多个I/O请求,该线程就不得不处理每个I/O完成通知,其他线程则会比较 空闲,这样会造成不平衡。


《Windows via C/C++》学习笔记 —— 设备I/O之“I/O完成端口”

上一篇讲了3种接受异步I/O请求完成的通知的方法,分别是:通知一个设备内核对象、通知一个事件内核对象、告警I/O。

  本篇主要讲另一种接受异步I/O请求的方法——I/O完成端口。这是性能最高,且扩充性最好的方法。但是实现比较复杂。

  介绍I/O完成端口之前介绍两种服务器线程模型:

连续模型:单个线程等待一个客户的请求,一旦有一个客户发出请求,该线程唤醒然后处理客户的请求。
并发模型:单个线程等待一个客户的请求,一旦有一个客户发出请求,该线程创建另一个线程来处理请求。在新创建的线程处理请求的同时,原来等待请求的线程通过循环继续等待另一个客户的请求。当处理请求的线程处理完毕之后,自动销毁。

  连续模型最大的缺点就是无法同时处理多个请求。它只能等待、处理、等待、处理……如此交替进行。当有2个请求同时到来时,只能处理其中之一,第2个请求必须等待直到第1个请求处理完毕。Ping服务器就是典型的连续模型。

  并发模型,让一个线程专门地等待请求,该线程可以为每一个请求创建一个线程来处理之。其优点是等待请求的线程所做的工作很少,默认状态为阻塞状态。当一个客户请求到来的时候,该线程被唤醒,然后创建一个新的线程来处理这个请求,然后这个线程继续等待另一个请求。这样,当有多个客户请求同时到来的时候,它们可以几乎同时被处理。但是当客户请求过多,那么就会存在太多的处理线程,这些线程都是可以被调度的,那么就会出现很多次的“线程转换”,这样,Windows内核会花费大量的时间在“线程转换”这个工作上,从而浪费了大量的时间。Windows为了解决这个问题,提供了“I/O完成端口”内核对象。

  不妨设想一下,如果事先创建了一些线程,让这些线程处于等待状态,然后将所有用户的请求都投递到一个消息队列中,然后这些线程被唤醒,逐一地从消息队列中取出请求并进行处理,就可以避免为每个用户开辟线程,节省资源,也提高了线程利用率。其实I/O完成端口就是基于这样思想的产物。感觉就是一个“消息队列”,与本身的名字“I/O完成端口”没有很大的联系。


创建I/O完成端口

  I/O完成端口可以称为是最复杂的内核对象,可以使用CreateIoCompletionPort创建一个I/O完成端口内核对象:

HANDLE CreateIoCompletionPort(

HANDLE hFile, //设备句柄

HANDLE hExistingCompletionPort, //已经创建的I/O完成端口对象句柄

ULONG_PTR CompletionKey, //一个完成Key,相当于完成标号

DWORD dwNumberOfConcurrentThreads); //允许同时运行的线程个数

  乍看一下这个函数,很难理解。其实,这个函数有两个功能:创建I/O完成端口,将一个I/O完成端口与一个设备关联起来。因此,可以将该函数拆开。下面的函数CreateNewCompletionPort用来创建一个I/O完成端口:



HANDLE CreateNewCompletionPort(DWORD dwNumberOfConcurrentThreads)

{

return(CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0,

dwNumberOfConcurrentThreads));

}



  这个函数接受一个参数,并在内部调用CreateIoCompletionPort,将其前3个参数设置为INVALID_HANDLE_VLAUE,NULL,0。并保留最后一个参数给用户,如此便创建了一个I/O完成端口,参数dwNumberOfConcurrentThreads告诉I/O完成端口当前允许有多少个线程可以执行,如果传递0,则表示允许执行的线程个数没有限制。这个参数就是为了防止“线程切换”过于频繁。你可以动态地增加它的值,这样来测试一个合理的可运行线程数,以达到性能最佳。


关联I/O完成端口与设备

  当你创建了一个I/O完成端口,内核实际上创建了5个数据结构:

1、设备列表:与创建的I/O完成端口关联的设备

2、I/O请求完成队列(FIFO):

3、等待线程队列(LIFO)

4、释放线程列表

5、暂停线程列表

  第1个数据结构:设备列表指明了与这个I/O完成端口关联的设备,可以是一个设备,也可以是多个设备。你可以通过CreateIoCompletionPort函数关联设备和I/O完成端口,也可以将该函数拆开,使用如下函数:



BOOL AssociateDeviceWithCompletionPort(

HANDLE hCompletionPort, // I/O完成端口内核对象句柄

HANDLE hDevice, // 设备内核对象句柄

DWORD dwCompletionKey) // 完成Key

{

HANDLE h = CreateIoCompletionPort(hDevice, hCompletionPort,

dwCompletionKey, 0);

return (h == hCompletionPort);

}



  这个函数提供了一个I/O完成端口句柄和一个设备句柄,并将两者关联起来。其中最后一个参数,是一个完成Key,在处理I/O请求完成的通知的时候,这个值才有用,它只是对你有意义,系统不会注意它。

  每次调用这个,系统在I/O完成端口的“设备列表”这个数据结构中添加了一个记录,这个记录指明了与这个I/O完成端口相关联的设备。

  由于CreateIoCompletionPort函数比较复杂,因此建议将其拆开使用,或者也可以同时创建I/O完成端口并关联设备,如下编码:

#define CK_FILE 1

HANDLE hFile = Create(...);

// 创建I/O完成端口并将hFile最代表的设备关联起来,允许可运行线程数为2

HANDLE hCompletionPort = CreateCompletionPort(hFile, NULL, CK_FILE, 2);

  第2个数据结构是“I/O请求完成队列”。当一个异步设备I/O请求完成,系统查看该设备是否与一个I/O完成端口关联,如果是,系统在“I/O请求完成队列”的队尾加上一个“已经完成的I/O请求”的记录。队列中的每个记录指明以下内容:1、传输的数据的字节数;2、设备与I/O完成端口关联时候的完成Key;3、I/O请求的OVERLAPPED结构指针;4、一个错误码。


取得I/O完成端口状态信息

  当你的服务器系统启动,应该创建一个I/O完成端口,然后创建一个线程池来处理客户请求,一般而言,线程池中线程的数量为CPU的2倍。

  线程池中的所有线程所执行的功能是一样的,这些线程往往进入阻塞状态来等待设备I/O完成,可以通过GetQueuedCompletionStatus函数实现:



BOOL GetQueuedCompletionStatus(

HANDLE hCompletionPort, // I/O完成端口对象句柄

PDWORD pdwNumberOfBytesTransferred, //传输数据的字节数

PULONG_PTR pCompletionKey, //关联的完成Key

OVERLAPPED** ppOverlapped, //OVERLAPPED结构的指针的地址

DWORD dwMilliseconds); //等待时间(毫秒)



  这个函数让线程等待一个特定的I/O完成端口,通过第一个参数指明这个I/O完成端口。这个函数使得呼叫它的线程进入等待状态,直到在这个I/O完成端口的“I/O请求完成队列”中出现了一个记录,或者参数dwMilliseconds指明的时间超出。

  第3个数据结构:“等待线程队列”,指明了所有等待在这个I/O完成端口上的线程,这些线程都是因为呼叫GetQueuedCompletionStatus函数而等待一个I/O完成端口的,这些线程的ID记录在这个队列中,使得I/O完成端口可以知道哪些线程正在等待。当一个与I/O完成端口关联的设备完成了一个异步设备I/O请求的时候,“I/O请求完成队列”的队尾会出现一个记录,此时I/O完成端口唤醒在“等待线程队列”中的一个线程,这个线程呼叫的GetQueuedCompletionStatus函数会返回,并得到传输数据的字节数、完成Key、OVERLAPPED结构的地址。

  确定GetQueuedCompletionStatus函数返回的原因比较复杂,可以通过下面编码确定之:



DWORD dwNumBytes; //传输数据的字节数

ULONG_PTR CompletionKey; //完成Key

OVERLAPPED* pOverlapped; //OVERLAPPED结构指针

// hIOCP是一个I/O完成端口对象句柄,在其他地方被创建

BOOL bOk = GetQueuedCompletionStatus(hIOCP,

&dwNumBytes, &CompletionKey, &pOverlapped, 1000);

DWORD dwError = GetLastError(); //取得错误码

if (bOk)

{

// 等待成功,一个I/O请求完成了,可以处理之

}

else

{

if (pOverlapped != NULL)

{

// I/O请求失败,dwError错误码包含了错误的原因

}

else

{

if (dwError == WAIT_TIMEOUT)

{

// 等待时间超出,没有记录出现在“I/O请求完成队列”

}

else

{

// 错误地呼叫GetQueuedCompletionStatus,比如句柄无效

// dwError错误码中包含错误的原因

}

}

}



  要注意的是,“I/O请求完成队列”中的记录是按FIFO的方式入队和出队的。而“等待线程队列”中的线程是按LIFO的方式进出的,很像堆栈(但是作者就说是queue)。

  在Windows Vista中,如果你希望很多I/O请求被同时提交或处理,你不需要增加很多线程,而可以通过GetQueuedCompletionStatusEx来取得多个I/O请求完成的结果:



BOOL GetQueuedCompletionStatusEx(

HANDLE hCompletionPort, //I/O完成端口句柄

LPOVERLAPPED_ENTRY pCompletionPortEntries, //I/O请求完成记录数组

ULONG ulCount, //I/O请求完成记录的个数

PULONG pulNumEntriesRemoved, //实际取得的I/O请求完成记录

DWORD dwMilliseconds, //等待时间

BOOL bAlertable); //是否让线程进入“待命状态”,一般设置为FALSE



  该函数的第2个参数是一个指向结构OVERLAPPED_ENTRY的地址(一般是一个该结构的数组),该结构定义如下:



typedef struct _OVERLAPPED_ENTRY {

ULONG_PTR lpCompletionKey; //完成Key

LPOVERLAPPED lpOverlapped; //OVERLAPPED指针

ULONG_PTR Internal; //该字段应该避免使用

DWORD dwNumberOfBytesTransferred; //传输数据的字节数

} OVERLAPPED_ENTRY, *LPOVERLAPPED_ENTRY;



  本书中有一节“How the I/O Completion Port Manages the Thread Pool

”,感觉没有必要说了,看看就行,都是内部细节。

  还有要讲的就是线程池中应该有多少个线程。看过一些资料,本书上说是CPU个数的2倍,还有一些资料上说是2*CPU个数+2,这个感觉也没有什么好讲的,具体问题具体分析吧,呵呵。


模仿完成的I/O请求

  你可以模仿一个完成的I/O请求,让某个等待在I/O完成端口上的线程唤醒并执行。这也是一种线程间通信的机制。你可以通过PostQueuedCompletionStatus实现之:

BOOL PostQueuedCompletionStatus(

HANDLE hCompletionPort, // I/O完成对象句柄

DWORD dwNumBytes, // 预期传递数据的字节数

ULONG_PTR CompletionKey, // 完成Key

OVERLAPPED* pOverlapped); // OVERLAPPED结构指针

  该函数在I/O完成端口的“I/O请求完成队列”中加入一个记录,这个记录对应的一些数据由该函数的第2、3、4个参数给出。调用成功,该函数返回TRUE。


I/O完成端口使用步骤

  我以网络服务的套接字为例,说明一下I/O完成端口的使用步骤:

1、初始化套接字(Ws2_32.dll)——WSAStartup

2、创建一个I/O完成端口

3、创建一些线程,可以包含一个监听线程和若干个等待状态的处理线程

4、创建一个套接字socket,并邦定(bind),然后监听(listen)

5、反复循环,调用accept等待客户请求连接,

6、将连接进来的套接字与I/O完成端口关联起来

7、投递一个处理信息的请求,可以使用PostQueuedCompletionStatus,唤醒处理线程,从而让处理线程进行连接请求处理。

  如此重复5~7即可。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: