假若—个应用程序同时需要管理为数众多的套接字,那么“完成端口”模型。往往可以达到最佳的系统性能,然而不幸的是,该模型只适用于以下操作系统(微软的):Windows NT和Windows 2000操作系统。因其设计的复杂性,只有在应用程序需要同时管理数百乃至上千个套接字的时候、而且希望随着系统内安装的CPU数量的增多、应用程序的性能也可以线性提升,才应考虑采用“完成端口”模型。要记住的一个基本准则是,假如要为Windows NT或windows 2000开发高性能的服务器应用,同时希望为大量套接字I/O请求提供服务(Web服务器便是这方面的典型例子),那么I/O完成端口模型便是最佳选择。
从本质上说,完成端口模型要求我们创建一个Win32完成端口对象,通过指定数量的线程对重叠I/O请求进行管理。以便为已经完成的
重叠I/O请求提供服务。要注意的是。所谓“完成端口”,实际是Win32、Windows NT以及windows 2000采用的一种I/O构造机制,除套
接字句柄之外,实际上还可接受其他东西。讲述如何使用套接字句柄,来发挥完成端口模型的巨大威力。
使用这种模型之前,首先要创建一个I/O完成端口对象,用它面向任意数量的套接字句柄。管理多个I/O请求。要做到这—点,需要调用CreateIoCompletionPort
函数。该函数定义如下:
HANDLE CreateIoCompletionPort(
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
DWORD CompletionKey,
DWORD NumberOfConcurrentThreads
);
要注意意该函数实际用于两个明显有别的目的:
■用于创建—个完成端口对象。
■将一个句柄同完成端口关联到一起。
最开始创建—个完成端口的时候,唯一感兴趣的参数便是NumberOfConcurrentThreads
并发线程的数量;前面三个参数都会被忽略。NumberOfConcurrentThreads
参数的特殊之处在于.它定义了在一个完成端口上,同时允许执行的线程数量。理想情况下我们希望每个处理器各自负责—个线程的运行,为完成端口提供服务,避免过于频繁的线程“场景”切换。若将该参数设为0,说明系统内安装了多少个处理器,便允许同时运行多少个线程!可用下述代码创建一个I/O完成端口:
CompetionPort=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0)
该语加的作用是返问一个句柄.在为完成端口分配了—个套接字句柄后,用来对那个端口进行标定(引用)。
成功创建一个完成端口后,便可开始将套接字句柄与对象关联到一起。但在关联套接字之前、首先必须创建—个或多个“工作者线程”,以便在I/O请求投递给完成端口对象后。为完成端口提供服务。在这个时候,大家或许会觉得奇怪、到底应创建多少个线程。以便为完成端口提供服务呢?这实际正是完成端口模型显得颇为“复杂”的—个方面, 因为服务I/O请求所需的数量取决于应用程序的总体设计情况。在此要记住的—个重点在于,在调用CreateIoComletionPort时指定的并发线程数量,与打算创建的工作者线程数量相比,它们代表的并非同—件事情。早些时候,曾建议大家用CreateIoCompletionPort
函数为每个处理器都指定一个线程(处理器的数量有多少,便指定多少线程)以避免由于频繁的线程“场景”交换活动,从而影响系统的整体性能。CreateIoCompletionPort
函数的NumberofConcurrentThreads
参数明确指示系统: 在一个完成端口上,一次只允许n个工作者线程运行。假如在完成端门上创建的工作者线程数量超出n个.那么在同一时刻,最多只允许n个线程运行。但实际上,在—段较短的时间内,系统有可能超过这个值。但很快便会把它减少至事先在CreateIoCompletionPor
t函数中设定的值。那么,为何实际创建的工作者线程数最有时要比CreateIoCompletionPort
函数设定的多—些呢?这样做有必要吗?如先前所述。这主要取决于应用程序的总体设计情况,假设我们的工作者线程调用了一个函数,比如Sleep()
或者WaitForSingleobject()
,但却进入了暂停(锁定或挂起)状态、那么允许另—个线程代替它的位置。换行之,我们希望随时都能执行尽可能多的线程;当然,最大的线程数量是事先在CreateIoCompletonPort
调用里设定好的。这样—来。假如事先预料到自己的线程有可能暂时处于停顿状态,那么最好能够创建比CreateIoCompletionPort
的NumberofConcurrentThreads
参数的值多的线程.以便到时候充分发挥系统的潜力。—旦在完成端口上拥有足够多的工作者线程来为I/O请求提供服务,便可着手将套接字句柄同完成端口关联到一起。这要求我们在—个现有的完成端口上调用CreateIoCompletionPort
函数,同时为前三个参数: FileHandle
,ExistingCompletionPort
和CompletionKey
——提供套接字的信息。其中,FileHandle
参数指定—个要同完成端口关联在—一起的套接字句柄。
ExistingCompletionPort
参数指定的是一个现有的完成端口。CompletionKey
(完成键)参数则指定要与某个特定套接字句柄关联在—起的“单句柄数据”,在这个参数中,应用程序可保存与—个套接字对应的任意类型的信息。之所以把它叫作“单句柄数据”,是由于它只对应着与那个套接字句柄关联在—起的数据。可将其作为指向一个数据结构的指针、来保存套接字句柄;在那个结构中,同时包含了套接字的句柄,以及与那个套接字有关的其他信息。就象稍后还会讲述的那样,为完成端口提供服务的线程例程可通过这个参数。取得与其套字句柄有关的信息。
根据我们到目前为止学到的东西。
首先来构建—个基本的应用程序框架。
如何使用完成端口模型。来开发—个回应(或“反射’)服务器应用。
在这个程序中。我们基本上按下述步骤行事:
1) 创建一个完成端口。第四个参数保持为0,指定在完成端口上,每个处理器一次只允许执行一个工作者线程。
2) 判断系统内到底安装了多少个处理器。
3) 创建工作者线程,根据步骤2)得到的处理器信息,在完成端口上,为已完成的I/O请求提供服务。在这个简单的例子中,我们为每个处理器
都只创建—个工作者线程。这是出于事先已经预计到,到时候不会有任何线程进入“挂起”状态,造成由于线程数量的不足,而使处理器空闲
的局面(没有足够的线程可供执行)。调用CreateThread函数时,必须同时提供—个工作者线程,由线程在创建好执行。本节稍后还会详细
讨论线程的职责。
4) 准备好—个监听套接字。在端口5150上监听进入的连接请求。
5) 使用accept函数,接受进入的连接请求。
6) 创建—个数据结构,用于容纳“单句柄数据”。 同时在结构中存入接受的套接字句柄。
7) 调用CreateIoCompletionPort将自accept返回的新套接字句柄向完成端口关联到一起,通过完成键(CompletionKey)参数,将但句柄
数据结构传递给CreateIoCompletionPort。
8) 开始在已接受的连接上进行I/O操作。在此,我们希望通过重叠I/O机制,在新建的套接字上投递一个或多个异步WSARecv或WSASend请
求。这些I/O请求完成后,一个工作者线程会为I/O请求提供服务,同时继续处理未来的I/O请求,稍后便会在步骤3)指定的工作者例程中。
体验到这一点。
9) 重复步骤5)—8)。直到服务器终止。
StartWinsock()
//步骤一,创建一个完成端口
CompletionPort=CreateIoCompletionPort(INVALI_HANDLE_VALUE,NULL,0,0);
//步骤二判断有多少个处理器
GetSystemInfo(&SystemInfo);
//步骤三:根据处理器的数量创建工作线程,本例当中,工作线程的数目和处理器数目是相同的
for(i = 0; i < SystemInfo.dwNumberOfProcessers,i++){
HANDLE ThreadHandle;
//创建工作者线程,并把完成端口作为参数传给线程
ThreadHandle=CreateThread(NULL,0,ServerWorkerThread,CompletionPort,0, &ThreadID);
//关闭线程句柄(仅仅关闭句柄,并非关闭线程本身)
CloseHandle(ThreadHandle);
}
//步骤四:创建监听套接字
Listen=WSASocket(AF_INET,S0CK_STREAM,0,NULL,WSA_FLAG_OVERLAPPED);
InternetAddr.sin_famlly=AF_INET;
InternetAddr.sin_addr.s_addr = htonl(INADDR_ANY);
InternetAddr.sln_port = htons(5150);
bind(Listen,(PSOCKADDR)&InternetAddr,sizeof(InternetAddr));
//准备监听套接字
listen(Listen,5);
while(TRUE){...}
//步骤五,接入Socket,并和完成端口关联
Accept = WSAAccept(Listen,NULL,NULL,NULL,0);
//步骤六 创建一个perhandle结构,并和端口关联
PerHandleData=(LPPER_HANDLE_DATA)GlobalAlloc(GPTR,sizeof(PER_HANDLE_DATA));
printf("Socket number %d connected\n",Accept);
PerHandleData->Socket=Accept;
//步骤七,接入套接字和完成端口关联
CreateIoCompletionPort((HANDLE)Accept,CompletionPort,(DWORD)PerHandleData,0);
//步骤八
//开始进行I/O操作,用重叠I/O发送一些WSASend()和WSARecv()
WSARecv(...)
将套接字句柄与一个完成端口关联在一起后,便可以套接字句柄为基础。投递发送或接
收请求。开始I/O请求的处理。接下来,可开始依赖完成端口,来接收有关I/O操作完成情况的通知。从本质上说、完成瑞口模型利用了Win32重叠I/O机制。在这种机制中。象WSASend和WSARecv这样的Winsock API调用会立即返回。此时, 需要由我们的应用程序负责在以后的某个时间。通过一个OVERLAPPED
结构,来接收调用的结果。在完成端口模型中。要想做到这一点,需要使用GetQueuedCompletionStatus
(获取排队完成状态)函数。让一个或者多个工作者线程在完成端口上等待。该函数的定义如下:
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort,
LPDWORD lpNumberOfBytesTransferred,
LPDWORD lpCompletionKey,
LPOVERLAPPED *lpOverlapped,
DWORD dwMilliseconds
)
其中,
CompletionPort
参数对应与要在上面等待的完成端口。
lpNumberOfBytesTransferred参数负责在完成了—次I/O操作后(如WSASend或WSARecv)、接收实际传输的字节数。
lpCompletionKey
参数为原先传递进入CreateCompletionPort
函数的套接字返回“单句柄数据”。如我们早先所述,大家最好将套接字句柄保存在这个“键”(Key)中。
lpOverlapped
参数用于接收完成的I/O操作的重叠结果。这实际是一个相当重要的参数,因为要用它获取每个I/O操作的数据。
DwMilliseconds
用于指定调用者希望等待一个完成数据包在完成端门上出现的时间。假如将其设为INFINITE
。调用会无休止地等持下去。
—个工作者线程从GetQueuedCompletionStatus
这个API调用接收到I/O完成通知后。在lpCompletionKey
和lpOverlapped
参数中,会包含—些必要的套接字信息。利用这些信息,可通过完成端口,继续在一个套接字上的I/O处理,通过这些参数。可获得两方面重要的套接字数据: 单句柄数据, 以及 单I/O操作数据。
其中,lpCompletionKey
参数包含了“单句柄数据”,因为在—个套接字首次与完成端口关联到—起的时候。那些数据便与一个特定的套接字句柄对应起来了。这些数据正是我们在进行CreateIoCompletionPort
API调用的时候,通过CompletionKey
参数传递的。 如早先所述。应用程序可通过该参数传递任意类型的数据。通常情况下,应用程序会将与I/O请求有关的套接字句柄保存在这里。
lpOVerlapped
参数则包含了—个OVERLAPPED
结构,在它后边跟随“单I/O操作数据”。
我们的工作者线程处理—个完成数据包时(将数据原封不动打转回去,接受连接,投递另—个线程,等等). 这些信息是它必须要知道的. 单I/O操作数据可以是追加到一个OVERLAPPED
结构末尾的任意数量的字节。假如一个函数要求用到一个OVERLAPPED
结构,我们便必须将这样的—个结构传递进去,以满足它的耍求。要想做到这一点,一个简单的方法是定义—个结构。然后将OVERLAPPED
结构作为新结构的第一个元素使用。举个例子来说。 可定义上述数据结构,实现对单I/O操作数据的管理:
typedef struct {
OVERLAPPED Overlapped;
WSABUF DataBuf;
CHAR Bufferl[DATA_BUFSIZE];
BOOL OperationType;
}PER_IO_OPERATION_DATA;
该结构演示了通常要与I/O操作关联在—起的某些重要数据元素,比如刚才完成的那个I/O操作的类型(发送或接收请求).在这个结构中。我们认为用于已完成I/O操作的数据缓冲区是非常有用的。要想调用—个Winsock API函数,同时为其分配一个OVERLAPPED
结构,既可将自己的结构“造型”为一个OVERLAPPED
指针,亦可简单地撤消对结构中的OVBRLAPPED
元素的引用。如下例所示:
PER_IO_OPERATION_DATA PerIoData;
可以想下边这样调用一个函数
WSARecv(socket,…,(OVERLAPPED *)&PerIoData;
或者像下边这样
WSARecv(socket,…,&( PerIoData.Overlapped));
在工作线程的后面部分。GetQueuedCompletionStatus
函数返回了—个重叠结构(和完成键)后。便可通过撤消对OperationType成员的引用。调查到底是哪个操作投递到了这个句柄之上(只需将返回的重叠结构造型为自的PER_IO_OPERATlON_DATA
结构)。对单I/O操作数据来说,它最大的—个优点便是允许我们在同一个句柄上。同时管理多个I/O操作(读/写、多个读、多个写,等等)。大家此时或许会产生这样的疑问:在同—个套接字上,真的有必要同时投递多个I/O操作吗?答案在于系统的“伸缩性”,或者说“扩展能力”。例如,假定我们的机器安装了多个中央处理器。每个处理器都在远行一个工作者线程,那么在同一个时候、完全可能有几个不同的处理器在同一个套接字上,进行数据的收发操作。
为了完成前述的简单回应服务器示例,我们需要提供一个ServerWorkerThread
(服务器工作者线程)函数。设计一个工作者线程例程,令其使用单句柄数据以及单I/O操作数据,为I/O请求提供服务。
最后要注意的一处细节是如何正确地关闭I/O完成端口一—特别是同时运行了一个或多个线程,在几个不同的套接字上执行I/O操作的时候。要避免的一个重要问题是在进行重叠I/O操作的同时,强行释放—个OVERLAPPED
结构。要想避免出现这种情况,最好的办法是针对每个套接字句柄,调用closesocket
函数。任何尚未进行的重叠I/O操作都会完成。—旦所有套接字句柄都已关闭。便需在完成端口上,终止所有工作者线程的运行。要想做到这一点,需要使用PostQueuedCompletionStatus
函数,向每个工作者线程都发送—个特殊的完成数据包。该函数会指示每个线程都“立即结束并退出”.下面是PostQueuedCompletionStatus
函数的定义:
BOOL PostQueuedCompletionStatus(
HANDLE CompletlonPort,
DW0RD dwNumberOfBytesTrlansferred,
DWORD dwCompletlonKey,
LPOVERLAPPED lpoverlapped,
);
其中,CompletionPort
参数指定想向其发送一个完成数据包的完成端口对象。而就dwNumberOfBytesTransferred
,dwCompletionKey
和lpOverlapped
这三个参数来说.每—个都允许我们指定—个值,直接传递给GetQueuedCompletionStatus
函数中对应的参数。这样—来。—个工作者线程收到传递过来的三个GetQueuedCompletionStatus
函数参数后,便可根据由这三个参数的某一个设置的特殊值,决定何时应该退出。例如,可用dwCompletionPort
参数传递0值,而—个工作者线程会将其解释成中止指令。一旦所有工作者线程都已关闭,便可使用CloseHandle
函数,关闭完成端口。最终安全退出程序。
另外还有几种颇有价值的技术。可用来进—步改善套接字应用程序的总体I/O性能。
值得考虑的一项技术是试验不同的套接字缓冲区大小,以改善I/O性能和应用程序的扩展能力。例如,假如某个程序只采用了—个比较大的缓冲区,仅能支持—个wSARecv请求,而不是同时设置了三个较小的缓冲区。提供对三个WSARecv请求的支持,那么该程序的扩展能力并不是很好,特别是在转移到安装了多个处理器的机器上之后。这是由于单独一个缓冲区每次只能处理一个线程!除此以外,单缓冲区设计还会对性能造成一定的干扰,假如—次仅能进行—次接收操作。网络协议驱动程序的潜力使不能得到充分发挥(它经常都会很“闲”)。换言之,假如在接收更多的数据前、需要等待—次WSARecv操作的完成,那么在WSARecv完成和下一次接收之间,整个协议实际上处于“休息”状态。
另—个值得考虑的性能改进措施是用套接宇选项SO_SNDBUF和SO_RCVBUF对内部套接字缓冲区的大小进行控制。利用这些选项,应用程序可更改—个套接字的内部数据缓冲区的大小。如将该设为0,Winsock便会在重叠I/O调用中直接使用应用程序的缓冲区、进行数据在协议堆栈里的传人,传出。这样一来,在应用程序与Winsock之间,便避免了进行—次缓冲区复制的必要。下述代码片断阐释了如何使用SO_SNDBUF选项,来进行setsockopt函数的调用:
setsockopt(socket,SOL_S0CKET,SO_SNDBUF,(char *)&nZero,sizeof(nZero));
要注意的是,将这些缓冲区的大小设为0后,只有在一段给定的时间内,存在着多个I/O请求的前提下才会产生积极作用。
提升性能的最后一项措施是使用
AcceptEx
这个API调用,来进行连接请求的处理,并投递少量数据。这样一来,我们的应用程序只需通过一次API调用,便可为一次接受请求和数据的接收提供服务。从而减少了单独进行accept和WSARecv调用造成的开销。这样做还有另一个好处,我们可使完成端口为AcceptEx提供服务,因为它也提供了一个OVERLAPPED结构。
假如事先预计到自己的服务器应用在一个连接建立好之后,只会进行少量的recv-send(收发)操作,那么AcceptEx便显得相当有用(比如在设计一个Web服务器的时候)。否则的话,接受一个连接后,假如程序要负责数百上千次数据 的传输操作,这样的对性能便没有多大的助益。最后提醒大家,注意,在Winsock中,一个Winsock应用不应使用ReadFile和 WriteFile 这两个Win32函数,在一个完成端口上进行IO处理。尽管这两个函数确实提供了一个
OVERLAPPED
结构,而且可在完成端口上成功的使用,但就在Winsock 2环境下进行IO处理来说,WSARecv和 WSASend 这两个函数却进行了更大程序的优化。若使用ReadFile WriteFile ,需在进行大量不必要的内核/用户模式进行调用、线程执行场景的频繁切换以及参数的汇集等等,使总体性能大打折扣。
-----------------------------------------------------------------------------------------------------------------------
—--------------------------------------------------------------------------------------------------------------------
-----------------------------------------------------------------------------------------------------------------------
重叠结构(OVERLAPPED)
要实现异步通信,必须要用到一个I/O数据结构,叫重叠结构“Overlapped”,Windows里所有的异步通信都是基于它的,完成端口也不例外。
至于为什么叫Overlapped?Jeffrey Richter的解释是因为“执行I/O请求的时间与线程执行其他任务的时间是重叠(overlapped)的”,从这个名字我们也可能看得出来重叠结构发明的初衷了,对于重叠结构的内部细节我这里就不过多的解释了,就把它当成和其他内核对象一样,不需要深究其实现机制,只要会使用就可以了,想要了解更多重叠结构内部的朋友,请去翻阅Jeffrey Richter的《Windows via C/C++》 5th 的292页。
重叠结构是异步通信机制实现的一个核心数据结构,因为看到后面的代码会发现,几乎所有的网络操作例如发送/接收之类的,都会用WSASend()和WSARecv()代替,参数里面都会附带一个重叠结构,这是为什么呢?因为重叠结构就可以理解成为是一个网络操作的ID号,也就是说要利用重叠I/O提供的异步机制的话,每一个网络操作都要有一个唯一的ID号,因为进了系统内核,里面黑灯瞎火的,也不了解上面出了什么状况,一看到有重叠I/O的调用进来了,就会使用其异步机制,并且操作系统就只能靠这个重叠结构带有的ID号来区分是哪一个网络操作了,然后内核里面处理完毕之后,根据这个ID号,把对应的数据传上去。
完成端口(CompletionPort)
首先,它之所以叫“完成”端口,就是说系统会在网络I/O操作“完成”之后才会通知我们,也就是说,我们在接到系统的通知的时候,其实网络操作已经完成了,就是比如说在系统通知我们的时候,并非是有数据从网络上到来,而是来自于网络上的数据已经接收完毕了;或者是客户端的连入请求已经被系统接入完毕了等等,我们只需要处理后面的事情就好了。
其次,我们需要知道,所谓的完成端口,其实和HANDLE一样,也是一个内核对象,虽然Jeff Richter说:“完成端口可能是最为复杂的内核对象了”,但是我们也不用去管他,因为它具体的内部如何实现的和我们无关,只要我们能够学会用它相关的API把这个完成端口的框架搭建起来就可以了。我们暂时只用把它大体理解为一个容纳网络通信操作的队列就好了,它会把网络操作完成的通知,都放在这个队列里面,咱们只用从这个队列里面取就行了,取走一个就少一个…。
使用完成端口的基本流程
使用完成端口一般按以下步骤(这里以网络服务器接受客户端连接并与客户端进行网络通信为例):
步骤1:创建完成端口
步骤2:创建侦听socket并将侦听socket绑定到完成端口上
步骤3:设置侦听
步骤1代码:
m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
步骤2代码:
//创建侦听socket
m_pListenContext->m_Socket = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
// 将Listen Socket绑定至完成端口中
if( NULL== CreateIoCompletionPort( (HANDLE)m_pListenContext->m_Socket, m_hIOCompletionPort,(ULONG_PTR)m_pListenContext, 0))
{
return false;
}
return true;
注意,想要使用重叠I/O的话,初始化Socket的时候一定要使用WSASocket并带上WSA_FLAG_OVERLAPPED参数才可以(只有在服务器端需要这么做,在客户端是不需要的);
既然要使用完成端口来帮我们进行监听工作,那么我们一定要把这个监听Socket和完成端口绑定才可以的吧:
如何绑定呢?同样很简单,用 CreateIoCompletionPort()函数。
这个和前面那个创建完成端口用的居然是同一个API!但是这里这个API可不是用来建立完成端口的,而是用于将Socket和以前创建的那个完成端口绑定的,因为他们的参数是明显不一样的,前面那个的参数是一个-1,三个0。
CreateIoCompletionPort()的几个参数:
HANDLE WINAPI CreateIoCompletionPort(
__in HANDLE FileHandle, // 这里当然是连入的这个套接字句柄了
__in_opt HANDLE ExistingCompletionPort, // 这个就是前面创建的那个完成端口
__in ULONG_PTR CompletionKey, // 这个参数就是类似于线程参数一样,在
// 绑定的时候把自己定义的结构体指针传递
// 这样到了Worker线程中,也可以使用这个
// 结构体的数据了,相当于参数的传递
__in DWORD NumberOfConcurrentThreads // 这里同样置0
);
步骤3代码:
// 服务器地址信息,用于绑定Socket
struct sockaddr_in ServerAddress;
// 填充地址信息
ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress));
ServerAddress.sin_family = AF_INET;
// 这里可以绑定任何可用的IP地址,或者绑定一个指定的IP地址
ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);
//ServerAddress.sin_addr.s_addr = inet_addr(CStringA(m_strIP).GetString());
ServerAddress.sin_port = htons(m_nPort);
// 绑定地址和端口
if (SOCKET_ERROR == bind(m_pListenContext->m_Socket, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress)))
return false;
// 开始进行监听
if (SOCKET_ERROR == listen(m_pListenContext->m_Socket,SOMAXCONN))
return false;
return true;
以上步骤都是完成端口约定俗成的套路,现在接下来的问题是如何接受客户端连接?
难点一: 使用AcceptEx代替accept时,完成端口模型让操作系统替我们接受新连接
不管是使用select还是epoll这里模型无非都是检测到侦听socket可读,然后在调用accept函数接受连接,这样存在一个问题,就是侦听socket只有一个,所以调用accept函数接受连接的逻辑也只能有一个(一般不会在多线程里面对同一个socket进行同一种操作)。但是如果是这样的话,如果同一时间有大量的连接来了,可能就要逐个接受连接了,相当于一群人排队进入一个门里面,那有没有更好的方法呢?有,windows提供了一个AcceptEx函数,在创建完侦听函数之后,调用这个函数,那么将来在完成端口的工作线程里面如果有接受新连接动作,则无需调用accept或者AcceptEx,操作系统自动帮你接受新连接,等在工作线程里面得到通知的时候,连接已经建立,而且新的客户端socket也已经创建好。注意:这是完成端口的另外一个优势,如果使用accept,不仅需要使用accept接受新连接,同时需要在连接现场建立一个socket,而使用AcceptEx,这两个步骤都不需要了。AcceptEx函数签名如下:
BOOL AcceptEx(
_In_ SOCKET sListenSocket,
_In_ SOCKET sAcceptSocket,
_In_ PVOID lpOutputBuffer,
_In_ DWORD dwReceiveDataLength,
_In_ DWORD dwLocalAddressLength,
_In_ DWORD dwRemoteAddressLength,
_Out_ LPDWORD lpdwBytesReceived,
_In_ LPOVERLAPPED lpOverlapped
);
注意看第二个参数sAcceptSocket,这个socket我们在初始化的时候需要准备好,将来新连接成功以后,可以直接使用这个socket表示客户端连接。但是你可能又会问,我初始化阶段需要准备多少个这样的socket呢?毕竟不可能多个连接使用同一个sAcceptSocket。的确如此,所以一般初始化的时候准备一批客户端socket,等工作线程有新连接成功后,表明开始准备的某个客户端socket已经被使用了,这个时候我们可以继续补充一个。相当于,我们预先准备五个容器,在使用过程中每次使用一个,我们就立刻补充一个。当然,这个AcceptEx这个函数不仅准备了接受连接操作,同时也准备了连接的两端的地址缓冲区和对端发来的第一组数据缓冲区,将来有新连接成功以后,操作系统通知我们的时候,操作系统不仅帮我门接收好了连接,还将连接两端的地址和对端发过来的第一组数据填到我们指定的缓冲区了。当然msdn上说使用这个函数最好不要直接使用,而是通过相应API获取该函数的指针,再调用之。
代码应该写成这样:
// 使用AcceptEx函数,因为这个是属于WinSock2规范之外的微软另外提供的扩展函数
// 所以需要额外获取一下函数的指针,
// 获取AcceptEx函数指针
DWORD dwBytes = 0;
if(SOCKET_ERROR == WSAIoctl(
m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidAcceptEx,
sizeof(GuidAcceptEx),
&m_lpfnAcceptEx,
sizeof(m_lpfnAcceptEx),
&dwBytes,
NULL,
NULL))
{
this->_ShowMessage(_T("WSAIoctl 未能获取AcceptEx函数指针。错误代码: %d\n"), WSAGetLastError());
return false;
}
当然,WSAIoctl函数第一个参数只要填写任意一个有效的socket就可以了。
采用AcceptEx方式的流程示意图如下:
而AcceptEx比Accept又强大在哪里呢?是有三点:
(1) 这个好处是最关键的,是因为AcceptEx是在客户端连入之前,就把客户端的Socket建立好了,也就是说,AcceptEx是先建立的Socket,
然后才发出的AcceptEx调用,也就是说,在进行客户端的通信之前,无论是否有客户端连入,Socket都是提前建立好了;而不需要像
accept是在客户端连入了之后,再现场去花费时间建立Socket。
(2) 相比accept只能阻塞方式建立一个连入的入口,对于大量的并发客户端来讲,入口实在是有点挤;而AcceptEx可以同时在完成端口上投
递多个请求,这样有客户端连入的时候,就非常从容不迫的处理连入请求了。
(3) AcceptEx还有一个非常体贴的优点,就是在投递AcceptEx的时候,我们还可以顺便在AcceptEx的同时,收取客户端发来的第一组数据,
这个是同时进行的,也就是说,在我们收到AcceptEx完成的通知的时候,我们就已经把这第一组数据接完毕了;但是这也意味着,如果
客户端只是连入但是不发送数据的话,我们就不会收到这个AcceptEx完成的通知。
难点二:完成端口模型让操作系统替我们进行数据收发
NO1. 写过网络通信程序的人都知道,尤其是服务器端程序,我们不能直接调用send和recv这类函数进行数据收发,因为当tcp窗口太小时,数据发不出去,send会阻塞线程,同理,如果当前网络缓冲区没有数据,调用recv也会阻塞线程。这是入门级的做法。
NO2. 既然上述做法不好,那我就换成主动检测数据是否可以收发,当数据可以收发的时候,再调用send或者recv函数进行收发。这就是常用的IO复用函数的用途,如select函数、linux下的poll函数。这是中级做法。
NO3. 使用IO复用技术主动检测数据是否可读可写,也存在问题。如果检测到了数据可读或可写,那这种检测就是值得的;但是反之检测不到呢?那也是白白地浪费时间的。如果有一种方法,我不需要主动去检测,我只需要预先做一个部署,当有数据可读或者可写时,操作系统能通知我就好了,而不是每次都是我自己去主动检测。有,这就是linux下的epoll模型和windows下的WSAAsyncSelect和完成端口模型。这是高级做法。
NO4. 但是无论是epoll模型还是WSAAsyncSelect模型,虽然操作系统会告诉我们什么时候数据可读或者可写,但是当数据可读或者可写时,还是需要我们自己去调用send或者recv函数做实际的收发数据工作。那有没有一种模型,不仅能通知我们数据可读和可写,甚至当数据可读或者可写时,连数据的收发工作也帮我们做好了?有,这就是windows的完成端口模型。
这就是标题所说的完成端口将IO操作从手动变为自动,完成端口将数据的可读与可写检测操作和收发数据操作这两项工作改为操作系统代劳,等系统完成之后会通知我们的,而我们只需要在这之前做一些相应的部署(初始化工作)就可以了。 那么需要做那些初始化工作呢?这里我们以收发网络数据为例。
对于收数据,我们只需要准备好,存放数据的缓冲区就可以了:
// 初始化变量
DWORD dwFlags = 0;
DWORD dwBytes = 0;
WSABUF *p_wbuf = &pIoContext->m_wsaBuf;
OVERLAPPED *p_ol = &pIoContext->m_Overlapped;
pIoContext->ResetBuffer();
pIoContext->m_OpType = RECV_POSTED;
// 初始化完成后,,投递WSARecv请求
int nBytesRecv = WSARecv( pIoContext->m_sockAccept, p_wbuf, 1, &dwBytes, &dwFlags, p_ol, NULL );
// 如果返回值错误,并且错误的代码并非是Pending的话,那就说明这个重叠请求失败了
if ((SOCKET_ERROR == nBytesRecv) && (WSA_IO_PENDING != WSAGetLastError()))
{
this->_ShowMessage(_T("投递第一个WSARecv失败!"));
return false;
}
WSARecv函数会立刻返回,不会阻塞,如果返回时数据已经收成功了,那我们准备的缓冲区m_wsaBuf中存放的就是我们收到的数据;否则WASRecv会返回-1(SOCKET_ERROR),此时错误码如果是WSA_IO_PENDING表示收数据暂且还没完成,这样你需要等待后续通知。所以从某种意义上来说WSARecv函数并不是收取数据,而更像是安排让操作系统收数据的设置。
同理,对于发数据,我们也只要准备好需要发送的数据即可:
// 初始化变量
DWORD dwFlags = 0;
DWORD dwBytes = 0;
WSABUF *p_wbuf = &pIoContext->m_wsaBuf;
OVERLAPPED *p_ol = &pIoContext->m_Overlapped;
pIoContext->ResetBuffer();
pIoContext->m_OpType = SEND_POSTED;
// 初始化完成后,,投递WSARecv请求
int nBytesSend = WSASend pIoContext->m_sockAccept, p_wbuf, 1, &dwBytes, &dwFlags, p_ol, NULL );
// 如果返回值错误,并且错误的代码并非是Pending的话,那就说明这个重叠请求失败了
if ((SOCKET_ERROR == nBytesSend) && (WSA_IO_PENDING != WSAGetLastError()))
{
this->_ShowMessage(_T("发送数据失败!"));
return false;
}
发数据的代码基本上和收数据一模一样。
上面介绍了一些不成体系的代码片段,那么我们应该怎么把上面介绍的代码组织成一个整体呢?完成端口模型,需要初始化步骤中还需要建立一些工作线程,这些工作线程就是用来处理各种操作系统的通知的,比如有新客户端连接成功了、数据收好了、数据发送好了等等。创建工作线程以及准备新连接到来时需要的一些容器的代码(上文介绍过了,如一些acceptSocket、两端地址缓冲区、第一份收到的数据缓冲区):
根据系统中CPU核心的数量建立对应的Worker线程
Worker线程很重要,是用来具体处理网络请求、具体和客户端通信的线程,而且对于线程数量的设置很有意思,要等于系统中CPU的数量,那么首先获取系统中CPU的数量,代码如下:
SYSTEM_INFO si;
GetSystemInfo(&si);
int m_nProcessors = si.dwNumberOfProcessors*2;
CPU是8核,一共启动了16个Worker线程:
最好是建立CPU核心数量*2那么多的线程,这样更可以充分利用CPU资源,因为完成端口的调度是非常智能的,比如我们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的情况,这样同一个CPU核心上的另一个线程就可以代替这个Sleep的线程执行了;因为完成端口的目标是要使得CPU满负荷的工作。
这里也有人说是建立 CPU“核心数量 * 2 +2”个线程,这个应该没有什么太大的区别。
然后按照这个数量,来启动这么多个Worker线程就好可以了,接下来开始下一个步骤。
创建工作线程:
DWORD nThreadID;
for (int i = 0; i < m_nThreads; i++)
{
THREADPARAMS_WORKER* pThreadParams = new THREADPARAMS_WORKER;
pThreadParams->nThreadNo = i+1;
m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, (void *)pThreadParams, 0, &nThreadID);
}
调用AcceptEx为将来接受新连接准备:
// 为AcceptEx 准备参数,然后投递AcceptEx I/O请求
for( int i=0;i<MAX_POST_ACCEPT;i++ )
{
// 新建一个IO_CONTEXT
PER_IO_CONTEXT* pAcceptIoContext = m_pListenContext->GetNewIoContext();
if( false==this->_PostAccept( pAcceptIoContext ) )
{
m_pListenContext->RemoveContext(pAcceptIoContext);
return false;
}
}
//
// 投递Accept请求
bool CIOCPModel::_PostAccept( PER_IO_CONTEXT* pAcceptIoContext )
{
ASSERT( INVALID_SOCKET!=m_pListenContext->m_Socket );
// 准备参数
DWORD dwBytes = 0;
pAcceptIoContext->m_OpType = ACCEPT_POSTED;
WSABUF *p_wbuf = &pAcceptIoContext->m_wsaBuf;
OVERLAPPED *p_ol = &pAcceptIoContext->m_Overlapped;
// 为以后新连入的客户端先准备好Socket( 这个是与传统accept最大的区别 )
pAcceptIoContext->m_sockAccept = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
if( INVALID_SOCKET==pAcceptIoContext->m_sockAccept )
{
_ShowMessage(_T("创建用于Accept的Socket失败!错误代码: %d"), WSAGetLastError());
return false;
}
// 投递AcceptEx
if(FALSE == m_lpfnAcceptEx( m_pListenContext->m_Socket, pAcceptIoContext->m_sockAccept, p_wbuf->buf, p_wbuf->len - ((sizeof(SOCKADDR_IN)+16)*2),
sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, &dwBytes, p_ol))
{
if(WSA_IO_PENDING != WSAGetLastError())
{
_ShowMessage(_T("投递 AcceptEx 请求失败,错误代码: %d"), WSAGetLastError());
return false;
}
}
return true;
}
这里我开始准备了MAX_POST_ACCEPT=10个socket。
而工作线程的线程函数应该看起来是这个样子:
DWORD ThreadFunction()
{
//使用GetQueuedCompletionStatus函数检测事件类型
if (事件类型 == 有新客户端连成功)
{
//做一些操作1,比如显示一个新连接信息
}
else if (事件类型 == 收到了一份数据)
{
//做一些操作2,比如解析数据
}
else if (事件类型 == 数据发送成功了)
{
//做一些操作3,比如显示一条数据发送成功信息
}
}
在没有事件发生时,函数GetQueuedCompletionStatus()
会让工作线程挂起,不然不会占用cpu时间片。
但是不知道你有没有发现线程函数存在以下问题:
-
GetQueuedCompletionStatus函数如何确定事件类型?如何判断哪些事件是客户端连接成功事件,哪些事件是收发数据成功事件呢?
-
当一个完成端口上绑定多个socket时,这些socket有的是侦听socket,有的是客户端socket,如何判断到底是哪个socket呢?
奥妙就在某个socket与完成端口句柄绑定时的第三个参数CompletionKey,这其实就是一个指针。
HANDLE WINAPI CreateIoCompletionPort(
_In_ HANDLE FileHandle,
_In_opt_ HANDLE ExistingCompletionPort,
_In_ ULONG_PTR CompletionKey,
_In_ DWORD NumberOfConcurrentThreads
);
GetQueuedCompletionStatus函数签名如下:
BOOL WINAPI GetQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_Out_ LPDWORD lpNumberOfBytes,
_Out_ PULONG_PTR lpCompletionKey,
_Out_ LPOVERLAPPED *lpOverlapped,
_In_ DWORD dwMilliseconds
);
看到没有,GetQueuedCompletionStatus正好也有一个参数叫CompletionPort,而且还是一个输出参数。没错!这两个其实就是同一个指针。这样如果我在绑定socket到完成端口句柄时使用一块内存的指针作为CompletionKey的值,该内存含有该socket的信息,这样我在工作线程中收到事件通知时就能取出这个CompletionKey来得到这个socket句柄了,这样我就知道到底是哪个socket上的事件了。伪码如下:
struct SOME_STRUCT
{
SOCKET s;
//可以再定义一些其它信息一起携带
};
//对于侦听socket
SOME_STRUCT someStruct1;
someStruct1.s = ListenSocket;
CreateIoCompletionPort( ListenSocket, m_hIOCompletionPort,(DWORD)&someStruct1, 0);
//对于普通客户端连接socket
SOME_STRUCT someStruct2;
someStruct2.s = acceptSocket;
CreateIoCompletionPort( acceptSocket, m_hIOCompletionPort,(DWORD)&someStruct2, 0);
其实这个SOME_STRUCT因为是每一个socket有一份,所以它有个名字叫“Per Socket Data”。
线程函数里面就应该写成这个样子:
DWORD ThreadFunction()
{
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(m_hIOCompletionPort, &dwBytesTransfered, (PULONG_PTR)&pSocketContext, &pOverlapped, INFINITE);
if (((SOME_STRUCT*)pSocketContext)->s == 侦听socket句柄)
{
//新连接接收成功,做一些操作
}
//普通客户端socket收发数据
else
{
if (事件类型 == 收到了一份数据)
{
//做一些操作2,比如解析数据
}
else if (事件类型 == 数据发送成功了)
{
//做一些操作3,比如显示一条数据发送成功信息
}
}
}
现在另外一个问题就是,如何判断是数据发送成功还是收到了数据?前面已经说过,对于每一次的收发数据,都需要调用WSASend或WSARecv函数进行准备,而这两个函数需要一个OVERLAPPED结构体,反正传得是这个结构体的指针,我们可以根据指针对象的伸缩特性,在这个OVERLAPPED结构体后面再增加一些字段来标识我们是收数据动作还是发数据动作。而这个扩展的OVERLAPPED结构体,因为是针对每一次IO操作的,所以叫“Per IO Data”。因此这个数据结构的第一个字段必须是一个OVERLAPPED结构体:
typedef struct _PER_IO_CONTEXT
{
OVERLAPPED m_Overlapped; // 每一个重叠网络操作的重叠结构(针对每一个Socket的每一个操作,都要有一个)
SOCKET m_sockAccept; // 这个网络操作所使用的Socket
WSABUF m_wsaBuf; // WSA类型的缓冲区,用于给重叠操作传参数的
char m_szBuffer[MAX_BUFFER_LEN]; // 这个是WSABUF里具体存字符的缓冲区
OPERATION_TYPE m_OpType; // 标识网络操作的类型(对应上面的枚举)
};
我们首先将SOME_STRUCT改名成它应该叫的名字,即_PER_SOCKET_CONTEXT:
typedef struct _PER_SOCKET_CONTEXT
{
SOCKET m_Socket; // 每一个客户端连接的Socket
SOCKADDR_IN m_ClientAddr; // 客户端的地址
CArray<_PER_IO_CONTEXT*> m_arrayIoContext; // 客户端网络操作的上下文数据,
};
我们再次观察GetQueuedCompletionStatus的函数签名会发现,其第三个参数正好就是一个OVERLAPPED结构指针,至此我们在工作线程里面不仅可以知道是哪个socket的事件,同时能通过OVERLAPPED*后面的字段知道是收数据还是发数据:
DWORD ThreadFunction()
{
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(m_hIOCompletionPort, &dwBytesTransfered, (PULONG_PTR)&pSocketContext, &pOverlapped, INFINITE);
if (((SOME_STRUCT*)pSocketContext)->s == 侦听socket句柄)
{
//新连接接收成功,做一些操作
}
//普通客户端socket收发数据
else
{
//通过pOverlapped结构得到pIOContext
PER_IO_CONTEXT* pIOContext = (PER_IO_CONTEXT*)pOverlapped;
if (pIOContext->Type == 收)
{
//做一些操作2,比如解析数据
}
else if (pIOContext->Type == 发)
{
//做一些操作3,比如显示一条数据发送成功信息
}
}
}
小结构体指针转换成大结构体指针操作:PER_IO_CONTEXT* pIOContext = (PER_IO_CONTEXT*)pOverlapped;
微软直接帮我们定义了一个宏CONTAINING_RECORD来操作:
// Calculate the address of the base of the structure given its type, and an
// address of a field within the structure.
#define CONTAINING_RECORD(address, type, field) ((type *)( \
(PCHAR)(address) - \
(ULONG_PTR)(&((type *)0)->field)))
所以上述代码也可以写成:
PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(pOverlapped, PER_IO_CONTEXT, m_Overlapped);
不知道你是否记得前面中说过每消耗一个预先准备客户端的socket,就要补上一个。这个代码现在看来就应该放在连接成功事件里面了:
DWORD ThreadFunction()
{
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(m_hIOCompletionPort, &dwBytesTransfered, (PULONG_PTR)&pSocketContext, &pOverlapped, INFINITE);
if (((SOME_STRUCT*)pSocketContext)->s == 侦听socket句柄)
{
//连接成功后可以做以下事情:
//1. 获取对端和本端的ip地址和端口号,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步,不是必须)
//2. 如果对端连接成功后会发数据过来,则可以从初始化时调用AcceptEx准备的缓冲区里面拿到,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步不是必须)
//3. 再次调用AcceptEx补充一个sAcceptSocket(这一步是必须的)
}
//普通客户端socket收发数据
else
{
//通过pOverlapped结构得到pIOContext
PER_IO_CONTEXT* pIOContext = (PER_IO_CONTEXT*)pOverlapped;
if (pIOContext->Type == 收)
{
//做一些操作2,比如解析数据
}
else if (pIOContext->Type == 发)
{
//做一些操作3,比如显示一条数据发送成功信息
}
}
}
上面连接成功后的伪码,第1步和第2步不是必须的,而第3步是必须的,如果不及时补充的话,等连接数多于准备的socket,可能就会发生故障了。
因为两端的地址信息和对端发过来的第一组数据都在同一个缓冲区里面,再次看下AcceptEx函数签名吧:
BOOL AcceptEx(
_In_ SOCKET sListenSocket,
_In_ SOCKET sAcceptSocket,
_In_ PVOID lpOutputBuffer,
_In_ DWORD dwReceiveDataLength,
_In_ DWORD dwLocalAddressLength,
_In_ DWORD dwRemoteAddressLength,
_Out_ LPDWORD lpdwBytesReceived,
_In_ LPOVERLAPPED lpOverlapped
);
虽然可以根据dwReceiveDataLength、dwLocalAddressLength、dwRemoteAddressLength、lpdwBytesReceived这几个参数计算出来,但是微软提供了一个函数来帮我们做这个解析动作: GetAcceptExSockaddrs 同理,这个函数最好也要通过WSAIoctl函数来动态获取:
// 获取GetAcceptExSockAddrs函数指针,也是同理
if(SOCKET_ERROR == WSAIoctl(
m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidGetAcceptExSockAddrs,
sizeof(GuidGetAcceptExSockAddrs),
&m_lpfnGetAcceptExSockAddrs,
sizeof(m_lpfnGetAcceptExSockAddrs),
&dwBytes,
NULL,
NULL))
{
this->_ShowMessage(_T("WSAIoctl 未能获取GuidGetAcceptExSockAddrs函数指针。错误代码: %d\n"), WSAGetLastError());
this->_DeInitialize();
return false;
}
然后使用返回的函数指针来使用函数GetAcceptExSockaddrs。解析地址信息和第一组数据的代码如下:
SOCKADDR_IN* ClientAddr = NULL;
SOCKADDR_IN* LocalAddr = NULL;
int remoteLen = sizeof(SOCKADDR_IN), localLen = sizeof(SOCKADDR_IN);
/// //
1. 首先取得连入客户端的地址信息 // 这个 m_lpfnGetAcceptExSockAddrs 不但可以取得客户端和本地端的地址信息,还能顺便取出客户端发来的第一组数据,十分强大
this->m_lpfnGetAcceptExSockAddrs(pIoContext->m_wsaBuf.buf, pIoContext->m_wsaBuf.len - ((sizeof(SOCKADDR_IN)+16)*2), sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, (LPSOCKADDR*)&LocalAddr, &localLen, (LPSOCKADDR*)&ClientAddr, &remoteLen);
this->_ShowMessage( _T("客户端 %s:%d 连入."), inet_ntoa(ClientAddr->sin_addr), ntohs(ClientAddr->sin_port) );
this->_ShowMessage( _T("客户额 %s:%d 信息:%s."),inet_ntoa(ClientAddr->sin_addr), ntohs(ClientAddr->sin_port),pIoContext->m_wsaBuf.buf );
以上介绍的是接收新连接成功后的处理,那收数据和发数据的准备工作在哪里做呢?(收取第一组数据可以在调用AcceptEx的地方做)。这个就仁者见仁,智者见智了。比如可以在新连接接收成功之后,立即准备给对端发数据;或者在收到对端数据的时候准备给对端发数据;在发送数据完成后准备收对端数据。伪码如下:
DWORD ThreadFunction()
{
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(m_hIOCompletionPort, &dwBytesTransfered, (PULONG_PTR)&pSocketContext, &pOverlapped, INFINITE);
if (((SOME_STRUCT*)pSocketContext)->s == 侦听socket句柄)
{
//连接成功后可以做以下事情:
//1. 获取对端和本端的ip地址和端口号,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步,不是必须)
//2. 如果对端连接成功后会发数据过来,则可以从初始化时调用AcceptEx准备的缓冲区里面拿到,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步不是必须)
//3. 再次调用AcceptEx补充一个sAcceptSocket(这一步是必须的)
//4. 调用WSASend准备发送数据工作或调用WSARecv准备接收数据工作(这一步,不是必须)
}
//普通客户端socket收发数据
else
{
//通过pOverlapped结构得到pIOContext
PER_IO_CONTEXT* pIOContext = (PER_IO_CONTEXT*)pOverlapped;
if (pIOContext->Type == 收)
{
//解析收到的数据(这一步,不是必须)
//调用WSASend准备发送数据工作(比如应答客户端)(这一步,不是必须)
//继续调用WSARecv准备收取数据工作(这一步,不是必须)
}
else if (pIOContext->Type == 发)
{
//调用WSARecv准备收取数据工作(这一步,不是必须)
}
}
}
现在还剩下最后一个问题,就是工作线程如何退出。当然你可以在每次判断标识位前先判断一个退出标识。但是如果工作线程正好被GetQueuedCompletionStatus挂载那里呢?如何唤醒,微软提供了另外一个函数:PostQueuedCompletionStatus,看下这个函数的签名:
BOOL WINAPI PostQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_In_ DWORD dwNumberOfBytesTransferred,
_In_ ULONG_PTR dwCompletionKey,
_In_opt_ LPOVERLAPPED lpOverlapped
);
这个函数可以唤醒被GetQueuedCompletionStatus函数挂起的工作线程,当然其第三个参数也是一个CompletionKey(dwCompletionKey)。你可以使用这个dwCompletionKey做标识干一些其它的事情,当然设置一个退出码也可以。例如:
PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD)EXIT_CODE, NULL);
这样工作线程里面就可以使用EXIT_CODE来作为退出标志:
DWORD ThreadFunction()
{
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(m_hIOCompletionPort, &dwBytesTransfered, (PULONG_PTR)&pSocketContext, &pOverlapped, INFINITE);
// 如果收到的是退出标志,则直接退出
if ( EXIT_CODE==(DWORD)pSocketContext )
{
return 0;
}
if (((SOME_STRUCT*)pSocketContext)->s == 侦听socket句柄)
{
//连接成功后可以做以下事情:
//1. 获取对端和本端的ip地址和端口号,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步,不是必须)
//2. 如果对端连接成功后会发数据过来,则可以从初始化时调用AcceptEx准备的缓冲区里面拿到,即AcceptEx的第三个参数lpOutputBuffer中拿(这一步不是必须)
//3. 再次调用AcceptEx补充一个sAcceptSocket(这一步是必须的)
//4. 调用WSASend准备发送数据工作或调用WSARecv准备接收数据工作(这一步,不是必须)
}
//普通客户端socket收发数据
else
{
//通过pOverlapped结构得到pIOContext
PER_IO_CONTEXT* pIOContext = (PER_IO_CONTEXT*)pOverlapped;
if (pIOContext->Type == 收)
{
//解析收到的数据(这一步,不是必须)
//调用WSASend准备发送数据工作(比如应答客户端)(这一步,不是必须)
//继续调用WSARecv准备收取数据工作(这一步,不是必须)
}
else if (pIOContext->Type == 发)
{
//调用WSARecv准备收取数据工作(这一步,不是必须)
}
}
return 0;
}
至此,关于完成端口的东西就全部介绍完了。我们总结一下,掌握完成端口的关键在于理解以下几点:
-
完成端口绑定了某个socket后,不仅其事件的读写检测由操作系统完成,而且就算是接受新连接、收发数据的动作也是由操作系统代劳了,操作系统完成后会通知你。等你收到通知时,一切都完成好了。你可以直接取出对应的数据使用。
-
要想第1点介绍的事情由操作系统代劳,你必须预先准备很多数据结构,比如两端的地址结构体、收发缓冲区、和用来表示新连接的socket等等,这些准备工作可能在程序初始化阶段,也可能在工作线程某个事件处理的地方。
-
初始化准备好的各种缓冲区如何在工作线程里面引用到的关键就在于绑定完成端口时CompletionKey和准备收发缓冲区时OVERLAPPED结构体的使用, CompletionKey对应PER Socket Data, OVERLAPPED对应Per IO Data,即CompletionKey是单Socket数据,OVERLAPPED是单IO数据。
完成端口使用中的注意事项
-
Socket的通信缓冲区设置成多大合适?
在x86的体系中,内存页面是以4KB为单位来锁定的,也就是说,就算是你投递WSARecv()的时候只用了1KB大小的缓冲区,系统还是得给你分4KB的内存。为了避免这种浪费,最好是把发送和接收数据的缓冲区直接设置成4KB的倍数。
-
关于完成端口通知的次序问题
调用GetQueuedCompletionStatus() 获取I/O完成端口请求的时候,肯定是用先入先出的方式来进行的。
但是,唤醒那些调用了GetQueuedCompletionStatus()的线程是以后入先出的方式来进行的。
比如有4个线程在等待,如果出现了一个已经完成的I/O项,那么是最后一个调用GetQueuedCompletionStatus()的线程会被唤醒。平常这个次序倒是不重要,但是在对数据包顺序有要求的时候,比如传送大块数据的时候,是需要注意下这个先后次序的。
– 这样如果反复只有一个I/O操作而不是多个操作完成的话,内核就只需要唤醒同一个线程就可以了,而不需要轮着唤醒多个线程,节约了资源,而且可以把其他长时间睡眠的线程换出内存,提到资源利用率。
-
如果各位想要传输文件…
如果各位需要使用完成端口来传送文件的话,这里有个非常需要注意的地方。因为发送文件的做法,按照正常人的思路来讲,都会是先打开一个文件,然后不断的循环调用ReadFile()读取一块之后,然后再调用WSASend ()去发发送。
但是我们知道,ReadFile()的时候,是需要操作系统通过磁盘的驱动程序,到实际的物理硬盘上去读取文件的,这就会使得操作系统从用户态转换到内核态去调用驱动程序,然后再把读取的结果返回至用户态;同样的道理,WSARecv()也会涉及到从用户态到内核态切换的问题 — 这样就使得我们不得不频繁的在用户态到内核态之间转换,效率低下……
而一个非常好的解决方案是使用微软提供的扩展函数TransmitFile()来传输文件,因为只需要传递给TransmitFile()一个文件的句柄和需要传输的字节数,程序就会整个切换至内核态,无论是读取数据还是发送文件,都是直接在内核态中执行的,直到文件传输完毕才会返回至用户态给主进程发送通知。这样效率就高多了。
-
关于重叠结构数据释放的问题
我们既然使用的是异步通讯的方式,就得要习惯一点,就是我们投递出去的完成请求,不知道什么时候我们才能收到操作完成的通知,而在这段等待通知的时间,我们就得要千万注意得保证我们投递请求的时候所使用的变量在此期间都得是有效的。
例如我们发送WSARecv请求时候所使用的Overlapped变量,因为在操作完成的时候,这个结构里面会保存很多很重要的数据,对于设备驱动程序来讲,指示保存着我们这个Overlapped变量的指针,而在操作完成之后,驱动程序会将Buffer的指针、已经传输的字节数、错误码等等信息都写入到我们传递给它的那个Overlapped指针中去。如果我们已经不小心把Overlapped释放了,或者是又交给别的操作使用了的话,谁知道驱动程序会把这些东西写到哪里去呢?岂不是很崩溃……
下面给出上文中使用到的对完成端口模型封装的类的全部代码:
IOCPModel.h
/*
==========================================================================
Purpose:
* 这个类CIOCPModel是本代码的核心类,用于说明WinSock服务器端编程模型中的
完成端口(IOCP)的使用方法,并使用MFC对话框程序来调用这个类实现了基本的
服务器网络通信的功能。
* 其中的PER_IO_DATA结构体是封装了用于每一个重叠操作的参数
PER_HANDLE_DATA 是封装了用于每一个Socket的参数,也就是用于每一个完成端口的参数
* 详细的文档说明请参考 http://blog.csdn.net/PiggyXP
Notes:
* 具体讲明了服务器端建立完成端口、建立工作者线程、投递Recv请求、投递Accept请求的方法,
所有的客户端连入的Socket都需要绑定到IOCP上,所有从客户端发来的数据,都会实时显示到
主界面中去。
==========================================================================
*/
#pragma once
// winsock 2 的头文件和库
#include <winsock2.h>
#include <MSWSock.h>
#pragma comment(lib,"ws2_32.lib")
// 缓冲区长度 (1024*8)
// 之所以为什么设置8K,也是一个江湖上的经验值
// 如果确实客户端发来的每组数据都比较少,那么就设置得小一些,省内存
#define MAX_BUFFER_LEN 8192
// 默认端口
#define DEFAULT_PORT 12345
// 默认IP地址
#define DEFAULT_IP _T("127.0.0.1")
//
// 在完成端口上投递的I/O操作的类型
typedef enum _OPERATION_TYPE
{
ACCEPT_POSTED, // 标志投递的Accept操作
SEND_POSTED, // 标志投递的是发送操作
RECV_POSTED, // 标志投递的是接收操作
NULL_POSTED // 用于初始化,无意义
}OPERATION_TYPE;
//====================================================================================
//
// 单IO数据结构体定义(用于每一个重叠操作的参数)
//
//====================================================================================
typedef struct _PER_IO_CONTEXT
{
OVERLAPPED m_Overlapped; // 每一个重叠网络操作的重叠结构(针对每一个Socket的每一个操作,都要有一个)
SOCKET m_sockAccept; // 这个网络操作所使用的Socket
WSABUF m_wsaBuf; // WSA类型的缓冲区,用于给重叠操作传参数的
char m_szBuffer[MAX_BUFFER_LEN]; // 这个是WSABUF里具体存字符的缓冲区
OPERATION_TYPE m_OpType; // 标识网络操作的类型(对应上面的枚举)
// 初始化
_PER_IO_CONTEXT()
{
ZeroMemory(&m_Overlapped, sizeof(m_Overlapped));
ZeroMemory( m_szBuffer,MAX_BUFFER_LEN );
m_sockAccept = INVALID_SOCKET;
m_wsaBuf.buf = m_szBuffer;
m_wsaBuf.len = MAX_BUFFER_LEN;
m_OpType = NULL_POSTED;
}
// 释放掉Socket
~_PER_IO_CONTEXT()
{
if( m_sockAccept!=INVALID_SOCKET )
{
closesocket(m_sockAccept);
m_sockAccept = INVALID_SOCKET;
}
}
// 重置缓冲区内容
void ResetBuffer()
{
ZeroMemory( m_szBuffer,MAX_BUFFER_LEN );
}
} PER_IO_CONTEXT, *PPER_IO_CONTEXT;
//====================================================================================
//
// 单句柄数据结构体定义(用于每一个完成端口,也就是每一个Socket的参数)
//
//====================================================================================
typedef struct _PER_SOCKET_CONTEXT
{
SOCKET m_Socket; // 每一个客户端连接的Socket
SOCKADDR_IN m_ClientAddr; // 客户端的地址
CArray<_PER_IO_CONTEXT*> m_arrayIoContext; // 客户端网络操作的上下文数据,
// 也就是说对于每一个客户端Socket,是可以在上面同时投递多个IO请求的
// 初始化
_PER_SOCKET_CONTEXT()
{
m_Socket = INVALID_SOCKET;
memset(&m_ClientAddr, 0, sizeof(m_ClientAddr));
}
// 释放资源
~_PER_SOCKET_CONTEXT()
{
if( m_Socket!=INVALID_SOCKET )
{
closesocket( m_Socket );
m_Socket = INVALID_SOCKET;
}
// 释放掉所有的IO上下文数据
for( int i=0;i<m_arrayIoContext.GetCount();i++ )
{
delete m_arrayIoContext.GetAt(i);
}
m_arrayIoContext.RemoveAll();
}
// 获取一个新的IoContext
_PER_IO_CONTEXT* GetNewIoContext()
{
_PER_IO_CONTEXT* p = new _PER_IO_CONTEXT;
m_arrayIoContext.Add( p );
return p;
}
// 从数组中移除一个指定的IoContext
void RemoveContext( _PER_IO_CONTEXT* pContext )
{
ASSERT( pContext!=NULL );
for( int i=0;i<m_arrayIoContext.GetCount();i++ )
{
if( pContext==m_arrayIoContext.GetAt(i) )
{
delete pContext;
pContext = NULL;
m_arrayIoContext.RemoveAt(i);
break;
}
}
}
} PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;
//====================================================================================
//
// CIOCPModel类定义
//
//====================================================================================
// 工作者线程的线程参数
class CIOCPModel;
typedef struct _tagThreadParams_WORKER
{
CIOCPModel* pIOCPModel; // 类指针,用于调用类中的函数
int nThreadNo; // 线程编号
} THREADPARAMS_WORKER,*PTHREADPARAM_WORKER;
// CIOCPModel类
class CIOCPModel
{
public:
CIOCPModel(void);
~CIOCPModel(void);
public:
// 启动服务器
bool Start();
// 停止服务器
void Stop();
// 加载Socket库
bool LoadSocketLib();
// 卸载Socket库,彻底完事
void UnloadSocketLib() { WSACleanup(); }
// 获得本机的IP地址
CString GetLocalIP();
// 设置监听端口
void SetPort( const int& nPort ) { m_nPort=nPort; }
// 设置主界面的指针,用于调用显示信息到界面中
void SetMainDlg( CDialog* p ) { m_pMain=p; }
protected:
// 初始化IOCP
bool _InitializeIOCP();
// 初始化Socket
bool _InitializeListenSocket();
// 最后释放资源
void _DeInitialize();
// 投递Accept请求
bool _PostAccept( PER_IO_CONTEXT* pAcceptIoContext );
// 投递接收数据请求
bool _PostRecv( PER_IO_CONTEXT* pIoContext );
// 在有客户端连入的时候,进行处理
bool _DoAccpet( PER_SOCKET_CONTEXT* pSocketContext, PER_IO_CONTEXT* pIoContext );
// 在有接收的数据到达的时候,进行处理
bool _DoRecv( PER_SOCKET_CONTEXT* pSocketContext, PER_IO_CONTEXT* pIoContext );
// 将客户端的相关信息存储到数组中
void _AddToContextList( PER_SOCKET_CONTEXT *pSocketContext );
// 将客户端的信息从数组中移除
void _RemoveContext( PER_SOCKET_CONTEXT *pSocketContext );
// 清空客户端信息
void _ClearContextList();
// 将句柄绑定到完成端口中
bool _AssociateWithIOCP( PER_SOCKET_CONTEXT *pContext);
// 处理完成端口上的错误
bool HandleError( PER_SOCKET_CONTEXT *pContext,const DWORD& dwErr );
// 线程函数,为IOCP请求服务的工作者线程
static DWORD WINAPI _WorkerThread(LPVOID lpParam);
// 获得本机的处理器数量
int _GetNoOfProcessors();
// 判断客户端Socket是否已经断开
bool _IsSocketAlive(SOCKET s);
// 在主界面中显示信息
void _ShowMessage( const CString szFormat,...) const;
private:
HANDLE m_hShutdownEvent; // 用来通知线程系统退出的事件,为了能够更好的退出线程
HANDLE m_hIOCompletionPort; // 完成端口的句柄
HANDLE* m_phWorkerThreads; // 工作者线程的句柄指针
int m_nThreads; // 生成的线程数量
CString m_strIP; // 服务器端的IP地址
int m_nPort; // 服务器端的监听端口
CDialog* m_pMain; // 主界面的界面指针,用于在主界面中显示消息
CRITICAL_SECTION m_csContextList; // 用于Worker线程同步的互斥量
CArray<PER_SOCKET_CONTEXT*> m_arrayClientContext; // 客户端Socket的Context信息
PER_SOCKET_CONTEXT* m_pListenContext; // 用于监听的Socket的Context信息
LPFN_ACCEPTEX m_lpfnAcceptEx; // AcceptEx 和 GetAcceptExSockaddrs 的函数指针,用于调用这两个扩展函数
LPFN_GETACCEPTEXSOCKADDRS m_lpfnGetAcceptExSockAddrs;
};
IOCPModel.cpp
#include "StdAfx.h"
#include "IOCPModel.h"
#include "MainDlg.h"
// 每一个处理器上产生多少个线程(为了最大限度的提升服务器性能,详见配套文档)
#define WORKER_THREADS_PER_PROCESSOR 2
// 同时投递的Accept请求的数量(这个要根据实际的情况灵活设置)
#define MAX_POST_ACCEPT 10
// 传递给Worker线程的退出信号
#define EXIT_CODE NULL
// 释放指针和句柄资源的宏
// 释放指针宏
#define RELEASE(x) {if(x != NULL ){delete x;x=NULL;}}
// 释放句柄宏
#define RELEASE_HANDLE(x) {if(x != NULL && x!=INVALID_HANDLE_VALUE){ CloseHandle(x);x = NULL;}}
// 释放Socket宏
#define RELEASE_SOCKET(x) {if(x !=INVALID_SOCKET) { closesocket(x);x=INVALID_SOCKET;}}
CIOCPModel::CIOCPModel(void):
m_nThreads(0),
m_hShutdownEvent(NULL),
m_hIOCompletionPort(NULL),
m_phWorkerThreads(NULL),
m_strIP(DEFAULT_IP),
m_nPort(DEFAULT_PORT),
m_pMain(NULL),
m_lpfnAcceptEx( NULL ),
m_pListenContext( NULL )
{
}
CIOCPModel::~CIOCPModel(void)
{
// 确保资源彻底释放
this->Stop();
}
///
// 工作者线程: 为IOCP请求服务的工作者线程
// 也就是每当完成端口上出现了完成数据包,就将之取出来进行处理的线程
///
DWORD WINAPI CIOCPModel::_WorkerThread(LPVOID lpParam)
{
THREADPARAMS_WORKER* pParam = (THREADPARAMS_WORKER*)lpParam;
CIOCPModel* pIOCPModel = (CIOCPModel*)pParam->pIOCPModel;
int nThreadNo = (int)pParam->nThreadNo;
pIOCPModel->_ShowMessage(_T("工作者线程启动,ID: %d."),nThreadNo);
OVERLAPPED *pOverlapped = NULL;
PER_SOCKET_CONTEXT *pSocketContext = NULL;
DWORD dwBytesTransfered = 0;
// 循环处理请求,知道接收到Shutdown信息为止
while (WAIT_OBJECT_0 != WaitForSingleObject(pIOCPModel->m_hShutdownEvent, 0))
{
BOOL bReturn = GetQueuedCompletionStatus(
pIOCPModel->m_hIOCompletionPort,
&dwBytesTransfered,
(PULONG_PTR)&pSocketContext,
&pOverlapped,
INFINITE);
// 如果收到的是退出标志,则直接退出
if ( EXIT_CODE==(DWORD)pSocketContext )
{
break;
}
// 判断是否出现了错误
if( !bReturn )
{
DWORD dwErr = GetLastError();
// 显示一下提示信息
if( !pIOCPModel->HandleError( pSocketContext,dwErr ) )
{
break;
}
continue;
}
else
{
// 读取传入的参数
PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(pOverlapped, PER_IO_CONTEXT, m_Overlapped);
// 判断是否有客户端断开了
if((0 == dwBytesTransfered) && ( RECV_POSTED==pIoContext->m_OpType || SEND_POSTED==pIoContext->m_OpType))
{
pIOCPModel->_ShowMessage( _T("客户端 %s:%d 断开连接."),inet_ntoa(pSocketContext->m_ClientAddr.sin_addr), ntohs(pSocketContext->m_ClientAddr.sin_port) );
// 释放掉对应的资源
pIOCPModel->_RemoveContext( pSocketContext );
continue;
}
else
{
switch( pIoContext->m_OpType )
{
// Accept
case ACCEPT_POSTED:
{
// 为了增加代码可读性,这里用专门的_DoAccept函数进行处理连入请求
pIOCPModel->_DoAccpet( pSocketContext, pIoContext );
}
break;
// RECV
case RECV_POSTED:
{
// 为了增加代码可读性,这里用专门的_DoRecv函数进行处理接收请求
pIOCPModel->_DoRecv( pSocketContext,pIoContext );
}
break;
// SEND
// 这里略过不写了,要不代码太多了,不容易理解,Send操作相对来讲简单一些
case SEND_POSTED:
{
}
break;
default:
// 不应该执行到这里
TRACE(_T("_WorkThread中的 pIoContext->m_OpType 参数异常.\n"));
break;
} //switch
}//if
}//if
}//while
TRACE(_T("工作者线程 %d 号退出.\n"),nThreadNo);
// 释放线程参数
RELEASE(lpParam);
return 0;
}
//====================================================================================
//
// 系统初始化和终止
//
//====================================================================================
// 初始化WinSock 2.2
bool CIOCPModel::LoadSocketLib()
{
WSADATA wsaData;
int nResult;
nResult = WSAStartup(MAKEWORD(2,2), &wsaData);
// 错误(一般都不可能出现)
if (NO_ERROR != nResult)
{
this->_ShowMessage(_T("初始化WinSock 2.2失败!\n"));
return false;
}
return true;
}
//
// 启动服务器
bool CIOCPModel::Start()
{
// 初始化线程互斥量
InitializeCriticalSection(&m_csContextList);
// 建立系统退出的事件通知
m_hShutdownEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
// 初始化IOCP
if (false == _InitializeIOCP())
{
this->_ShowMessage(_T("初始化IOCP失败!\n"));
return false;
}
else
{
this->_ShowMessage(_T("\nIOCP初始化完毕\n."));
}
// 初始化Socket
if( false==_InitializeListenSocket() )
{
this->_ShowMessage(_T("Listen Socket初始化失败!\n"));
this->_DeInitialize();
return false;
}
else
{
this->_ShowMessage(_T("Listen Socket初始化完毕."));
}
this->_ShowMessage(_T("系统准备就绪,等候连接....\n"));
return true;
}
// 开始发送系统退出消息,退出完成端口和线程资源
void CIOCPModel::Stop()
{
if( m_pListenContext!=NULL && m_pListenContext->m_Socket!=INVALID_SOCKET )
{
// 激活关闭消息通知
SetEvent(m_hShutdownEvent);
for (int i = 0; i < m_nThreads; i++)
{
// 通知所有的完成端口操作退出
PostQueuedCompletionStatus(m_hIOCompletionPort, 0, (DWORD)EXIT_CODE, NULL);
}
// 等待所有的客户端资源退出
WaitForMultipleObjects(m_nThreads, m_phWorkerThreads, TRUE, INFINITE);
// 清除客户端列表信息
this->_ClearContextList();
// 释放其他资源
this->_DeInitialize();
this->_ShowMessage(_T("停止监听\n"));
}
}
// 初始化完成端口
bool CIOCPModel::_InitializeIOCP()
{
// 建立第一个完成端口
m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
if ( NULL == m_hIOCompletionPort)
{
this->_ShowMessage(_T("建立完成端口失败!错误代码: %d!\n"), WSAGetLastError());
return false;
}
// 根据本机中的处理器数量,建立对应的线程数
m_nThreads = WORKER_THREADS_PER_PROCESSOR * _GetNoOfProcessors();
// 为工作者线程初始化句柄
m_phWorkerThreads = new HANDLE[m_nThreads];
// 根据计算出来的数量建立工作者线程
DWORD nThreadID;
for (int i = 0; i < m_nThreads; i++)
{
THREADPARAMS_WORKER* pThreadParams = new THREADPARAMS_WORKER;
pThreadParams->pIOCPModel = this;
pThreadParams->nThreadNo = i+1;
m_phWorkerThreads[i] = ::CreateThread(0, 0, _WorkerThread, (void *)pThreadParams, 0, &nThreadID);
}
TRACE(" 建立 _WorkerThread %d 个.\n", m_nThreads );
return true;
}
/
// 初始化Socket
bool CIOCPModel::_InitializeListenSocket()
{
// AcceptEx 和 GetAcceptExSockaddrs 的GUID,用于导出函数指针
GUID GuidAcceptEx = WSAID_ACCEPTEX;
GUID GuidGetAcceptExSockAddrs = WSAID_GETACCEPTEXSOCKADDRS;
// 服务器地址信息,用于绑定Socket
struct sockaddr_in ServerAddress;
// 生成用于监听的Socket的信息
m_pListenContext = new PER_SOCKET_CONTEXT;
// 需要使用重叠IO,必须得使用WSASocket来建立Socket,才可以支持重叠IO操作
m_pListenContext->m_Socket = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (INVALID_SOCKET == m_pListenContext->m_Socket)
{
this->_ShowMessage(_T("初始化Socket失败,错误代码: %d.\n"), WSAGetLastError());
return false;
}
else
{
TRACE(_T("WSASocket() 完成.\n"));
}
// 将Listen Socket绑定至完成端口中
if( NULL== CreateIoCompletionPort( (HANDLE)m_pListenContext->m_Socket, m_hIOCompletionPort,(DWORD)m_pListenContext, 0))
{
this->_ShowMessage(_T("绑定 Listen Socket至完成端口失败!错误代码: %d/n"), WSAGetLastError());
RELEASE_SOCKET( m_pListenContext->m_Socket );
return false;
}
else
{
TRACE(_T("Listen Socket绑定完成端口 完成.\n"));
}
// 填充地址信息
ZeroMemory((char *)&ServerAddress, sizeof(ServerAddress));
ServerAddress.sin_family = AF_INET;
// 这里可以绑定任何可用的IP地址,或者绑定一个指定的IP地址
ServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);
//ServerAddress.sin_addr.s_addr = inet_addr(CStringA(m_strIP).GetString());
ServerAddress.sin_port = htons(m_nPort);
// 绑定地址和端口
if (SOCKET_ERROR == bind(m_pListenContext->m_Socket, (struct sockaddr *) &ServerAddress, sizeof(ServerAddress)))
{
this->_ShowMessage(_T("bind()函数执行错误.\n"));
return false;
}
else
{
TRACE(_T("bind() 完成.\n"));
}
// 开始进行监听
if (SOCKET_ERROR == listen(m_pListenContext->m_Socket,SOMAXCONN))
{
this->_ShowMessage(_T("Listen()函数执行出现错误.\n"));
return false;
}
else
{
TRACE(_T("Listen() 完成.\n"));
}
// 使用AcceptEx函数,因为这个是属于WinSock2规范之外的微软另外提供的扩展函数
// 所以需要额外获取一下函数的指针,
// 获取AcceptEx函数指针
DWORD dwBytes = 0;
if(SOCKET_ERROR == WSAIoctl(
m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidAcceptEx,
sizeof(GuidAcceptEx),
&m_lpfnAcceptEx,
sizeof(m_lpfnAcceptEx),
&dwBytes,
NULL,
NULL))
{
this->_ShowMessage(_T("WSAIoctl 未能获取AcceptEx函数指针。错误代码: %d\n"), WSAGetLastError());
this->_DeInitialize();
return false;
}
// 获取GetAcceptExSockAddrs函数指针,也是同理
if(SOCKET_ERROR == WSAIoctl(
m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidGetAcceptExSockAddrs,
sizeof(GuidGetAcceptExSockAddrs),
&m_lpfnGetAcceptExSockAddrs,
sizeof(m_lpfnGetAcceptExSockAddrs),
&dwBytes,
NULL,
NULL))
{
this->_ShowMessage(_T("WSAIoctl 未能获取GuidGetAcceptExSockAddrs函数指针。错误代码: %d\n"), WSAGetLastError());
this->_DeInitialize();
return false;
}
// 为AcceptEx 准备参数,然后投递AcceptEx I/O请求
for( int i=0;i<MAX_POST_ACCEPT;i++ )
{
// 新建一个IO_CONTEXT
PER_IO_CONTEXT* pAcceptIoContext = m_pListenContext->GetNewIoContext();
if( false==this->_PostAccept( pAcceptIoContext ) )
{
m_pListenContext->RemoveContext(pAcceptIoContext);
return false;
}
}
this->_ShowMessage( _T("投递 %d 个AcceptEx请求完毕"),MAX_POST_ACCEPT );
return true;
}
// 最后释放掉所有资源
void CIOCPModel::_DeInitialize()
{
// 删除客户端列表的互斥量
DeleteCriticalSection(&m_csContextList);
// 关闭系统退出事件句柄
RELEASE_HANDLE(m_hShutdownEvent);
// 释放工作者线程句柄指针
for( int i=0;i<m_nThreads;i++ )
{
RELEASE_HANDLE(m_phWorkerThreads[i]);
}
RELEASE(m_phWorkerThreads);
// 关闭IOCP句柄
RELEASE_HANDLE(m_hIOCompletionPort);
// 关闭监听Socket
RELEASE(m_pListenContext);
this->_ShowMessage(_T("释放资源完毕.\n"));
}
//====================================================================================
//
// 投递完成端口请求
//
//====================================================================================
//
// 投递Accept请求
bool CIOCPModel::_PostAccept( PER_IO_CONTEXT* pAcceptIoContext )
{
ASSERT( INVALID_SOCKET!=m_pListenContext->m_Socket );
// 准备参数
DWORD dwBytes = 0;
pAcceptIoContext->m_OpType = ACCEPT_POSTED;
WSABUF *p_wbuf = &pAcceptIoContext->m_wsaBuf;
OVERLAPPED *p_ol = &pAcceptIoContext->m_Overlapped;
// 为以后新连入的客户端先准备好Socket( 这个是与传统accept最大的区别 )
pAcceptIoContext->m_sockAccept = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
if( INVALID_SOCKET==pAcceptIoContext->m_sockAccept )
{
_ShowMessage(_T("创建用于Accept的Socket失败!错误代码: %d"), WSAGetLastError());
return false;
}
// 投递AcceptEx
if(FALSE == m_lpfnAcceptEx( m_pListenContext->m_Socket, pAcceptIoContext->m_sockAccept, p_wbuf->buf, p_wbuf->len - ((sizeof(SOCKADDR_IN)+16)*2),
sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, &dwBytes, p_ol))
{
if(WSA_IO_PENDING != WSAGetLastError())
{
_ShowMessage(_T("投递 AcceptEx 请求失败,错误代码: %d"), WSAGetLastError());
return false;
}
}
return true;
}
// 在有客户端连入的时候,进行处理
// 流程有点复杂,你要是看不懂的话,就看配套的文档吧....
// 如果能理解这里的话,完成端口的机制你就消化了一大半了
// 总之你要知道,传入的是ListenSocket的Context,我们需要复制一份出来给新连入的Socket用
// 原来的Context还是要在上面继续投递下一个Accept请求
//
bool CIOCPModel::_DoAccpet( PER_SOCKET_CONTEXT* pSocketContext, PER_IO_CONTEXT* pIoContext )
{
SOCKADDR_IN* ClientAddr = NULL;
SOCKADDR_IN* LocalAddr = NULL;
int remoteLen = sizeof(SOCKADDR_IN), localLen = sizeof(SOCKADDR_IN);
///
// 1. 首先取得连入客户端的地址信息
// 这个 m_lpfnGetAcceptExSockAddrs 不得了啊~~~~~~
// 不但可以取得客户端和本地端的地址信息,还能顺便取出客户端发来的第一组数据,老强大了...
this->m_lpfnGetAcceptExSockAddrs(pIoContext->m_wsaBuf.buf, pIoContext->m_wsaBuf.len - ((sizeof(SOCKADDR_IN)+16)*2),
sizeof(SOCKADDR_IN)+16, sizeof(SOCKADDR_IN)+16, (LPSOCKADDR*)&LocalAddr, &localLen, (LPSOCKADDR*)&ClientAddr, &remoteLen);
this->_ShowMessage( _T("客户端 %s:%d 连入."), inet_ntoa(ClientAddr->sin_addr), ntohs(ClientAddr->sin_port) );
this->_ShowMessage( _T("客户额 %s:%d 信息:%s."),inet_ntoa(ClientAddr->sin_addr), ntohs(ClientAddr->sin_port),pIoContext->m_wsaBuf.buf );
//
// 2. 这里需要注意,这里传入的这个是ListenSocket上的Context,这个Context我们还需要用于监听下一个连接
// 所以我还得要将ListenSocket上的Context复制出来一份为新连入的Socket新建一个SocketContext
PER_SOCKET_CONTEXT* pNewSocketContext = new PER_SOCKET_CONTEXT;
pNewSocketContext->m_Socket = pIoContext->m_sockAccept;
memcpy(&(pNewSocketContext->m_ClientAddr), ClientAddr, sizeof(SOCKADDR_IN));
// 参数设置完毕,将这个Socket和完成端口绑定(这也是一个关键步骤)
if( false==this->_AssociateWithIOCP( pNewSocketContext ) )
{
RELEASE( pNewSocketContext );
return false;
}
///
// 3. 继续,建立其下的IoContext,用于在这个Socket上投递第一个Recv数据请求
PER_IO_CONTEXT* pNewIoContext = pNewSocketContext->GetNewIoContext();
pNewIoContext->m_OpType = RECV_POSTED;
pNewIoContext->m_sockAccept = pNewSocketContext->m_Socket;
// 如果Buffer需要保留,就自己拷贝一份出来
//memcpy( pNewIoContext->m_szBuffer,pIoContext->m_szBuffer,MAX_BUFFER_LEN );
// 绑定完毕之后,就可以开始在这个Socket上投递完成请求了
if( false==this->_PostRecv( pNewIoContext) )
{
pNewSocketContext->RemoveContext( pNewIoContext );
return false;
}
/
// 4. 如果投递成功,那么就把这个有效的客户端信息,加入到ContextList中去(需要统一管理,方便释放资源)
this->_AddToContextList( pNewSocketContext );
// 5. 使用完毕之后,把Listen Socket的那个IoContext重置,然后准备投递新的AcceptEx
pIoContext->ResetBuffer();
return this->_PostAccept( pIoContext );
}
// 投递接收数据请求
bool CIOCPModel::_PostRecv( PER_IO_CONTEXT* pIoContext )
{
// 初始化变量
DWORD dwFlags = 0;
DWORD dwBytes = 0;
WSABUF *p_wbuf = &pIoContext->m_wsaBuf;
OVERLAPPED *p_ol = &pIoContext->m_Overlapped;
pIoContext->ResetBuffer();
pIoContext->m_OpType = RECV_POSTED;
// 初始化完成后,,投递WSARecv请求
int nBytesRecv = WSARecv( pIoContext->m_sockAccept, p_wbuf, 1, &dwBytes, &dwFlags, p_ol, NULL );
// 如果返回值错误,并且错误的代码并非是Pending的话,那就说明这个重叠请求失败了
if ((SOCKET_ERROR == nBytesRecv) && (WSA_IO_PENDING != WSAGetLastError()))
{
this->_ShowMessage(_T("投递第一个WSARecv失败!"));
return false;
}
return true;
}
/
// 在有接收的数据到达的时候,进行处理
bool CIOCPModel::_DoRecv( PER_SOCKET_CONTEXT* pSocketContext, PER_IO_CONTEXT* pIoContext )
{
// 先把上一次的数据显示出现,然后就重置状态,发出下一个Recv请求
SOCKADDR_IN* ClientAddr = &pSocketContext->m_ClientAddr;
this->_ShowMessage( _T("收到 %s:%d 信息:%s"),inet_ntoa(ClientAddr->sin_addr), ntohs(ClientAddr->sin_port),pIoContext->m_wsaBuf.buf );
// 然后开始投递下一个WSARecv请求
return _PostRecv( pIoContext );
}
/
// 将句柄(Socket)绑定到完成端口中
bool CIOCPModel::_AssociateWithIOCP( PER_SOCKET_CONTEXT *pContext )
{
// 将用于和客户端通信的SOCKET绑定到完成端口中
HANDLE hTemp = CreateIoCompletionPort((HANDLE)pContext->m_Socket, m_hIOCompletionPort, (DWORD)pContext, 0);
if (NULL == hTemp)
{
this->_ShowMessage(_T("执行CreateIoCompletionPort()出现错误.错误代码:%d"),GetLastError());
return false;
}
return true;
}
//====================================================================================
//
// ContextList 相关操作
//
//====================================================================================
//
// 将客户端的相关信息存储到数组中
void CIOCPModel::_AddToContextList( PER_SOCKET_CONTEXT *pHandleData )
{
EnterCriticalSection(&m_csContextList);
m_arrayClientContext.Add(pHandleData);
LeaveCriticalSection(&m_csContextList);
}
// 移除某个特定的Context
void CIOCPModel::_RemoveContext( PER_SOCKET_CONTEXT *pSocketContext )
{
EnterCriticalSection(&m_csContextList);
for( int i=0;i<m_arrayClientContext.GetCount();i++ )
{
if( pSocketContext==m_arrayClientContext.GetAt(i) )
{
RELEASE( pSocketContext );
m_arrayClientContext.RemoveAt(i);
break;
}
}
LeaveCriticalSection(&m_csContextList);
}
// 清空客户端信息
void CIOCPModel::_ClearContextList()
{
EnterCriticalSection(&m_csContextList);
for( int i=0;i<m_arrayClientContext.GetCount();i++ )
{
delete m_arrayClientContext.GetAt(i);
}
m_arrayClientContext.RemoveAll();
LeaveCriticalSection(&m_csContextList);
}
//====================================================================================
//
// 其他辅助函数定义
//
//====================================================================================
// 获得本机的IP地址
CString CIOCPModel::GetLocalIP()
{
// 获得本机主机名
char hostname[MAX_PATH] = {0};
gethostname(hostname,MAX_PATH);
struct hostent FAR* lpHostEnt = gethostbyname(hostname);
if(lpHostEnt == NULL)
{
return DEFAULT_IP;
}
// 取得IP地址列表中的第一个为返回的IP(因为一台主机可能会绑定多个IP)
LPSTR lpAddr = lpHostEnt->h_addr_list[0];
// 将IP地址转化成字符串形式
struct in_addr inAddr;
memmove(&inAddr,lpAddr,4);
m_strIP = CString( inet_ntoa(inAddr) );
return m_strIP;
}
///
// 获得本机中处理器的数量
int CIOCPModel::_GetNoOfProcessors()
{
SYSTEM_INFO si;
GetSystemInfo(&si);
return si.dwNumberOfProcessors;
}
/
// 在主界面中显示提示信息
void CIOCPModel::_ShowMessage(const CString szFormat,...) const
{
// 根据传入的参数格式化字符串
CString strMessage;
va_list arglist;
// 处理变长参数
va_start(arglist, szFormat);
strMessage.FormatV(szFormat,arglist);
va_end(arglist);
// 在主界面中显示
CMainDlg* pMain = (CMainDlg*)m_pMain;
if( m_pMain!=NULL )
{
pMain->AddInformation(strMessage);
TRACE( strMessage+_T("\n") );
}
}
/
// 判断客户端Socket是否已经断开,否则在一个无效的Socket上投递WSARecv操作会出现异常
// 使用的方法是尝试向这个socket发送数据,判断这个socket调用的返回值
// 因为如果客户端网络异常断开(例如客户端崩溃或者拔掉网线等)的时候,服务器端是无法收到客户端断开的通知的
bool CIOCPModel::_IsSocketAlive(SOCKET s)
{
int nByteSent=send(s,"",0,0);
if (-1 == nByteSent) return false;
return true;
}
///
// 显示并处理完成端口上的错误
bool CIOCPModel::HandleError( PER_SOCKET_CONTEXT *pContext,const DWORD& dwErr )
{
// 如果是超时了,就再继续等吧
if(WAIT_TIMEOUT == dwErr)
{
// 确认客户端是否还活着...
if( !_IsSocketAlive( pContext->m_Socket) )
{
this->_ShowMessage( _T("检测到客户端异常退出!") );
this->_RemoveContext( pContext );
return true;
}
else
{
this->_ShowMessage( _T("网络操作超时!重试中...") );
return true;
}
}
// 可能是客户端异常退出了
else if( ERROR_NETNAME_DELETED==dwErr )
{
this->_ShowMessage( _T("检测到客户端异常退出!") );
this->_RemoveContext( pContext );
return true;
}
else
{
this->_ShowMessage( _T("完成端口操作出现错误,线程退出。错误代码:%d"),dwErr );
return false;
}
}
参考博文:
https://blog.csdn.net/analogous_love/article/details/74531514