windows下的IO模型之完成端口

本文整理于:http://blog.csdn.net/piggyxp/article/details/6922277

一. 完成端口的优点

  完成端口会充分利用Windows内核来进行I/O的调度,是用于C/S通信模式中性能最好的网络通信模型,没有之一;甚至连和它性能接近的通信模型都没有。

微软提出完成端口模型的初衷,就是为了解决这种"one-thread-per-client"的缺点的,它充分利用内核对象的调度,只使用少量的几个线程来处理和客户端的所有通信,消除了无谓的线程上下文切换,最大限度的提高了网络通信的性能。

    其实无论是哪种网络操模型,对于内存占用都是差不多的,真正的差别就在于CPU的占用,其他的网络模型都需要更多的CPU动力来支撑同样的连接数据。

二.完成端口原理:

  完成端口的做法是这样的:事先开好几个线程,你有几个CPU我就开几个,首先是避免了线程的上下文切换,因为线程想要执行的时候,总有CPU资源可用,然后让这几个线程等着,等到有用户请求来到的时候,就把这些请求都加入到一个公共消息队列中去,然后这几个开好的线程就排队逐一去从消息队列中取出消息并加以处理,这种方式就很优雅的实现了异步通信和负载均衡的问题,因为它提供了一种机制来使用几个线程“公平的”处理来自于多个客户端的输入/输出,并且线程如果没事干的时候也会被系统挂起,不会占用CPU周期,挺完美的一个解决方案,不是吗?哦,对了,这个关键的作为交换的消息队列,就是完成端口。

   对于完成端口这个概念,我一直不知道为什么它的名字是叫“完成端口”,我个人的感觉应该叫它“完成队列”似乎更合适一些,总之这个“端口”和我们平常所说的用于网络通信的“端口”完全不是一个东西,我们不要混淆了。

        首先,它之所以叫“完成”端口,就是说系统会在网络I/O操作“完成”之后才会通知我们,也就是说,我们在接到系统的通知的时候,其实网络操作已经完成了,就是比如说在系统通知我们的时候,并非是有数据从网络上到来,而是来自于网络上的数据已经接收完毕了;

  关于我们熟悉的WSAAsyncSelect或者是WSAEventSelect这两个异步模型,对于这两个模型,我不知道其内部是如何实现的,但是这其中一定没有用到Overlapped机制,就不能算作是真正的异步,可能是其内部自己在维护一个消息队列吧,总之这两个模式虽然实现了异步的接收,但是却不能进行异步的发送,这就很明显说明问题了,我想其内部的实现一定和完成端口是迥异的,并且,完成端口非常厚道,因为它是先把用户数据接收回来之后再通知用户直接来取就好了,而WSAAsyncSelect和WSAEventSelect之流只是会接收到数据到达的通知,而只能由应用程序自己再另外去recv数据,性能上的差距就更明显了。

三.完成端口的基本流程

   (1) 调用 CreateIoCompletionPort() 函数创建一个完成端口,而且在一般情况下,我们需要且只需要建立这一个完成端口,把它的句柄保存好

        (2) 根据系统中有多少个处理器,就建立多少个工作者(为了醒目起见,下面直接说Worker)线程,这几个线程是专门用来和客户端进行通信的,目前暂时没什么工作;

        (3) 下面就是接收连入的Socket连接了,这里有两种实现方式:

  一是和别的编程模型一样,还需要启动一个独立的线程,专门用来accept客户端的连接请求;

  二是用性能更高更好的异步AcceptEx()请求,本文采用的是性能更好的AcceptEx,至于两者代码编写上的区别,我接下来会详细的讲。

        (4) 每当有客户端连入的时候,我们就还是得调用CreateIoCompletionPort()函数,这里却不是新建立完成端口了,而是把新连入的Socket(也就是前面所谓的设备句柄),与目前的完成端口绑定在一起。

       (5) 例如,客户端连入之后,我们可以在这个Socket上提交一个网络请求,例如WSARecv(),然后系统就会帮咱们乖乖的去执行接收数据的操作,我们大可以放心的去干别的事情了;

       (6) 而此时,我们预先准备的那几个Worker线程就不能闲着了, 我们在前面建立的几个Worker就要忙活起来了,都需要分别调用GetQueuedCompletionStatus() 函数在扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,我们再继续投递下一个网络通信的请求就OK了,如此循环。

采用accept方式的流程示意图如下:

                          

         采用AcceptEx方式的流程示意图如下:

                           

