首先应该说明的是,我也是第一次使用完成端口。虽然以前偶尔在网上看到完成端口的文章和代码,但真正自己动手写还是第一次,不过我这个人有个特点就是大胆,例如没有写那个界面编程系列前,其实我甚至不知道原来一个矩形的宽度Width原来就是Rect.Right-Rect.left。但现在网络信息那么发达,学习一个新东西,看看MSDN,再Google一下,还是可以冒充老手的。另外,本文仅仅讲完成端口在网络方面的应用。
一、为什么要使用完成端口:
在Windows下做过网络开发的朋友都知道,网络模型大概有这几种:
1、消息模型。大概流程是使用WSAAsyncSelect函数将Socket句柄跟窗口句柄关联,有事件发生的时候将在窗口消息过程触发对应的消息(例如:新的连接--FD_ACCEPT、有数据到达--FD_READ)。如果连接数和收发数据大,那么这种模型很快就无法支撑,这种模型一般用于长连接而且是小包数据的环境,例如,基于反向连接的远程管理程序,被控制端连接上来后,一般仅等待控制端发送指令,那么可以使用这个。Delphi以前的TServerSocket、TClientSocket控件就是基于这种模型的。
2、阻塞模式。这种模型一般是一个连接对应一个线程,例如Delphi的Indy控件库就是基于阻塞的。阻塞的好处是处理数据的业务代码逻辑可以很独立,缺点就是需要线程了,即使使用线程池+Select,效果改变也不大。因为每个进程可创建的线程数是有限的,例如,读者可以自己写一个测试程序,点一下按钮就创建多个线程,线程里面什么都不干,只是简单的Sleep(1),在32位的XP下,在作者的电脑里面,程序创建到2010个线程就开始出错了:
3、事件模型。因为事件等待的限制(一个线程只能同时等待64个),所以实际上仍然无法满足海量连接。另外线程的切换其实也是需要开销的。
二、什么是完成端口:
简单点说,完成端口就是个黑盒子,它有一个进端口,一个出端口。你把要求(例如需要接收数据)从进端口送进去,它内部完成后,出列从出端口给你最终的结果。你甚至可以这样想象:你是个厨师,做外卖饮食的。你需要猪肉(网络数据)作为原料炒菜,传统的模型是需要你自己切割猪肉(接收数据)。菜炒出来后,需要按照客人的地址外送到他手上(发送数据)。使用完成端口的话,你拿一个碗(内存),上面贴好标签(WSARecv或WSASend)表明是需要将猪肉放到这个碗里(接收),还是把这个碗里的内容送到客人手上(发送),然后从进端口送进这个小屋子(盒子)里面。盒子完成后,从出端口把碗还给你,标签上面还会标明结果。如下图所示:
三、完成端口的使用流程:主要是创建完成端口,然后往入口送请求,从出口取结果。
1、创建一个完成端口,就是创建上图的完成盒子和进出端口:m_CompletionPort := CreateIoCompletionPort(INVALID_HANDLE_VALUE, 0, 0, 0);
2、将Socket和上面的完成端口关联起来。你可以看作是去那个完成盒子登记一下。
3、创建几个线程(微软推荐一般是CPU的个数*2),作用是不断的从上面的出端口取结果:GetQueuedCompletionStatus(m_CompletionPort,xxx,xxx,xxx,INFINITE)。INFINITE表示没有等待无限时间,直到有一个请求完成了。
4、需要收数据的时候,分配一块内存(也可以不分配,后面再说)然后通过WSARecv函数送到那个入端口,函数的返回值会有三种可能:
(1)返回值不等于SOCKET_ERROR,说明投递成功。
(2)返回值等于SOCKET_ERROR,但WSAGetLastError等于ERROR_IO_PENDING,说明投递成功,但处于排队状态,因为入口那里人很多。
(3)返回值等于SOCKET_ERROR,但WSAGetLastError不等于ERROR_IO_PENDING,说明投递失败了,例如可能网络出现故障。
下面是一个典型的WSARecv伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
dwBytesRecv := 0 ; dwFlags := 0 ; GetMem(pBuffer, 1024 * 4 ); //分配一块4KB的内存,一般是从内存池取。 pOverlappedData^.DataBuf . buf:=pBuffer; //将该内存和TWsaBuf关联起来。 pOverlappedData^.DataBuf . len:= 1024 * 4 ; ret := WSARecv(ClientSocket . m_Socket, @pOverlappedData^.DataBuf, 1 , dwBytesSend, dwFlags, LPWSAOVERLAPPED(pOverlappedData), nil ); if ret = SOCKET_ERROR then begin lLastError := WSAGetLastError(); if lLastError <> ERROR_IO_PENDING then begin //发生错误,应该释放内存或回收到池...... Exit; end ; end ; |
一般地说,连接上来后,都需要投递一个WSARecv便于接收数据。
5、需要发送数据的时候,跟上面接收数据是一样的。另外,因为完成端口是基于异步的,所以这些操作都会马上返回。当然,即使返回成功,也不表示数据发送出去对方已经成功接收,这个跟其它模型是一样的。注意:只要投递成功了,那么最后一定会从出口那里出列(就是GetQueuedCompletionStatus返回)。
四、使用完成端口需要注意的地方:
折腾了半天,终于到了正题了。使用完成端口,你将要抛弃以前那些小打小闹的连接观念,完成端口的实质,是利用内存换线程,所以也有人说,玩完成端口,其实就是玩内存。一般地说,如果完成端口程序出了问题,99.999999999999999999999999999999999999999999999999999999999999999999999%是因为内存使用不当导致的,而且一旦出问题,都是莫名其妙的错误。比如说,空指针、空变量,诸如此类,其实错误根本不在这里。记住:完成端口是做服务器用的,不要再站在只有几十个连接的角度考虑问题。
1、投递接收的内存缓冲区大小。上面已经说了,连接上来后,第一件事情就是投递一个WSARecv,这个函数会绑定一块内存,如果接收成功,或者这个Socket发生网络错误(也可以是用户自己关闭了Socket,例如需要退出程序),这个请求(和所绑定的内存)才会从出端口返回,如果接收到数据,数据将保存在这块内存里面。问题在于,投递成功后,如果没有出列返回前(例如,对方没有数据发送过来,这个Socket也没有发生错误),这块内存你是无法使用的,系统已经将它锁定,也不能释放。另外,这块内存的大小,一般是页面大小(系统页面内存一般是4KB,可以通过GetSystemInfo函数得到)的倍数,例如页面大小是4KB,即使你发送1个字节,系统仍然锁定4KB。假设一下,现在有4万个连接上来,你投递了4万个WSARecv,系统锁定了这些内存。如果这4万个连接都不发送数据,那么当有新的连接上来,或者进行其它需要有内存的操作(例如发送数据),就可能会发送WSANoBuff错误了。这种情况下,新的连接无法成功,数据也无法发送,意思是你的程序基本OVER了。
使用前面猪肉的比喻,你的碗的数量(内存)是一定的,你的碗送进小屋里面后,没有回来前,你的碗是越来越少,甚至不够用的。
解决的办法一般有两个:
(1)0字节投递。意思是投递的时候,pOverlappedData^.DataBuf.len设置为0,这样一来就不会有任何内存被锁定。就好比你需要猪肉,但不给碗,只给标签。具体做法还可以分为两种,一种是投递的时候,len设置为0,最后一个参数设置为MSG_PARTIAL,当请求出列返回,说明真的有数据了,再循环调用Recv直到返回WSAEWOULDBLOCK。另外一种len设置为0,当请求出列返回后,再投递一个真正带接收内存的WSARecv,因为本身有数据了,这个请求也会很快返回。
但这种做法的缺点在于牺牲了吞吐量。想象一下,春运你去买票,第一次,你去排队,经过前面那3万人(连接),终于轮到你了,但是你到窗口只是问有没有到广州的火车票,答复说有,你返回,拿钱包,然后再排队一次。
(2)程序启动后,根据预先设定(比如说,你的服务器只允许1万个连接),计算出程序极限需要多少内存,然后一次性分配,放到内存池。需要内存的时候,就从这个池里面取,用完后,丢回这个池里面。如果连接数超过1万,当有新的连接上来,那么就拒绝对方连接,直接closesocket。这种做法还可以防止内存碎片,因为一开始就分配了一整块。缺点就见仁见智了。
2、内存的释放时机。
因为投递后(不管是发送还是接收),内存都会锁定,所以如果它没有出列,就千万不要释放它。有些人写代码的逻辑是这样的:
(1)每分配一块内存,加到List。释放的时候就从List删除。
(2)程序退出的时候,他就循环这个List,逐一释放。
这种逻辑实际上是不对的。运气好的时候,释放过程中程序就出错了,你还知道自己怎么死的;运气不好的时候,特别是连接很多,你释放完,但不要关闭程序,有时候过了15分钟程序才报错。
实际上,完成端口顺利退出(而且没有内存泄漏)是第一步。如果你写完成端口,那么我建议你第一步就是写退出,以随时随地退出,快速退出而没有内存泄露为标准。真正的做法应该是先关闭所有socket,从而导致这个socket投递的所有内存从盒子里面出列,从而解锁,然后再释放。一般稳妥的做法是对每个连接都使用一个计数器,每投递一个请求就加1,每返回一个就减1。只有计数器为0,说明没有内存被锁定了,才能释放这个SOCKET对象。
3、使用心跳。有些文章说,可以使用WinSock2的keepLive选项,但笔者更加建议使用自定义心跳,只要一个连接在一定时间内没有数据收发,就要断开它,而keepLive对于占着茅坑不拉屎的恶意连接也是容忍不断开的。一般做法是使用时间轮:
简单点说,新加入的对象(包括更新)总是位于指针的前一个槽,指针比如说每秒钟移动一格,指向哪个槽就把该槽所有的SOCKET给关闭。这个是O(1)操作。如果使用LIST,那么复杂度将是O(n)。
4、将所有阻塞的同步操作改为异步。例如读写文件,如果你在IO线程阻塞写文件,那么表现是CPU占用不高,但操作系统非常卡。
5、多投递。到底一个SOCKET同时投递多少个WSARecv最合适,这又是个见仁见智的问题。我觉得这也是外面的完成端口库无法通用的原因,因为它们要么全部是0投递,要么全部是多投递,要么永远只有一个投递。其实我觉得投递多少这个应该和业务逻辑结合。对于我的程序来说,做法是将一个Socket分成了几类,对外提供了一个ChangeType函数。例如,对于长连接,但偶尔有数据的Socket,我是0投递或永远仅有一个WSARecv投递。对于文件传输这种吞吐量很大的连接,我马上ChangeType,令其连续投递多个WSARecv。注意:对一个Socket连续投递多个WSARecv是会可能造成乱序的。比如说,你投递了WSARecv1,WSARecv2,WSARecv3,每个给它4K内存,然后对方发送了16K内容过来,那么,WSARecv1,WSARecv2,WSARecv3里面的内容是顺序的,这个是完成端口决定的,绝对不会乱。但是出列的时候(比如说你前面开了4个线程GetQueuedCompletionStatus,因为线程调度有先后),有可能是WSARecv2会先返回,所以要自己做处理。例如,WSARecv的时候,加个序列号。也有人只用一个IO线程,从而不使用序列号。
五、其它一些需要说明的:
没有什么好说的了。这东西其实就是内存换线程而已。没什么神秘的。几个线程和一点点CPU就可以轻松的将网络带宽用到极限,例如下面的程序仅用了一个网络线程:
网络吞吐量:
CPU占用:
实际上,完成端口还可以用于文件读写之类。那个完成盒子里面有着一个高速的队列。合理的利用完成端口,可以减轻应用程序很多工作,完成端口,完成端口,它全部帮你完成,让你的程序无事可做。
http://www.138soft.com/?p=338