《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 hFile, //设备句柄
HANDLE hExistingCompletionPort, //已经创建的I/O完成端口对象句柄
ULONG_PTR CompletionKey, //一个完成Key,相当于完成标号
DWORD dwNumberOfConcurrentThreads); //允许同时运行的线程个数
乍看一下这个函数,很难理解。其实,这个函数有两个功能:创建I/O完成端口,将一个I/O完成端口与一个设备关联起来。因此,可以将该函数拆开。下面的函数CreateNewCompletionPort用来创建一个I/O完成端口:
{
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完成端口,也可以将该函数拆开,使用如下函数:
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完成端口并关联设备,如下编码:
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函数实现:
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函数返回的原因比较复杂,可以通过下面编码确定之:
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请求完成的结果:
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的地址(一般是一个该结构的数组),该结构定义如下:
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实现之:
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即可。