本文假设你已经对重叠I/O的机制已有了解,否则请先参考本系列的前一篇《手把手教你玩转SOCKET模型之重叠I/O篇》;
目录:
1.完成例程的优点
2.完成例程的基本原理
3.关于完成例程的函数介绍
4.完成例程的实现步骤
5.实际应用中应该进一步完善的地方
一. 完成例程的优点
1. 首先需要指明的是,这里的“完成例程”(Completion Routine)并非是大家所常听到的“完成端口”(Completion Port),而是另外一种管理重叠I/O请求的方式,而至于什么是重叠I/O,简单来讲就是Windows系统内部管理I/O的一种方式,核心就是调用的ReadFile和WriteFile函数,在制定设备上执行I/O操作,不光是可用于网络通信,也可以用于其他需要的地方。
在Windows系统中,管理重叠I/O可以有三种方式:
(1)基于事件通知的重叠I/O模型
(2) 基于“完成例程”的重叠I/O模型
(3) “完成端口”模型
虽然都是基于重叠I/O,但是因为前两种模型都是需要自己来管理任务的分派,所以性能上没有区别,而完成端口是创建完成端口对象使操作系统亲自来管理任务的分派,所以完成端口肯定是能获得最好的性能。
2. 如果你想要使用重叠I/O机制带来的高性能模型,又懊恼于基于事件通知的重叠模型要收到64个等待事件的限制,还有点畏惧完成端口稍显复杂的初始化过程,那么“完成例程”无疑是你最好的选择!^_^因为完成例程摆脱了事件通知的限制,可以连入任意数量客户端而不用另开线程,也就是说只用很简单的一些代码就可以利用Windows内部的I/O机制来获得网络服务器的高性能,是不是心动了呢?那就一起往下看。。。。。。。。。。
3. 而且个人感觉“完成例程”的方式比重叠I/O更好理解,因为就和我们传统的“回调函数”是一样的,也更容易使用一些,推荐!
二. 完成例程的基本原理
概括一点说,上一篇拙作中提到的那个基于事件通知的重叠I/O模型,在你投递了一个请求以后(比如WSARecv),系统在完成以后是用事件来通知你的,而在完成例程中,系统在网络操作完成以后会自动调用你提供的回调函数,区别仅此而已,是不是很简单呢?
首先这里统一几个名词,包括“重叠操作”、“重叠请求”、“投递请求”等等,这是为了配合这的重叠I/O才这么讲的,说的直白一些,也就是你在代码中发出的WSARecv()、WSASend()等等网络函数调用。
上篇文章中偷懒没画图,这次还是画个流程图来说明吧,采用完成例程的服务器端,通信流程简单的来讲是这样的:
从图中可以看到,服务器端存在一个明显的异步过程,也就是说我们把客户端连入的SOCKET与一个重叠结构绑定之后,便可以将通讯过程全权交给系统内部自己去帮我们调度处理了,我们在主线程中就可以去做其他的事情,边等候系统完成的通知就OK,这也就是完成例程高性能的原因所在。
如果还没有看明白,我们打个通俗易懂的比方,完成例程的处理过程,也就像我们告诉系统,说“我想要在网络上接收网络数据,你去帮我办一下”(投递WSARecv操作),“不过我并不知道网络数据合适到达,总之在接收到网络数据之后,你直接就调用我给你的这个函数(比如_CompletionProess),把他们保存到内存中或是显示到界面中等等,全权交给你处理了”,于是乎,系统在接收到网络数据之后,一方面系统会给我们一个通知,另外同时系统也会自动调用我们事先准备好的回调函数,就不需要我们自己操心了。
看到这里,各位应该已经对完成例程的体系结构有了比价清晰的了解了吧,下面各位喝点咖啡转转脖子休息休息,然后就进入到下面的具体实现部分了。
三. 完成例程的函数介绍
这个部分将要介绍在完成例程模型中会使用到的关键函数,内容比较枯燥,大家要做好心理准备。不过在实际应用以前,很多东西肯定也不会理解得太深刻,可以先泛泛的了解一下,以后再回头复习这里的知识就可以了。
厄。。。。。。仔细审查了一下代码,发现其实这里也没有什么新函数好介绍了,大部分都是使用重叠模型那一章里介绍的一样的函数,需要查看的朋友请看这里《手把手教你玩转重叠IO模型》
,这里就不再重复了:
这里只补充一个知识点,就是咱们完成例程方式和前面的事件通知方式最大的不同之处就在于,我们需要提供一个回调函数供系统收到网络数据后自动调用,回调函数的参数定义应该遵照如下的函数原型:
1. 完成例程回调函数原型及传递方式
函数应该是这样定义的,函数名字随便起,但是参数类型不能错
Void CALLBACK _CompletionRoutineFunc(
DWORD dwError, // 标志咱们投递的重叠操作,比如WSARecv,完成的状态是什么
DWORD cbTransferred, // 指明了在重叠操作期间,实际传输的字节量是多大
LPWSAOVERLAPPED lpOverlapped, // 参数指明传递到最初的IO调用内的一个重叠 结构
DWORD dwFlags // 返回操作结束时可能用的标志(一般没用));
还有一点需要重点提一下的是,因为我们需要给系统提供一个如上面定义的那样的回调函数,以便系统在完成了网络操作后自动调用,这里就需要提一下究竟是如何把这个函数与系统内部绑定的呢?如下所示,在WSARecv函数中是这样绑定的
int WSARecv(
SOCKET s, // 当然是投递这个操作的套接字
LPWSABUF lpBuffers, // 接收缓冲区,与Recv函数不同
// 这里需要一个由WSABUF结构构成的数组
DWORD dwBufferCount, // 数组中WSABUF结构的数量,设置为1即可
LPDWORD lpNumberOfBytesRecvd, // 如果接收操作立即完成,这里会返回函数调用
// 所接收到的字节数
LPDWORD lpFlags, // 说来话长了,我们这里设置为0 即可
LPWSAOVERLAPPED lpOverlapped, // “绑定”的重叠结构
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
// 我们的完成例程函数的指针
);
其他参数我们可以先不用先细看,只看最后一个,看到了吗?直接在
举个例子:(变量的定义顺序和上面的说明的顺序是对应的,下同)
SOCKET s;
WSABUF DataBuf; // 定义WSABUF结构的缓冲区
// 初始化一下DataBuf
#define DATA_BUFSIZE 4096
char buffer[DATA_BUFSIZE];
ZeroMemory(buffer, DATA_BUFSIZE);
DataBuf.len = DATA_BUFSIZE;
DataBuf.buf = buffer;
DWORD dwBufferCount = 1, dwRecvBytes = 0, Flags = 0;
// 建立需要的重叠结构,每个连入的SOCKET上的每一个重叠操作都得绑定一个
WSAOVERLAPPED AcceptOverlapped ;// 如果要处理多个操作,这里当然需要一个
// WSAOVERLAPPED数组
ZeroMemory(&AcceptOverlapped, sizeof(WSAOVERLAPPED));
// 作了这么多工作,终于可以使用WSARecv来把我们的完成例程函数绑定上了
// 当然,假设我们的_CompletionRoutine函数已经定义好了
WSARecv(s, &DataBuf, dwBufferCount, &dwRecvBytes,
&Flags, &AcceptOverlapped, _CompletionRoutine);
四. 完成例程的实现步骤
基础知识方面需要知道的就是这么多,下面我们配合代码,来一步步的讲解如何亲手实现一个完成例程模型(前面几步的步骤和基于事件通知的重叠I/O方法是一样的)。
【第一步】创建一个套接字,开始在指定的端口上监听连接请求
和其他的SOCKET初始化全无二致,直接照搬即可,在此也不多费唇舌了,需要注意的是为了一目了然,我去掉了错误处理,平常可不要这样啊,尽管这里出错的几率比较小。
WSADATA wsaData;
WSAStartup(MAKEWORD(2,2),&wsaData);
ListenSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP); //创建TCP套接字
SOCKADDR_IN ServerAddr; //分配端口及协议族并绑定
ServerAddr.sin_family=AF_INET;
ServerAddr.sin_addr.S_un.S_addr =htonl(INADDR_ANY);
ServerAddr.sin_port=htons(11111); // 在11111端口监听
// 端口号可以随意更改,但最好不要少于1024
bind(ListenSocket,(LPSOCKADDR)&ServerAddr, sizeof(ServerAddr)); // 绑定套接字
listen(ListenSocket, 5); //开始监听
【第二步】接受一个入站的连接请求
一个accept就完了,都是一样一样一样一样的啊~~~~~~~~~~
至于AcceptEx的使用,在完成端口中我会讲到,这里就先不一次灌输这么多了,不消化啊^_^
AcceptSocket = accept (ListenSocket, NULL,NULL) ;
当然,这里是我偷懒,如果想要获得连入客户端的信息(记得论坛上也常有人问到),accept的后两个参数就不要用NULL,而是这样
SOCKADDR_IN ClientAddr; // 定义一个客户端得地址结构作为参数
int addr_length=sizeof(ClientAddr);
AcceptSocket = accept(ListenSocket,(SOCKADDR*)&ClientAddr, &addr_length);
// 于是乎,我们就可以轻松得知连入客户端的信息了
LPCTSTR lpIP = inet_ntoa(ClientAddr.sin_addr); // 连入客户端的 IP
UINT nPort = ClientAddr.sin_port; // 连入客户端的Port
【第三步】准备好我们的重叠结构
有新的套接字连入以后,新建立一个WSAOVERLAPPED重叠结构(当然也可以提前建立好),准备绑定到我们的重叠操作上去。这里也可以看到和上一篇中的明显区别,就是不用再为WSAOVERLAPPED结构绑定一个hEvent了。
// 这里只定义一个,实际上是每一个SOCKET的每一个操作都需要绑定一个重叠结构的,所以在实际使用面对多个客户端的时候要定义为数组,详见示例代码;
WSAOVERLAPPED AcceptOverlapped;
ZeroMemory(&AcceptOverlapped, sizeof(WSAOVERLAPPED)); // 置零
【第四步】开始在套接字上投递WSARecv请求,需要将第三步准备的WSAOVERLAPPED结构和我们定义的完成例程函数为参数
各个变量都已经初始化OK以后,我们就可以开始进行具体的Socket通信函数调用了,然后让系统内部的重叠结构来替我们管理I/O请求,我们只用等待网络通信完成后调用咱们的回调函数就OK了。
这个步骤的重点就是 绑定一个Overlapped变量和一个完成例程函数
// 将WSAOVERLAPPED结构指定为一个参数,在套接字上投递一个异步WSARecv()请求
// 并提供下面的作为完成例程的_CompletionRoutine回调函数(函数名字)
if(WSARecv(
AcceptSocket,
&DataBuf,
1,
&dwRecvBytes,
&Flags,
&AcceptOverlapped,
_CompletionRoutine) == SOCKET_ERROR) // 注意我们传入的回调函数指针
{
if(WSAGetLastError() != WSA_IO_PENDING)
{
ReleaseSocket(nSockIndex);
continue;
}
}
}
【第五步】 调用WSAWaitForMultipleEvents函数或者SleepEx函数等待重叠操作返回的结果
我们在前面提到过,投递完WSARecv操作,并绑定了Overlapped结构和完成例程函数之后,我们基本就是完事大吉了,等了系统自己去完成网络通信,并在接收到数据的时候,会自动调用我们的完成例程函数。
而我们在主线程中需要做的事情只有:做别的事情,并且等待系统完成了完成例程调用后的返回结果。
就是说在WSARecv调用发起完毕之后,我们不得不在后面再紧跟上一些等待完成结果的代码。有两种办法可以实现:
1) 和上一篇重叠I/O中讲到的一样,我们可以使用WSAWaitForMultipleEvent来等待重叠操作的事件通知, 方法如下:
// 因为WSAWaitForMultipleEvents() API要求
// 在一个或多个事件对象上等待, 但是这个事件数组已经不是和SOCKET相关联的了
// 因此不得不创建一个伪事件对象.
WSAEVENT EventArray[1];
EventArray[0] = WSACreateEvent(); // 建立一个事件
////////////////////////////////////////////////////////////////////////////////
// 然后就等待重叠请求完成就可以了,注意保存返回值,这个很重要
DWORD dwIndex = WSAWaitForMultipleEvents(1,EventArray,FALSE,WSA_INFINITE,TRUE);
这里参数的含义我就不细说了,MSDN上一看就明白,调用这个函数以后,线程就会置于一个警觉的等待状态,注意fAlertable 参数一定要设置为 TRUE。
2) 可以直接使用SleepEx函数来完成等待,效果都是一样的。
SleepEx函数调用起来就简单得多,它的函数原型定义是这样的
DWORD SleepEx(
DWORD dwMilliseconds, // 等待的超时时间,如果设置为INFINITE就会一直等待下去
BOOL bAlertable // 是否置于警觉状态,如果为FALSE,则一定要等待超时时间完毕之后才会返回,这里我们是希望重叠操作一完成就能返回,所以同(1)一样,我们一定要设置为TRUE
);
调用这个函数的时候,同样注意用一个DWORD类型变量来保存它的返回值,后面会派上用场。
【第六步】通过等待函数的返回值取得重叠操作的完成结果
这是我们最关心的事情,费了那么大劲投递的这个重叠操作究竟是个什么结果呢?就是通过上一步中我们调用的等待函数的DWORD类型的返回值,正常情况下,在操作完成之后,应该是返回WAIT_IO_COMPLETION,如果返回的是 WAIT_TIMEOUT,则表示等待设置的超时时间到了,但是重叠操作依旧没有完成,应该通过循环再继续等待。如果是其他返回值,那就坏事了,说明网络通信出现了其他异常,程序就可以报错退出了……
判断返回值的代码大致如下:
///////////////////////////////////////////////////////////////////////////////////
// 返回WAIT_IO_COMPLETION表示一个重叠请求完成例程代码的结束。继续为更多的完成例程服务
if(dwIndex == WAIT_IO_COMPLETION)
{
TRACE("重叠操作完成.../n");
}
else if( dwIndex==WAIT_TIMEOUT )
{
TRACE(“超时了,继续调用等待函数”);
}
else
{
TRACE(“废了…”);
}
操作完成了之后,就说明我们上一个操作已经成功了,成功了之后做什么?当然是继续投递下一个重叠操作了啊…..继续上面的循环。
【第七步】继续回到第四步,在套接字上继续投递WSARecv请求,重复步骤4-7
大家可以参考我的代码,在这里就先不写了,因为各位都一定比我smart,领悟了关键所在以后,稍作思考就可以灵活变通了。
【第八步】“享受”接收到的数据
朋友们看到这里一定会问,我忙活了这么久,那客户端传来的数据在哪里接收啊?怎么一点都没有提到呢……
这个问题问得好,我们写了这么多代码图个什么呢?
其实想要读取客户端的数据很简单,因为我们在WSARecv调用的时候,是传递了一个WSABUF的变量的,用于保存网络数据,而在我们写的完成例程回调函数里面,就可以取到客户端传送来的网络数据了。
因为系统在调用我们完成例程函数的时候,其实网络操作已经完成了,WSABUF里面已经有我们需要的数据了,只是通过完成例程来进行后期的处理。具体可以参考示例代码。 而DataBuf.buf就是一个char*字符串指针,听凭你的处理吧,我就不多说了。
五. 实际应用中应该完善的地方
其实我一直都很想把我以前做的工程中的代码贴出来给大家分享,但是代码实在是太繁杂了,仅仅把网络通信的部分剥离出来,不经过测试的话,肯定还会有其他的很多问题,反而误导了初学者,不过我的计划是在写下一个“完成端口”部分的时候,直接把项目中的一部分代码拿出来试试看吧……
总之网络服务器端程序,在实际应用的时候,关键的几点就是:
1) 要考虑到客户端很多、通信量很大的时候,如何去处理,如何尽可能的减小开销,提高效率;
2) 多个线程之间共用一些变量的时候,一定要注意到同步问题;
3) 作为一个网络程序,出现异常是家常便饭,一定要把代码写得尽可能的健壮,要尽量全面的考虑处理各种各样的错误;
4) 尽量不要出现各种字符缓冲区的问题,写安全的代码,防止被黑客利用……(这点似乎扯远了,但是确实是一个很现实的问题)。
其他的问题,还希望各位这方面的网络专家使劲批评指正,因为代码是很多年前的了,一定存在着很多的问题。
作者:小猪