两个图中最大的相同点是什么?是的,最大的相同点就是主线程无所事事,闲得蛋疼……

         为什么呢?因为我们使用了异步的通信机制,这些琐碎重复的事情完全没有必要交给主线程自己来做了,只用在初始化的时候和Worker线程交待好就可以了,用一句话来形容就是,主线程永远也体会不到Worker线程有多忙,而Worker线程也永远体会不到主线程在初始化建立起这个通信框架的时候操了多少的心……

这样做的好处在哪里?为什么还要异步的投递AcceptEx连接的操作呢?

         首先,我可以很明确的告诉各位,如果短时间内客户端的并发连接请求不是特别多的话,用accept和AcceptEx在性能上来讲是没什么区别的

        如果客户端只进行连接请求,而什么都不做的话,我们的Server只能接收大约3万-4万个左右的并发连接,然后客户端其余的连入请求就只能收到WSAENOBUFS (10055)了,因为系统来不及为新连入的客户端准备资源了。

        需要准备什么资源?当然是准备Socket了……虽然我们创建Socket只用一行SOCKET s= socket(…) 这么一行的代码就OK了,但是系统内部建立一个Socket是相当耗费资源的,因为Winsock2是分层的机构体系,创建一个Socket需要到多个Provider之间进行处理,最终形成一个可用的套接字。总之,系统创建一个Socket的开销是相当高的,所以用accept的话,系统可能来不及为更多的并发客户端现场准备Socket了。

        而AcceptEx比Accept又强大在哪里呢?是有三点:

         (1) 这个好处是最关键的,是因为AcceptEx是在客户端连入之前,就把客户端的Socket建立好了,也就是说,AcceptEx是先建立的Socket,然后才发出的AcceptEx调用,也就是说,在进行客户端的通信之前,无论是否有客户端连入,Socket都是提前建立好了;而不需要像accept是在客户端连入了之后,再现场去花费时间建立Socket。如果各位不清楚是如何实现的,请看后面的实现部分。

         (2) 相比accept只能阻塞方式建立一个连入的入口,对于大量的并发客户端来讲,入口实在是有点挤;而AcceptEx可以同时在完成端口上投递多个请求,这样有客户端连入的时候,就非常优雅而且从容不迫的处理连入请求了。

         (3) AcceptEx还有一个非常体贴的优点,就是在投递AcceptEx的时候,我们还可以顺便在AcceptEx的同时,收取客户端发来的第一组数据,这个是同时进行的,也就是说,在我们收到AcceptEx完成的通知的时候,我们就已经把这第一组数据接完毕了;但是这也意味着,如果客户端只是连入但是不发送数据的话,我们就不会收到这个AcceptEx完成的通知……这个我们在后面的实现部分,也可以详细看到。

         最后,各位要有一个心里准备,相比accept,异步的AcceptEx使用起来要麻烦得多……

 四。完成端口的详细讲解

 【第一步】创建一个完成端口

HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );

  对于最后一个参数 0,我这里要简单的说两句,这个0可不是一个普通的0,它代表的是NumberOfConcurrentThreads,也就是说,允许应用程序同时执行的线程数量。当然,我们这里为了避免上下文切换,最理想的状态就是每个处理器上只运行一个线程了,所以我们设置为0,就是说有多少个处理器,就允许同时多少个线程运行。

   【第二步】根据系统中CPU核心的数量建立对应的Worker线程

建立CPU核心数量*2那么多的线程,这样更可以充分利用CPU资源,因为完成端口的调度是非常智能的,比如我们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的情况,这样同一个CPU核心上的另一个线程就可以代替这个Sleep的线程执行了;因为完成端口的目标是要使得CPU满负荷的工作。

// 根据CPU数量,建立*2的线程  
  m_nThreads = 2 * m_nProcessors;  
 HANDLE* m_phWorkerThreads = new HANDLE[m_nThreads];  
  
 for (int i = 0; i < m_nThreads; i++)  
 {  
     m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, …);  
 }  

 【第三步】创建一个用于监听的Socket,绑定到完成端口上,然后开始在指定的端口上监听连接请求

