设备I/O之“I/O完成端口”

来源:互联网 发布:中国人才流失数据 编辑:程序博客网 时间:2024/06/03 10:06

本篇主要讲另一种接受异步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即可。

0 0
原创粉丝点击