540 likes | 868 Views
第 26 讲 WinSock2.0API 和 SOCKET 池. 没有最高性能只有更高性能. 讲师: Gamebaby Rock Sun. 第26讲 主要内容. 1. SOCKET五种工作模型的简单性能分析 2. Winsock2.0新扩展函数简介 3. AcceptEx函数及工作原理 4.GetAcceptExSockAddr函数 5.TransmitFile函数及工作原理 6.TransmitPackets函数及工作原理 7.ConnectEx函数 8.DisconnectEx函数 9.WSARecvMsg函数
E N D
第26讲 WinSock2.0API和SOCKET池 没有最高性能只有更高性能 讲师:Gamebaby Rock Sun
第26讲 主要内容 • 1.SOCKET五种工作模型的简单性能分析 • 2.Winsock2.0新扩展函数简介 • 3.AcceptEx函数及工作原理 • 4.GetAcceptExSockAddr函数 • 5.TransmitFile函数及工作原理 • 6.TransmitPackets函数及工作原理 • 7.ConnectEx函数 • 8.DisconnectEx函数 • 9.WSARecvMsg函数 • 10.Winsock2.0扩展函数的动态加载方法 • 11.SOCKET池原理和编程模型 • 12.IOCP+SOCKET池 • 13.聚集/散播I/O原理和编程方法 • 14.综合的性能考虑 • 15.GRSLib中IOCP+SOCKET池的封装
引言 • 开发网络应用,尤其是服务端应用,一直被认为是很神秘,很难学的 • 实际上,只要掌握了几个基本原则,如:创建套接字、连接、监听、接受连接、收发数据等也就基本掌握了网络编程的核心 • 而真正的困难在于开发能够同时处理单个乃至成千上万个连接的可伸缩的Winsock程序(主要是服务程序比如Web服务/FTP服务/网游服务等) • 这一讲就主要来介绍如何开发出能达到这个响应规模的网络应用的方法
五种模型可伸缩性等比较 • 从前一讲中已经知道了Winsock能够工作于五种模型之下 • 仔细分析比对可以发现,只有重叠I/O模型,才有潜力提供高的可伸缩性和性能 • 而且要发挥重叠I/O全部的优势,就必须使用IOCP模型尤其在当今多核CPU逐步推广的情况下 • 其它的异步模型,本质上仅仅解决了收发数据的时机问题,而没有解决收发数据低效这个核心问题(比对思考文件读写异步和同步问题),它们的可伸缩性和性能是不能令人满意的 • 阻塞模式更没有所谓的伸缩性或性能可言,因为它就是严格串行执行的,同一时间中真正只有一个连接被处理
可伸缩的Winsock2.0 API • 自Winsock2.0起,微软为了更高性能的网络应用特意添加了一些在原来SOCKET模型中并没有提供的能够异步工作的API,这些函数是: • TransmitFile、AcceptEx、ConnectEx、TransmitPackets、DisconnectEx、WSARecvMsg • 这些函数都在MSWSOCK.h中定义,但只有TransmitFile、AcceptEx及GetAcceptExSockAddrs是从MSWSOCK.dll中导出的 • 最终这些函数都建议动态加载后使用,而不是直接引用 • 动态加载的方法在介绍完函数后详细介绍
AcceptEx • 对于服务器应用来说AcceptEx是个最好的福音,它使得传统的接受连接操作也可以用重叠I/O方式操作了 • 这个函数甚至可以让服务器能够具备极高的连接响应能力 BOOL AcceptEx(SOCKET sListenSocket, SOCKET sAcceptSocket, PVOID lpOutputBuffer, DWORD dwReceiveDataLength, DWORD dwLocalAddressLength, DWORD dwRemoteAddressLength, LPDWORD lpdwBytesReceived, LPOVERLAPPED lpOverlapped);
AcceptEx参数 • sListenSocket是正在监听的SOCKET句柄 • sAcceptSocket是一个比较重要的参数,可以说AcceptEx函数提高性能的全部秘密就集中在这个参数上,稍后详述,此处要知道这个参数必须是一个已经创建好的SOCKET句柄即可,它最终会变成代表与客户机通讯的那个SOCKET句柄 • lpOutputBuffer是三个数据一体化的缓冲区指针,分别是接受连接时顺带接收的数据缓冲,之后是本地地址结构的缓冲,最后是远程客户端地址结构缓冲 • dwReceiveDataLength就是在接收连接时指定接收数据缓冲的长度 • dwLocalAddressLength是本地地址结构长度,其值等于sizeof(SOCKADDR)+16 • dwRemoteAddressLength是远程客户端地址结构长度,其值也等于sizeof(SOCKADDR)+16 • lpdwBytesReceived该参数用于返回接受连接请求时接收的数据的长度 • lpOverlapped就是重叠I/O需要的结构(一般该结构会被扩展定义)
Local Address (dwLocalAddressLength) Receive Data Buffer (dwReceiveDataLength) Remote Address (dwRemoteAddressLength) lpOutputBuffer AcceptEx需要的缓冲 • 下面的示意图演示了lpOutputBuffer的内存结构
AcceptEx调用时的几个问题 • 因为AcceptEx的设计目标纯粹就是为了性能,所以监听套接字的属性不会被代表客户端通讯的套接字自动继承 • 要继承属性(包括socket内部接受/发送缓存大小等等)就必须调用setsockopt使用选项SO_UPDATE_ACCEPT_CONTEXT,如下: int nRet = ::setsockopt(skClient,SOL_SOCKET ,SO_UPDATE_ACCEPT_CONTEXT ,(char *)&skListen,sizeof(skListen)); • 另外,当设置了在接受连接的同时要求AcceptEx接收数据的话,也即dwReceiveDataLength>0并且分配了相应的缓冲,这有时会被恶意程序利用,这些程序会发起大量连接请求而不发送一个字节的数据,这样AcceptEx就会傻傻的等着接收数据而不返回 • 这对一个服务器应用来说非常危险,防范的措施主要有: • 1、将dwReceiveDataLength设置为0 • 2、启动一个监视线程对用于连接的SOCKET轮询调用: • int iSecs; • int iBytes = sizeof( int ); • getsockopt( hAcceptSocket, SOL_SOCKET, SO_CONNECT_TIME, (char *)&iSecs, &iBytes ); • iSecs 为 -1 表示还未建立连接, 否则就是已经连接的时间. • 当iSecs超过某个筏值时,就果断断开这个连接
AcceptEx高效工作的秘密 • 相对于标准的accept函数来说,从功能上AcceptEx并没有什么太大的区别,它们都能在监听端接受一个来自客户端的连接请求 • 但是AcceptEx却要比accept高效,主要有以下这么几个原因: • 1、从内部工作机理上来说,accept在内部其实有一个创建SOCKET对象的动作,这个SOCKET用于代表与客户端的连接 • 2、而AcceptEx内部没有这个创建SOCKET的动作,它要求调用者事先创建一个SOCKET句柄传递给它,这就是sAcceptSocket参数的意义 • 3、事先的创建SOCKET,实际是把接受连接时创建SOCKET这个调用的时间从接受连接的过程中剔除出去了,这样接受连接的调用就简单纯粹的多 • 4、另外AcceptEx还把很多附带工作都排除在接受连接之外,比如之前说的地址解码,SOCKET环境设置等 • 5、最终从客户端来看,连接请求仿佛是在一瞬间就完成了,很快服务器就接受了连接,服务器就具备了极高的连接响应性 • 6、从服务端来说,它完全可以事先创建预计数量的SOCKET句柄并在其上提前调用AcceptEx来等待客户端连接,而不是循环调用一个accept来接受可能是高密度的连接请求,这样实际可以理解为一个“串行”的接受连接的请求的模型被变作了高效的“并行”接受连接请求的模型
GetAcceptExSockAddr • 在调用AcceptEx时,无论是否要求接受连接同时接收数据,都需要为两个地址(本地/远端)准备缓冲,并接收地址 • 需要注意的就是这两个地址都是经过编码的地址,要正确解码地址就需要调用GetAcceptExSockAddr函数: void GetAcceptExSockaddrs( PVOID lpOutputBuffer, DWORD dwReceiveDataLength, DWORD dwLocalAddressLength, DWORD dwRemoteAddressLength, LPSOCKADDR* LocalSockaddr, LPINT LocalSockaddrLength, LPSOCKADDR* RemoteSockaddr, LPINT RemoteSockaddrLength); • 这个函数的开初几个参数就来自AcceptEx调用时传递的参数 • 最后的4个参数就是用于接收解码后的SOCKADDR地址结构的缓冲 • 对于使用IPv4协议的应用来说这些结构就是SOCKADDR_IN结构体
TransmitFile • 对于一些网络应用来说,发送文件有时是一个基本的功能,比如:web服务,FTP服务等 • 在Winsock中为此而专门提供了一个高效传输文件的API: BOOL TransmitFile(SOCKET hSocket, HANDLE hFile, DWORD nNumberOfBytesToWrite, DWORD nNumberOfBytesPerSend, LPOVERLAPPED lpOverlapped, LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers, DWORD dwFlags); • 这个函数主要工作在TCP协议上
TransmitFile参数 • hSocket参数就是要在其上发送文件的SOCKET句柄 • hFile就是要发送的文件对象的句柄,当该参数为NULL时,该函数只会发送lpTransmitBuffers中的缓冲数据,这实际没意义 • nNumberOfBytesToWrite表示要从文件发送多少数据,如果该值为0,表示发送整个文件 • nNumberOfBytesPerSend表示每次发送操作发送数据块的大小,若为0表示用系统默认块大小(WindowsNT工作站版上是4k,Windows Server上是64k) • lpOverlapped就是重叠I/O方式操作时需要的结构 • lpTransmitBuffers是一个TRANSMIT_FILE_BUFFERS结构体,利用它可以指定在文件开始发送前需要发送的额外数据以及文件发送结束后需要发送的额外数据,这个参数也可以置为NULL,仅表示发送文件数据 • dwFlags是一个按位组合的标志值,后面详细介绍这个参数
TransmitFile的dwFlags参数标志 • 下表列出了全部可用的标志及含义 • 要特别注意的就是TF_DISCONNECT标志和TF_REUSE_SOCKET标志,这两个标志经常被组合来利用TransmitFile函数"回收"SOCKET句柄,以便像AcceptEx等函数再次利用这个句柄
使用TransmitFile需要注意的问题 • 虽然可以利用TF_DISCONNECT标准和TF_REUSE_SOCKET这两个标志来利用TransmitFile回收SOCKET句柄(此时可以指定hFile为NULL且lpTransmitBuffers也为NULL,即不发送任何数据调用),但这并不是该函数的主业 • 专业回收SOCKET句柄的函数是DisconnectEx • 同时TransmitFile函数只有在服务器版Windows上才能发挥其全部功能 • 而在专业版或家庭版等Windows上它被限定为最多同时有两个调用在传输,而其他的调用都被置为排队等待状态
RING3层/外壳 ReadFile send/WSASend TransmitFile 系统调用门 RING0层/内核 文件 SOCKET 文件 SOCKET TransmitFile高效工作的秘密 • 下面的示意图演示了普通发送文件操作和TransmitFile发送文件的区别和高效工作的秘密: • 从图中就可以发现TransmitFile其实绕开了多次从RING0层到RING3层再到RING0层(这个很耗CPU周期)的数据传输和相应调用 • TransmitFile实际要求系统在RING0层就把文件和SOCKET传输自动关联起来,剩下的传输操作都只在内核层完成 • 关于RING0和RING3层的知识请复习本系列教程第一部分第一讲
TransmitPackets • 对于有些网络服务器应用来说,比如:GIS系统的网络服务器,它们有时需要发送超大型数据(有时是几十G)到客户端,有时甚至需要发送多个文件到客户端(这些数据主要是地图数据,其中有些是超大型图片) • 为此Winsock中又特别扩展了一个函数来简化实现这类功能: BOOL PASCAL TransmitPackets( SOCKET hSocket, LPTRANSMIT_PACKETS_ELEMENT lpPacketArray, DWORD nElementCount, DWORD nSendSize, LPOVERLAPPED lpOverlapped, DWORD dwFlags); • 这个函数不但可以在面向连接(面向流)式的协议(TCP)上工作,还可以在无连接式的数据报协议(UDP)上工作
TransmitPackets参数 • lpPacketArray参数是TRANSMIT_PACKETS_ELEMENT结构的数组 • nElementCount是前一个数组参数的元素个数 • nSendSize指出需要发送的字节数,如果该参数为0,则表示发送所有由lpPacketArray定义的数据 • lpOverlapped是重叠I/O调用时需要的结构 • dwFlags含义与TransmitFile的dwFlags参数含义相同,只是对应的标志值以TP开头而不是TF开头.需要注意的就是当TransmitPackets工作在数据报之类的协议之上时,就不能使用TP_DISCONNECT和TP_REUSE_SOCKET标志了,这两个标志对数据报类无连接的协议无意义
TRANSMIT_PACKETS_ELEMENT结构 typedef struct _TRANSMIT_PACKETS_ELEMENT { ULONG dwElFlags; ULONG cLength; union { struct { LARGE_INTEGER nFileOffset; HANDLE hFile; }; PVOID pBuffer; }; } TRANSMIT_PACKETS_ELEMENT; • 这个结构貌似复杂,其实可以理解为3个部分: • 第一部分dwEIFlags用以说明当前结构实际描述的是什么类型的数据有下面几个标志值: #define TP_ELEMENT_MEMORY 1 #define TP_ELEMENT_FILE 2 #define TP_ELEMENT_EOP 4 其中前两个标志用于说明当前结构描述的是一个文件还是一块内存缓冲 而最后一个标志用于辅助说明前两个标志,说明当前结构表示的数据应当作为一个结束包来发送,也就说之前所有的数据到当前这个结构描述的数据应当视为一个包 • 第二部分是cLength用以说明当前结构描述的数据长度/发送文件内容的长度 • 第三个部分联合定义根据第一个部分的实际标志值,用于说明是一个文件还是一个内存块,当是一个文件时还可以指定一个64位长整数型的文件内偏移,这为应用利用TransmitPackets发送大于4GB的文件创造了可能.当偏移为-1时,表示从文件当前指针位置开始发送
TransmitPackets注意事项 • 需要注意的是因为TransmitPackets能够很快的处理数据发送,因此可能会造成大量待发送数据堆积在下层协议的协议栈上.而对于无连接的面向数据报的协议来说,有时协议驱动会选择将它们简单丢弃. • 另外对于TransmitPackets来说也只有服务器版的Windows能够发挥它全部的性能,而对于家庭版和专业版来说,最多能够同时处理两个TransmitPackets调用,其它的调用都会被排队处理 • 最后TransmitPackets在发送文件时工作机理与TransmitFile是类似的,而TransmitPackets可以发送多个文件,并且可以发送超大文件(大于4GB),在发送内存块上,TransmitPackets也有很多优化,调用者可以放心的将超大的缓冲块传递给TransmitPackets而不必过多的担心
ConnectEx • 作为客户端应用来说,或者说一些需要反连接工作的应用来说(如:Active FTP方式的服务器),使用传统的connect进行阻塞式或非阻塞式的编程都无法得到很好的性能响应 • 为此Winsock也特意提供了connect的重叠I/O方式版本: BOOL PASCAL ConnectEx( SOCKET s, const struct sockaddr* name, int namelen, PVOID lpSendBuffer, DWORD dwSendDataLength, LPDWORD lpdwBytesSent, LPOVERLAPPED lpOverlapped );
ConnectEx参数 • 第一个参数就是需要进行连接操作的SOCKET句柄,这个SOCKET句柄需要事先绑定 • name是要连接的远端服务器的地址结构 • namelen就是远端地址结构的长度 • lpSendBuffer,dwSendDataLength,lpdwBytesSent三个参数共同用于描述在连接到服务器成功之后向服务器直接发送的数据缓冲,长度以及实际发送的数据长度 • lpOverlapped就是重叠I/O操作需要的结构体
ConnectEx用法提要 • 与AcceptEx类似,ConnectEx成功之后,依然需要调用setsockopt和SO_UPDATE_CONNECT_CONTEXT标志来设置下环境 • 与传统的connect函数不同,ConnectEx函数要求一个已经绑定过的SOCKET句柄参数,其实这也是将connect内部的绑定操作排除在真正connect操作之外的一种策略 • 最终连接的操作也会很快的就被完成,而绑定可以提前甚至在初始化的时候就完成
DisconnectEx • 在之前介绍TransmitFile时讲过可以利用TransmitFile/TransmitPackets来"回收"SOCKET • 回收的SOCKET可以被AcceptEx和ConnectEx等API再次利用,由此节约了大量的创建SOCKET/销毁SOCKET调用的开销,这也是SOCKET池设计的原理基础 • 但毕竟TransmitFile/TransmitPackets不是专业的"回收"API,为此Winsock从Windows XP其提供了新的API: BOOL DisconnectEx( SOCKET hSocket, LPOVERLAPPED lpOverlapped, DWORD dwFlags, DWORD reserved );
DisconnectEx参数 • 第一个参数就是要断开连接或准备回收的SOCKET句柄 • lpOverlapped就是重叠I/O方式操作时需要的OVERLAPPED结构 • dwFlags就是指定是否在断开连接后回收这个SOCKET句柄,指定0表示不回收,此时相当于closesocket,指定TF_REUSE_SOCKET表示断开连接之后回收这个SOCKET,以便AcceptEx/ConnectEx能够再利用这个SOCKET • dwReserved是保留参数,目前都未启用,直接传递0即可
DisconnectEx使用提要 • 当以重叠I/O的方式调用DisconnectEx时,若该SOCKET还有未完成的传输调用时,该函数会返回FALSE,并且最终错误码是WSA_IO_PENDING,即断开/回收操作将在传输完成后执行 • 如果以阻塞方式(lpOverlapped参数为NULL)调用这个函数,那么该函数会在所有的I/O操作都完成之后才执行并返回
WSARecvMsg • 这个API也是从Windows XP之后才提供的新Winsock函数,可以简单理解它为WSARecv的一个扩展版,它不但可以接收数据,还可以返回数据来源的地址: int WSARecvMsg( SOCKET s, LPWSAMSG lpMsg, LPDWORD lpdwNumberOfBytesRecvd, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine ); • 这个函数的详细用法就不再介绍了,因为这个函数几乎不会用到,需要时查看MSDN帮助即可
WSASendMsg • 从Windows Vista起又提供了WSARecvMsg的对应函数WSASendMsg int WSASendMsg( SOCKET s, LPWSAMSG lpMsg, DWORD dwFlags, LPDWORD lpNumberOfBytesSent, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); (因为不常用本教程中也不多讨论这个函数,详情请参阅MSDN)
扩展函数的动态加载 • 之前介绍的这一系列Winsock2.0的扩展API,最好都动态加载之后再行调用,因为它们具体的导出位置在不同平台上变动太大,如果静态联编的话,会给开发编译工作带来巨大的麻烦,所以使用运行时动态加载来调用这些API • 为了简化动态加载时的工作,Winsock为这些API专门设计了动态加载的方法 • 这个方法要借助一个Winsock扩展函数WSAIoctl
WSAIoctl和动态加载 • 原型: int WSAIoctl( SOCKET s, DWORD dwIoControlCode, LPVOID lpvInBuffer, DWORD cbInBuffer, LPVOID lpvOutBuffer, DWORD cbOutBuffer, LPDWORD lpcbBytesReturned, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); • 这个函数的详细解释将放在下一讲进行,这里先看下比如要加载AcceptEx这个函数如何进行: DWORD dwBytes = 0; LPFN_ACCEPTEX pfnAcceptEx = NULL; GUID GuidAcceptEx = WSAID_ACCEPTEX; WSAIoctl(skTemp,SIO_GET_EXTENSION_FUNCTION_POINTER, &GuidAcceptEx,sizeof(GUID),&pfnAcceptEx,sizeof(pfnAcceptEx), &dwBytes, NULL, NULL)) • pfnAcceptEx就是AcceptEx运行时的函数指针
动态加载Winsock扩展API的说明(1) • 对于每一个扩展的Winsock API都定义了一个对应GUID常量用于标识这个API: • 调用时定义一个GUID类型的变量,赋值为想要载入的API对应的GUID常量宏 • 然后像之前那样传入GUID值
动态加载Winsock扩展API的说明(2) • 同时在Winsock头文件中定义了如下的每个函数对应的原型类型声明: • 使用时就用类型标识符定义对应的函数指针,然后传递给WSAIoctl函数即可
动态加载Winsock扩展API的说明(3) • 调用WSAIoctl动态加载这些扩展函数时,需要提供一个已有的SOCKET句柄参数 • 这个SOCKET句柄参数其实很重要 • 主要是因为创建SOCKET时其实调用者指定的重要信息就是使用的具体通讯协议 • 而只有具体的通讯协议驱动才真正的实现了对应版本的扩展函数 • 所以必须正确提供这个SOCKET,最终做到协议无关的SOCKET编程 • 目前来说,其实重要的协议就是IP协议族,和将来即将推广的IPv6协议族
动态加载Winsock扩展函数的示例 • 在本讲中为GRSLib添加了一个名为CGRSWinsockExAPI的类用于动态加载这些扩展API并管理它们的指针
SOCKET回收的启示——SOCKET池 • 通过这些Winsock扩展API的介绍,大家应该已经知道了这组扩展API其实核心就是为了提高Winsock应用的性能,尤其是服务应用 • 同时这些扩展API主要针对的是面向连接的伪流式协议的(如TCP/IP协议) • 那么其中利用DisconnectEx"回收"SOCKET句柄,然后再让AcceptEx和ConnectEx重新使用给我们什么提示呢? • 这就是SOCKET池,利用这个回收机制,可以在程序启动时创建大量的SOCKET句柄准备好,然后发起AcceptEx或ConnectEx调用,通讯完毕后调用DisconnectEx回收之,再次调用AcceptEx/ConnectEx • 如此一来,就省却了大量的创建SOCKET,关闭SOCKET的耗时调用
初始化阶段 退出阶段 运行阶段 AcceptEx/ConnectEx socket/WSASocket closesocket SOCKET池 WSARecv/WSASend TransmitFile TransmitPackets DisconnectEx SOCKET池原理示意图
IOCP和SOCKET池 • IOCP本身就是一种线程池技术,如果用它来结合SOCKET池将成为Windows系统上最佳性能组合 • 当然中间可以考虑本课程其余部分讲解过的内存池/线程池/进程池等高性能技术来打造极限性能的服务应用 • 这种技术因为ConnectEx函数的加入不但适用于服务端,而且适用于客户端 • 当然因为Windows版本的策略,在家庭版/专业版/旗舰版等非服务器版本上有些API的性能不能全面发挥 • 但这依然是最好的编程模型
IOCP+SOCKET池示例 • HighPerfServer项目展示了一个使用IOCP+SOCKET池的Echo服务器端的例子(AcceptEx) • HighPerfClient项目展示了一个使用IOCP+SOCKET池的客户端例子(ConnectEx) • 例子中还展示了在接受连接后即投递一个0缓冲长度的WSARecv调用,以及在对应的读取事件中利用散播/聚集读取边分析边接收数据,接收数据时又利用了非阻塞读取
WSABUF参数的启示——散播和聚集I/O • 在之前的课程中为了演示SOCKET的重叠I/O方法,而引入了几个Winsock扩展API:WSASend、WSARecv以及WSASendto、WSARecvFrom • 在这些作为传输数据的骨干API的声明中,对于要传输的数据缓冲都要求封装为一个WSABUF的结构体 • 从其后的参数是说明这个结构体个数的情况来分析,不难发现实际发送数据时可以传递WSABUF的数组从而可以发送多个数据缓冲(与TransmitPackets类似) • 但实际上WSABUF数组的作用不止于此,它还具有一个更加高级的功能——散播和聚集I/O
散播和聚集I/O详解——起源 • 从起源上讲,散播和聚集I/O是一种起源于高级硬盘I/O的技术(DMA/SCSI等) • 它本质的目的是将一组小的I/O操作聚集成一个大块的I/O操作,或者反过来将一个大块的I/O操作拆分为几个小块的I/O操作 • 它的好处是,比如分散写入不同大小的几个小数据(各自是几个字节),这对于传统硬盘的写入来说是比较麻烦的一种操作,要写几次小块数据,而对于聚集写操作来说,它会在驱动层将这些小块内存先拼装成一个大块内存,然后只调用一次写入操作,一次性写入硬盘 • 这样之后,就充分的发挥了高级硬盘系统(DMA/SCSI/RAID等)的连续写入读取的性能优势,而这些设备对于小块数据的读写是没有任何优势的,甚至性能是下降的 • 而在Winsock中将这种理念发挥到了SOCKET的传输上 • 散播和聚集I/O有时也被称作向量化I/O(Vectored I/O)
散播和聚集I/O详解——WSABUF • 到了SOCKET上,在Winsock中,也提供了这种模型,当然就是通过前述的WSABUF这个结构数组参数来实现的 • WSABUF结构很简单,原型如下: typedef struct __WSABUF { u_long len; char FAR* buf; } WSABUF, *LPWSABUF; buf是缓冲区的指针,len是buf的长度 • 需要注意的是虽然buf的类型别定义为char*但不表示这个结构只能用来收发char*类型的字符串,这是个很严重的误解,实际可以收发任意数据类型的缓冲,只需要进行强制类型转换即可 • 作为WSASend、WSASendto、WSARecv、WSARecvFrom等函数的数组参数,最终WSABUF数组可以描述多个分散的缓冲块用于收发 • 这对于一般的发送数据操作来说,省去了自己拼装大缓冲再发送的麻烦,只需要定义一个WSABUF数组,并按顺序将缓冲依次赋值给WSABUF即可 • 对接收数据来说,作用正好相反可以把数据拆分成多个小块
散播和聚集I/O详解——自定义协议 • 最终了解了散播和聚集I/O的原理之后,那么最终它是用来干什么的呢? • 其实它最主要的一个用途就是可以方便的封装自定义的协议 • 在一些应用中,每个数据包都是有自定义的结构的,这些结构就被称为自定义的协议 • 其中最常见的封装就是一个协议头用以说明包类型和长度,然后是包数据,最后是一个包尾里面存放用于校验数据的CRC码等 • 对于面向伪流的协议来说,这样的结构会带来一个比较头疼的问题——粘包,即多个小的数据包会被连在一起被接收端接收,然后就是头疼和麻烦的拆包过程 • 而如果使用了散播和聚集I/O方法,那么所有的工作就简单了,可以定义一个3元素的WSABUF结构数组分别发送包头/包数据/包尾 • 然后接收端先用一个WSABUF接收包头,然后根据包头指出的长度准备包数据/包尾的缓冲,再用2元素的WSABUF接收剩下的数据 • 同时对于使用了IOCP+重叠I/O的通讯应用来说,在复杂的多线程环境下散播和聚集I/O方法依然可以很可靠的工作
散播和聚集I/O详解——示例 • scatter-gather server展示了一个简单Echo方式工作的散播/聚集收发服务器 • scatter-gather client展示了一个利用散播/聚集方式发送数据的客户端 • 可以利用普通的客户端与散播/聚集方式工作的服务器收发数据做实验,以进一步深刻的理解散播/聚集工作方式的原理(这个很重要)
重叠I/O方式WSARecv的启示 • 在一个使用面向连接协议工作的繁忙的服务应用中,都需要频繁的WSARecv/WSASend数据包 • 之前的讲解中已经提到可以使用重叠I/O和IOCP来提高这些操作的性能 • 但随之而来的问题是,因为要预先投递WSARecv,那么为每个连接准备多大的接收缓冲合适呢?(同时处理的连接数可能大于一万多) • 这是个很需要慎重的问题,因为如果投递了缓冲那么WSARecv在系统内部(理解为驱动)就会把这个缓冲内存给锁定了,直到真正接收到数据,而最小锁定单位是4K(复习第一部分第一讲) • 这样一来,可能被同时锁定的内存数量是相当惊人的,还有些连接上可能需要投递不止一个WSARecv调用 • 所以为了能够在内存上也体现出高效能的特性,此时可以采取如下策略: • 在每一个需要接收数据的SOCKET上都投递一个0缓冲长度的WSARecv调用,即空缓冲调用 • 而在得到真正的WSARecv完成通知时,才真正的准备缓冲调用WSARecv,此时可以不必再是重叠I/O方式的操作
综合性能的考虑——服务应用类型 • 对于实际的面向网络的服务(主要指使用TCP协议的服务应用)来说,大致可以分为两大类:连接密集型/传输密集型 • 连接密集型服务的主要设计目标就是以最大的性能响应尽可能多的客户端连接请求,比如一个Web服务 • 传输密集型服务的设计目标就是针对每个已有连接做到尽可能大的数据传输量,有时以牺牲连接数为代价,比如一个FTP服务器 • 在实际中,还有些服务是基于UDP协议工作的,此类服务因为不需要面向连接,所以没有管理连接的要求,因此往往就是以最大化数据吞吐量为目标,因此这类服务应用在本课程中也归结为传输密集型
综合性能的考虑——设计问题 • 对于面向连接的服务(主要指使用TCP协议的服务)来说,主要设计考虑的是如下几个环节: • 1、接受连接 • 2、数据传输 • 3、IOCP+线程池接力 • 4、其它性能优化考虑
接受连接 • 无论是连接密集型还是传输密集型的服务应用,在接受连接时都建议使用重叠I/O方式的AcceptEx来进行"等待" • 开始时可能准备的AcceptEx数量可能会不足,此时可以另起线程在监听SOCKET句柄上等待FD_ACCEPT事件来决定何时再次投递大量的AcceptEx进行等待 • 当然再次调用AcceptEx时需要创建大量的SOCKET句柄,这个工作最好不要在IOCP线程池线程中进行,以防创建过程耗时而造成现有SOCKET服务响应性能下降 • 最终需要注意的就是,任何处于"等待"AcceptEx状态的SOCKET句柄都不要直接调用closesocket,这样会造成严重的内核内存泄漏 • 如果必须这样做,那要首先关闭监听套接字句柄,之后再去关闭这些等待连接的套接字
数据传输 • 有些资料中经常建议将SOCKET句柄上的接收和发送数据缓冲区设置为0可以提高网络传输性能 • 主要是避免了从调用者缓冲将数据拷贝到发送缓冲或从接收缓冲拷贝数据到调用者的缓冲中 • 其实这个设置对于性能的影响微乎其微,因为当这些缓冲耗尽时,跟设置为0时效果是一样的 • 而实际中,这样做真正的目的就是节约系统内核的缓冲,尤其是在管理大量连接SOCKET句柄的服务中,因为一个句柄上这个缓冲大小约为17K,当SOCKET句柄数量巨大时这个缓冲耗费还是惊人的(主要是内核未分页内存,复习第一部分第一讲) • 当然如果收/发缓存都为0,那意味着系统会锁定调用者提供的内存,通常这不是大问题 • 设置缓冲为0的方法如下: int iBufLen= 0; setsockopt(skAccept,SOL_SOCKET,SO_SNDBUF,(const char*)&iBufLen,sizeof(int)); setsockopt(skAccept,SOL_SOCKET,SO_RCVBUF,(const char*)&iBufLen,sizeof(int));
IOCP+线程池接力 • 为了性能的考虑,一般网络服务应用都使用IOCP+重叠I/O+SOCKET池的方式来实现具体的服务应用 • 这其中需要注意的一个问题就是IOCP线程池中的线程不要用于过于耗时或复杂的操作,比如:访问数据库操作,存取文件操作,复杂的数据计算操作等 • 这些操作因为会严重占用SOCKET操作的IOCP线程资源因此会极大降低服务应用的响应性能(实践中会发现的现象之一就是连接请求成功率会极大的下降) • 但是很多实际的应用中确实需要在服务端进行这些操作,那么如何来平衡这个问题呢? • 这时就需要另起线程或线程池来接力IOCP线程池的工作 • 比如在WSARecv的完成通知中,将接收到的缓冲直接传递给QueueUserWorkItem线程池方法,启动线程池中的线程去处理数据,而IOCP线程池则继续专心于网络服务
其它的性能考虑 • 其它的性能考虑剩下的就是利用前面讲过的WSARecv-0字节调用,以及散播/聚集I/O来进一步优化服务的设计