// 初始化Socket库  
WSADATA wsaData;  
WSAStartup(MAKEWORD(2,2), &wsaData);  
//初始化Socket  
struct sockaddr_in ServerAddress;  
// 这里需要特别注意,如果要使用重叠I/O的话,这里必须要使用WSASocket来初始化Socket  
// 注意里面有个WSA_FLAG_OVERLAPPED参数  
SOCKET m_sockListen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);  
// 填充地址结构信息  
ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress));  
ServerAddress.sin_family = AF_INET;  
// 这里可以选择绑定任何一个可用的地址,或者是自己指定的一个IP地址   
//ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);                        
ServerAddress.sin_addr.s_addr = inet_addr(“你的IP”);           
ServerAddress.sin_port = htons(11111);                            
// 绑定端口  
if (SOCKET_ERROR == bind(m_sockListen, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress)))   
// 开始监听  
listen(m_sockListen,SOMAXCONN)) 

    需要注意的地方有两点:

        (1) 想要使用重叠I/O的话,初始化Socket的时候一定要使用WSASocket并带上WSA_FLAG_OVERLAPPED参数才可以(只有在服务器端需要这么做,在客户端是不需要的);

        (2) 注意到listen函数后面用的那个常量SOMAXCONN了吗?这个是在微软在WinSock2.h中定义的,并且还附赠了一条注释,Maximum queue length specifiable by listen.,所以说,不用白不用咯^_^

   接下来有一个非常重要的动作:既然我们要使用完成端口来帮我们进行监听工作,那么我们一定要把这个监听Socket和完成端口绑定才可以的吧:

        如何绑定呢?同样很简单,用 CreateIoCompletionPort()函数。

    是的,这个和前面那个创建完成端口用的居然是同一个API!但是这里这个API可不是用来建立完成端口的,而是用于将Socket和以前创建的那个完成端口绑定的,大家可要看准了,不要被迷惑了,因为他们的参数是明显不一样的

        说实话,我感觉微软应该把这两个函数分开,弄个 CreateNewCompletionPort() 多好呢?

        这里在详细讲解一下CreateIoCompletionPort()的几个参数:

 HANDLE WINAPI CreateIoCompletionPort(  
    __in      HANDLE  FileHandle,             // 这里当然是连入的这个套接字句柄了  
     __in_opt  HANDLE  ExistingCompletionPort, // 这个就是前面创建的那个完成端口  
     __in      ULONG_PTR CompletionKey,        // 这个参数就是类似于线程参数一样,在  
                                               // 绑定的时候把自己定义的结构体指针传递  
                                               // 这样到了Worker线程中,也可以使用这个  
                                               // 结构体的数据了,相当于参数的传递  
     __in      DWORD NumberOfConcurrentThreads // 这里同样置0  
);  

  【第四步】在这个监听Socket上投递AcceptEx请求

我们应该用WSAIoctl 配合SIO_GET_EXTENSION_FUNCTION_POINTER参数来获取函数的指针,然后再调用AcceptEx。

        这是为什么呢?因为我们在未取得函数指针的情况下就调用AcceptEx的开销是很大的,因为AcceptEx 实际上是存在于Winsock2结构体系之外的(因为是微软另外提供的),所以如果我们直接调用AcceptEx的话,首先我们的代码就只能在微软的平台上用了,没有办法在其他平台上调用到该平台提供的AcceptEx的版本(如果有的话), 而且更糟糕的是,我们每次调用AcceptEx时,Service Provider都得要通过WSAIoctl()获取一次该函数指针,效率太低了,所以还不如我们自己直接在代码中直接去这么获取一下指针好了。

        获取AcceptEx函数指针的代码大致如下:

       
       LPFN_ACCEPTEX     m_lpfnAcceptEx;         // AcceptEx函数指针  
        GUID GuidAcceptEx = WSAID_ACCEPTEX;        // GUID,这个是识别AcceptEx函数必须的  
DWORD dwBytes = 0;    
  
WSAIoctl(  
    m_pListenContext->m_Socket,   
    SIO_GET_EXTENSION_FUNCTION_POINTER,   
    &GuidAcceptEx,   
    sizeof(GuidAcceptEx),   
    &m_lpfnAcceptEx,   
    sizeof(m_lpfnAcceptEx),   
    &dwBytes,   
    NULL,   
    NULL);   

另外需要注意的是,通过WSAIoctl获取AcceptEx函数指针时,只需要随便传递给WSAIoctl()一个有效的SOCKET即可,该Socket的类型不会影响获取的AcceptEx函数指针。

        然后,我们就可以通过其中的指针m_lpfnAcceptEx调用AcceptEx函数了

 AcceptEx函数的定义如下:

