【原创】IOCP编程之聚集散播

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u014038143/article/details/78192238

做为IOCP应用中重要的一个方法就是被称为“聚集-散播”的方法。非常遗憾的是在很多介绍IOCP使用的资料中,我几乎没有见过有专门介绍此方法的文章,因此本文就重点讲述此方法。

在使用IOCP操作大量的TCP连接并处理IO请求的时候,一个很让我们头疼的事情就是所谓的“粘包”问题,即当发送方发送的数据包尺寸小于接收方缓冲,同时又连续发送数据的情况下,两个数据包被一起接收,接收端就需要将包重新拆分,如果遇到第二个包不完整的情况处理起来就更麻烦,在线程池的环境下,这还需要考虑多线程同步以保证数据一致性的问题。当然在我的系列文章中以及本人的网络课程中都提示过一个方法就是使用阻塞式的recv操作调用,将一个tcp-socket中的数据都接收完,但是这个方法其实面临着巨大的风险,试想如果是一个恶意的发送端,不停的发送尺寸非常小的数据包时,接收端就不得不使用一个活动的线程不断的接收这些数据,从而占用线程池的线程资源,造成接收端的瘫痪。

另一方面在发送端,当我们需要发送多个不同内存位置的数据时,我们必须要提供一个“封包”机制将不同的数据包memcpy进一个一致连续的内存块,然后一次性提交发送,对于一个高吞吐量设计同时利用了多线程或线程池技术的发送端来说,这中间的复杂性,以及性能浪费是相当可观的。

综上其实质就是头疼的“内存连续性操作”问题(至少我这么定义这个问题),在多线程/线程池环境下操作内存的复杂度是很高的,搞过此类问题的同学肯定深有体会,甚至有些初学者有可能直接被这个看似简单的问题搞得焦头烂额。当然抱怨通常不是解决问题的方法,唯有认真学习、分析和解决问题才是最终道路。

在使用IOCP的过程中,我们关注的肯定是IOCP的高性能、高并发特性,在使用C++操作IOCP的过程中,自然而然还需要关注额外的内存管理问题,发送和接收缓冲区的合并/拆分都需要我们付出额外的代价,稍不留神并发一致性问题、内存泄漏、非法访问等等问题都会爆发。实际在IOCP中为了降低合并/拆分内存的复杂度,便提供了一个重要的特性——“聚集-散播”操作。

为了理解“聚集-散播”操作的原理,让我们继续想像一个使用场景,先从发送端来思考,当我们需要发送多个数据时,为了提高效率,有些设计中我们往往将过小的数据包合并成一个大包来发送,或者干脆就是每个小包都调用一次send发送,由SOCKET底层去考虑合并与拆分的问题。在这种情况下,其实有两个问题,第一个就是小的数据块需要合并成一个大的数据块,然后一次性发送,这中间就有大量的低效memcpy操作,如果数据块来自不同的线程,还需要考虑多线程同步问题;另一个问题就是send的调用问题,如果是多线程环境时send的先后顺序是无法保证的,从而导致数据先后次序的错乱,对于顺序很重要的数据来说这是个大问题,比如在网游中,你不能先发送玩家game over了,才发送玩家接受到攻击的数据。

本质上这个问题就是说,我们既要保证小块数据的一致性,又想避免小块数据拼装成大包的低效memcpy操作。从另一个方面来说,其实如果你懂的网卡驱动底层的操作的话,还可以想到实际在驱动层面也有一个类似的memcpy将数据从内存中复制到到网卡的发送缓存中。这样细想下来,实际上一个发送数据操作本身中就伴随着两个(也可能是多个)重复的memcpy操作,那么有没有办法让第二个memcpy同时完成第一个memcpy操作的工作呢?

实际上在IOCP中“聚集”操作就是来做这个操作,首先让我们来看一下WSASend方法的原型:

int WSASend(
    _In_  SOCKET                             s,
    _In_  LPWSABUF                           lpBuffers,
    _In_  DWORD                              dwBufferCount,
    _Out_ LPDWORD                            lpNumberOfBytesSent,
    _In_  DWORD                              dwFlags,
    _In_  LPWSAOVERLAPPED                    lpOverlapped,
    _In_  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

关于这个函数干什么的我就不啰嗦了,这里我们重点观察第二个参数和第三个参数,如果你是个C/C++的老程,通过参数名称你就应该明白这其实说明第二个参数是一个WSABUF类型的数组,第三个参数就是数组元素个数。而WSABUF结构的原型是这样的:

typedef struct __WSABUF {
    u_long   len;
    char FAR *buf;
} WSABUF, *LPWSABUF;

冰雪聪明的你,应该已经反应过来,实际上我们可以声明一个WSABUF类型的数组,再将所有的待发送的缓冲一个个按顺序放到这个数组的每个元素中,然后只需要一次调用WSASend方法,那么Windows系统内部就会按照你定义的WSABUF数组的顺序发送这些数据,并且会在底层(驱动层那个memcpy)时拼接成一个完整的包(连续的内存块)。这样一来对于一个拼包操作(一大堆alloc、memcpy等操作),就被一个数组赋值替代了,如果你还不明白这对效率提升有什么意义,那么你就需要好好的回炉再学学内存管理的基础知识了。ok,整体的可以像下面这样发送一个完整协议的包:

......
WSABUF wbPacket[2] = {};

wbPacket[0].buf = lpHead;
wbPacket[0].len = sizeof(ST_HEAD);
wbPacket[1].buf = lpBody;
wbPacket[1].len = lBodyLen;
......
DWORD dwSent = 0;
int iRet = WSASend( sock2Server , wbPacket , 2 , &dwSent , 0 , NULL , NULL );
......

其实看到这里,你应该有一种恍然大悟的感觉,并且一定会说,原来还可以这么玩!仔细看看上面的代码并且一琢磨,你立刻就会发现,原来需要alloc一块内存,并且memcpy lpHead和lpBody进Buffer的操作真的不见了,一个数组就搞定了。

         同样对于接收端来说,WSARecv也有类似的参数,原型如下:

int WSARecv(
    _In_    SOCKET                             s,
    _Inout_ LPWSABUF                           lpBuffers,
    _In_    DWORD                              dwBufferCount,
    _Out_   LPDWORD                            lpNumberOfBytesRecvd,
    _Inout_ LPDWORD                            lpFlags,
    _In_    LPWSAOVERLAPPED                    lpOverlapped,
    _In_    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

同样我们可以像下面这样一次性接收并同时完成“拆包”操作,也就是“散播”操作:

......
WSABUF wbPacket[2] = {};

wbPacket[0].buf = lpHead;
wbPacket[0].len = sizeof(ST_HEAD);
wbPacket[1].buf = lpBody;
wbPacket[1].len = lBodyLen;
......
DWORD dwRecv = 0;
int iRet = WSARecv( sock2Server , wbPacket , 2 , &dwRecv , 0 , NULL , NULL );
......

当然实际中操作需要比上面这两个例子复杂的多,但是本质上都是减少了不必要的alloc及memcpy等内存操作,从根本上提高了收发数据的效率。这时其实我最想的就是,假如WriteFile和ReadFile也能如此使用,那画面该有多美~~~

    至此,IOCP系列的文章算完整的告一个段落了,这篇文章实际来的有点太晚了,居然过了好几年,才将这最后一篇写完,深感愧疚!这也是多年来我的老毛病,很多时候都在深度学习,产出太少,实际有很多经验体会都不能及时成文分享给大家,请大家谅解。同时以后我将及时改正这个陋习,更加勤奋的为大家分享我的心得和经验。也谢谢各位网友对我博客的长期关注,在此鞠躬!

猜你喜欢

转载自blog.csdn.net/u014038143/article/details/78192238