BOOL AcceptEx (       
               SOCKET sListenSocket,   
               SOCKET sAcceptSocket,   
               PVOID lpOutputBuffer,   
               DWORD dwReceiveDataLength,   
               DWORD dwLocalAddressLength,   
               DWORD dwRemoteAddressLength,   
               LPDWORD lpdwBytesReceived,   
               LPOVERLAPPED lpOverlapped   
);  
  • 参数1--sListenSocket, 这个就是那个唯一的用来监听的Socket了,没什么说的;
  • 参数2--sAcceptSocket, 用于接受连接的socket,这个就是那个需要我们事先建好的,等有客户端连接进来直接把这个Socket拿给它用的那个,是AcceptEx高性能的关键所在。
  • 参数3--lpOutputBuffer,接收缓冲区,这也是AcceptEx比较有特色的地方,既然AcceptEx不是普通的accpet函数,那么这个缓冲区也不是普通的缓冲区,这个缓冲区包含了三个信息:一是客户端发来的第一组数据,二是server的地址,三是client地址,都是精华啊…但是读取起来就会很麻烦,不过后面有一个更好的解决方案。
  • 参数4--dwReceiveDataLength,前面那个参数lpOutputBuffer中用于存放数据的空间大小。如果此参数=0,则Accept时将不会待数据到来,而直接返回,如果此参数不为0,那么一定得等接收到数据了才会返回…… 所以通常当需要Accept接收数据时,就需要将该参数设成为:sizeof(lpOutputBuffer) - 2*(sizeof sockaddr_in +16),也就是说总长度减去两个地址空间的长度就是了,看起来复杂,其实想明白了也没啥……
  • 参数5--dwLocalAddressLength,存放本地址地址信息的空间大小;
  • 参数6--dwRemoteAddressLength,存放本远端地址信息的空间大小;
  • 参数7--lpdwBytesReceived,out参数,对我们来说没用,不用管;
  • 参数8--lpOverlapped,本次重叠I/O所要用到的重叠结构。

我们这个是异步操作,我们是在线程启动的地方投递的这个操作, 等我们再次见到这些个变量的时候,就已经是在Worker线程内部了,因为Windows会直接把操作完成的结果传递到Worker线程里,这样咱们在启动的时候投递了那么多的IO请求,这从Worker线程传回来的这些结果,到底是对应着哪个IO请求的呢?。。。。

        聪明的你肯定想到了,是的,Windows内核也帮我们想到了:用一个标志来绑定每一个IO操作,这样到了Worker线程内部的时候,收到网络操作完成的通知之后,再通过这个标志来找出这组返回的数据到底对应的是哪个Io操作的。

        这里的标志就是如下这样的结构体:

typedef struct _PER_IO_CONTEXT{  
  OVERLAPPED   m_Overlapped;          // 每一个重叠I/O网络操作都要有一个                
   SOCKET       m_sockAccept;          // 这个I/O操作所使用的Socket,每个连接的都是一样的  
   WSABUF       m_wsaBuf;              // 存储数据的缓冲区,用来给重叠操作传递参数的,关于WSABUF后面还会讲  
   char         m_szBuffer[MAX_BUFFER_LEN]; // 对应WSABUF里的缓冲区  
   OPERATION_TYPE  m_OpType;               // 标志这个重叠I/O操作是做什么的,例如Accept/Recv等  
  
 } PER_IO_CONTEXT, *PPER_IO_CONTEXT;  

   【第五步】我们再来看看Worker线程都做了些什么

(1) 使用 GetQueuedCompletionStatus() 监控完成端口

首先这个工作所要做的工作大家也能猜到,无非就是几个Worker线程哥几个一起排好队队来监视完成端口的队列中是否有完成的网络操作就好了,代码大体如下:

 

void *lpContext = NULL;  
OVERLAPPED        *pOverlapped = NULL;  
DWORD            dwBytesTransfered = 0;  
  
BOOL bReturn  =  GetQueuedCompletionStatus(  
                                     pIOCPModel->m_hIOCompletionPort,  // 这个就是我们建立的那个唯一的完成端口
                                         &dwBytesTransfered,  //这个是操作完成后返回的字节数
                             (LPDWORD)&lpContext,  // 这个是我们建立完成端口的时候绑定的那个自定义结构体参数  
                             &pOverlapped,  // 这个是我们在连入Socket的时候一起建立的那个重叠结构  
                             INFINITE );  //等待完成端口的超时时间,如果线程不需要做其他的事情,那就INFINITE就行了  

 

 各位留意到其中的GetQueuedCompletionStatus()函数了吗?这个就是Worker线程里第一件也是最重要的一件事了,这个函数的作用就是我在前面提到的,会让Worker线程进入不占用CPU的睡眠状态,直到完成端口上出现了需要处理的网络操作或者超出了等待的时间限制为止。

        一旦完成端口上出现了已完成的I/O请求,那么等待的线程会被立刻唤醒,然后继续执行后续的代码。

 【第六步】当收到Accept通知时 _DoAccept()

原文地址:https://www.cnblogs.com/curo0119/p/8463423.html