正文

Winsock服务器设计的四个关键问题2009-07-11 20:07:00

【评论】 【打印】 【字体: 】 本文链接:http://blog.pfan.cn/xman/45129.html

分享到:

6.2.1 接受连接的方法

Winsock扩展函数AcceptEx是唯一能够使用重叠I/O接受客户连接的函数。下面主要深入探讨使用该函数接收连接的问题。

前面已经讨论过,当客户连接进来时,服务器需要创建一个套接字来负责维护与一个客户端的会话。使用AcceptEx函数之前必须创建一些套接字,并且这些套接字必须是未绑定、未连接的,即使它们可能在调用TransmitFile, TransmitPackets, DisconnectEx后可以重用。

响应服务器必须总是具有足够的AcceptEx在站岗,以便在有客户连接请求时调用。但是,并没有具体的数量能够保证服务器能够立即响应连接。我们知道在调用listen将监听套接字置于监听状态后,TCP/IP堆栈会自动接受到来的连接,直到达到listenbacklog参数设定的限制。对于Windows NT服务器而言,支持的backlog的最大值为200。如果服务器投递了15AcceptEx调用,然后突然有50个客户请求连接服务器,它们的连接请求都不会遭到拒绝。服务器投递的AcceptEx I/O会满足前面的15个连接,剩下的35个连接都被系统默认连接了。检查一下backlog的值发现,系统还有能力默认接受165个连接。之后,如果服务器投递AcceptEx调用,它们会立即成功返回,因为系统会将默认接收的连接放入等待连接队列中。

服务器的特性是决定要投递多少个AcceptEx操作的重要因素。例如,希望处理大量短时间即时连接的客户要比处理少量长时间连接的客户投递更多的AcceptEx I/O。一个好的策略是允许AcceptEx的调用数量在最小值和最大值之间变化。具体做法是,应用程序跟踪未决的AcceptEx I/O的数量,当一个或多个I/O完成使这个未决I/O数量变得比最小值还小时,就再投递额外的AcceptEx I/O

Windows 2000和以后的Windows操作系统版本中,Winsock提供了一种机制,用来确定应用程序是否投递了足够的AcceptEx调用。创建监听套接字时,使用WSAEventSelect函数为监听套接字关联一个事件对象,注册FD_ACCEPT事件。如果投递的AcceptEx操作用完,但是仍有客户请求接入(系统根据backlog值决定是否接受这些连接),事件对象就是受信,说明应该投递额外的AcceptEx操作了。这实际上还是利用事件对象来使调用线程处于一种可警告状态,当有客户连接请求时,就根据当前AcceptEx操作是否用完来警告(通知)是否需要投递新的AcceptEx操作来处理新的客户连接。

使用AcceptEx处理连接的另外一个功能就是在处理连接时还可以接收用户发来的第一块数据(前提是为AcceptEx提供了接收缓冲区),这对于那些请求连接的同时发送了一些数据过来的客户来说很适用。但是,此时,除非接收连接的同时接收到了客户发送过来的一些数据,否则AcceptEx是不会返回的。

为了满足客户的需求,服务器不得不投递更多的接受I/O,这会占用大量的系统资源。如果客户仅调用connect函数连接服务器,长时间既不发送数据,也不关闭连接,就可能造成AcceptEx投递的大量重叠I/O操作不能返回。这就是恶意连接。为此,服务器应该记录每个AcceptEx投递的未决I/O,定时扫描它们,设置SO_CONNECT_TIME参数调用getsockopt检查它们连接的时间,如果超时,就将连接关闭。如果使用WSAEventSelect模型来通知有连接事件,则当事件受信时,是检查客户套接字(AcceptSocket)是否真正连接了。

每当调用AcceptEx接受客户端连接时,它也在等待接受客户发送过来的第一个数据块,这时不允许投递另外一个AcceptEx。当AcceptEx返回后,如果事件对象再次受信则表明有新的连接到来。需要注意的是,无论何时,千万不要关闭一个调用AcceptEx还没有返回的套接字(AcceptSocket),因为这会导致内存泄露。因为从内部执行逻辑看,当没有连接的套接字句柄被关闭时,调用AcceptEx所涉及到的内核模式的数据结构并不会清除掉,直到有新的连接建立或者监听套接字被关闭。

尽管在一个等待完成通知的工作者线程中,投递一个AcceptEx操作,看起来既简单又合情合理,但是应尽量避免这样做,因为创建套接字还是很耗费资源的。另外,也不要在工作者线程中进行任何复杂的计算,以便处理器可以尽快的在接到完成通知后进行后续处理。创建套接字耗费资源的一个原因在于Winsock 2.0本身的架构很复杂,成功地创建一个套接字可能需要调用很多内核服务。因此,服务器应该在单独线程中创建套接字,投递AcceptEx操作。当调用线程投递的AcceptEx重叠操作完成时,一个受信的事件将会通知处理线程。

6.2.2 数据传输问题

数据传输是通信程序执行的核心操作。当一个客户与服务器建立连接后,它们的主要工作就是传输数据,因为数据是信息的表示。由上一节几种I/O模型的性能测试分析可知,当连接数量很大时,数据吞吐量是一个重要的性能考核指标。

从性能角度考虑,所有的数据传输最好都应采用重叠I/O处理。默认情况下,系统为每个socket分配一个的接受缓冲区和一个发送缓冲区,用来缓存接收和发送的数据。但在重叠I/O中,这些缓冲区往往不用,可以传递参数SO_SNDBUFSO_RCVBUF调用setsockopt,来将它们设置为0

让我们来看看,当发送缓冲区没有设置为0时,系统是怎么处理一个典型的send操作的。当一个应用程序调用send函数时,如果有充足的缓冲空间,需要发送的数据将被拷贝到套接字的发送缓冲区,send函数立即成功返回,并且一个完成通知被抛出。另外一个方面,如果套接字的发送缓冲区已满,则应用程序提供的发送缓冲区被锁定,再次对send函数的调用将会返回WSA_IO_PENDING错误。当发送缓冲区中的数据被处理(例如,提交给传输层处理)时,Winsock实际上直接处理锁定在缓冲区中的数据,也即绕过套接字的发送缓冲区,直接从应用程序缓冲区中提交数据给传输层。

接收数据的情况恰好相反。当一个重叠的receive请求抛出后,如果数据已经接收成功,它会被缓存在套接字接收缓冲区。数据会拷贝到应用程序缓冲区(直到饱和)。receive调用返回,并且一个完成通知被抛出。当套接字缓冲区被设置为空时,如果调用重叠的receive操作将返回WSA_IO_PENDING错误。当有数据到达时,它将绕过套接字缓冲区而直接被拷贝到应用程序缓冲区。

设置单套接字缓冲区为0,并不能提高性能,因为只要一直有大量的重叠接发请求被抛出,就不会有额外的内存拷贝。设置套接字发送缓冲区为空比设置套接字接收缓冲区为空对系统的性能影响要小。因为应用程序的发送缓冲区会被经常锁定直到它被提交给传输层处理。然而,若将接收缓冲区设置为0,并且没有重叠的receive调用,任何传进来的数据只能缓存在传输层。传输层驱动程序只会缓存滑动窗口尺寸的数据,即17KB—传输层可以分配的缓冲区大小的上限。实际的缓冲区要比17KB小。传输层缓冲区(针对一次连接)是在非分页池之外分配的,这意味着,当服务建立了1000个连接时,即使没有抛出receive请求,非分页池中也会分配17MB的内存。而非分页池是很珍贵的资源,除非服务器可以保证总是有接收请求抛出,否则套接字接收缓冲区应该不需设置。

只有在一些特殊情况下,对套接字接收缓冲区不予设置将会导致性能降低。考虑服务器需要处理成千上万个客户连接,而每个连接上又都没有投递receive请求的情况,如果客户端零星地发送数据过来,传输进来的数据将被缓存在套接字接收缓冲区中。当服务器处理一个receive重叠I/O时,它会做一些不必要的工作。当完成通知到达时,重叠操作会处理一个I/O请求包(IRP)。在这种情形下,服务器不能保留很多抛出的receive请求。因此,最好使用简单的非阻塞接收函数。

6.3 内存资源管理问题

由于机器硬件条件所限,系统资源是有限的,因此不得不考虑内存资源的管理问题。从上一节对不同I/O模型进行的性能测试结果分析可知,维持大规模的通信连接,不仅会耗费掉大量内存,而且对CPU的占用也是很高的。

对于配置比较高的服务器而言,处理成千上万个连接并不成问题。但是随着连接量的剧增,内存资源的限制将逐渐凸现。最有可能遇到的两个限制因素就是锁定页和非分页池。锁定页的限制不是太严重,更应该避免的是非分页池被耗尽。每一次调用重叠的sendreceive请求,提交的缓冲区都可能被锁住。当内存被锁定时,它就不能从物理内存换出。操作系统对锁定内存的数量是有限制的,当达到极限时,重叠操作将会返回WSAENOBUFS错误。如果服务器在每个连接上投递多个重叠接收操作,随着客户连接数量的增多,极限就会达到。如果期望服务器能够处理高并发通信,服务器可以在每个连接上投递一个0字节的接受操作,这样就不会有内存锁定。0字节的接受完成以后,服务器可以简单地执行一个非阻塞的接收函数来获取缓存在套接字接收缓冲区中的所有数据。当非阻塞接收调用返回WSAEWOULDBLOCK时,就表示不再有未决的数据了。这种方法非常适合用来设计那些希望通过牺牲每个套接字上的吞吐率来获取更大规模并发连接的服务器。

当然,最好还要了解客户端与服务器通信的方式。在上面的例子中,当0字节的接收完成后,再投递一个异步接收操作,将接收到所有缓存在套接字接收缓冲区中的数据。如果服务器知道客户端将会连续不断发送数据,那么当0字节的接收完成后,假如客户端将发送大数据块(超过单套接字缓冲区8KB的容量)过来,服务器将抛出一个或多个重叠的接收操作。

另外一个需要重点考虑的问题就是系统所需页的数量。当系统锁定传递给重叠操作的内存时,它是在页边界上进行的。在x86体系结构上,内存页的大小为4KB。如果一个操作投递了1KB的缓冲区,系统实际上会为它锁定4KB大小的内存块。为避免这种浪费,重叠发送和接收缓冲区的大小应该是页大小的倍数。可以使用GetSystemInfo这个API来获知当前系统页的大小。

如果突破非分页池极限,将会导致更严重的错误,并且很难恢复。非分页池是内存的一部分,它常驻内存,并且永远不会被交换出去。内核模式的系统组件,如驱动程序,通常使用非分页池,其中包括Winsock和协议驱动程序,例如tcpip.sys。每个套接字的创建将消耗一小部分非分页池,用于维持套接字状态信息。当套接字绑定到一个地址后,TCP/IP堆栈将分配额外的非分页池来保存本地地址的信息。当一个对等套接字接入后,TCP/IP堆栈也将分配部分非分页池来保存远程地址信息。基本上,一个建立连接的套接字占用2KB非分页池内存,acceptAcceptEx返回的套接字则占用1.5KB非分页池内存。之所以出现这个区别,是因为服务器本地地址信息已经存储在监听套接字中,故acceptAcceptEx返回的套接字只需保存远程主机地址信息。此外,每个在套接字上投递的重叠操作都需要给I/O请求包(IRP)分配内存,一个IRP使用大约500B非分页池内存。

从以上分析可以看出,为每个连接分配的非分页池内存并不是很大。然而,随着客户连接量逐增,服务器对非分页池的使用将是非常大的。考虑运行在只有1GB物理内存的Windows 2000或以后版本Windows系统上的服务器,将有256MB的内存非配给非分页池。通常,非分页池大小是机器物理内存的1/4Windows 2000及以后版本的Windows系统上,非分页池大小为256MB/1GB),而Windows NT 4.0限制为128MB1GB)。拥有256MB的非分页池的服务器可以支持50,000或更大的连接量。但是必须限制重叠的accept数量,以及在已经建立连接的重叠收发操作。在这个例子中,如果已经建立连接的套接字,按每个1.5KB计算,将耗费75MB的非分页池内存。如果采用了上面提及的投递0字节接收的方法,这样为每个连接分配的IRP将占用25MB的非分页池内存。

如果系统耗尽了非分页池,会有两种可能的后果。在最好的情况下,Winsock调用将返回WSAENOBUFS错误。最糟糕的情况是系统崩溃,这种情况通常是系统没能正确处理内存非配的问题造成的。没有一种可行的方案能够恢复非分页池耗尽的错误,并且也没有可行的方案来监视非分页池可分配的大小,因为非分页池耗尽导致系统崩溃。

由以上探讨,可以得出结论,没有一种方法可以确定服务器到底支持多大的并发连接和重叠操作,并且也不可能准确地获知非分页池是否耗尽或者锁定内存页数超过极限。因为它们都将导致Winsock调用都返回相同的错误—WSAENOBUFS。因为以上因素,针对服务器的测试必须测试不同数量的连接情况以及重叠操作完成情况,以便在并发通信规模和数据吞吐率这两个指标之间选择一种折中的方案。如果在方案中强加限制,以防止服务器耗尽非分页池,则返回WSAENOBUFS错误时,我们就知道是因为超过了锁定页的限制。并且可以以一种更优化的处理方式编写程序,如进一步限制一些待决的操作或关闭某些连接。

包重新排序问题

这个问题与伸缩性没有多大关联,但是却是实际通信中不得不考虑的一个问题,因为它涉及到能否正确通信的问题。

虽然使用完成端口的I/O操作总是会按照它们被提交的顺序完成,但是线程调度问题可能会导致关联到完成端口上的工作不能按正常顺序完成。例如,有两个I/O工作线程,应该接收字节块1,字节块2,字节块3”,但是你可能以错误顺序接收这3个字节块:字节块2,字节块1,字节块3”。这也意味着在完成端口上投递发送请求发送数据时,数据实际也会以错误顺序被发送出去。

当然,如果只使用一个工作线程,仅提交一个I/O调用,是不存在顺序问题的。因为同一时刻,一个工作线程只能处理一个I/O操作。但是,这样就没有发挥出完成端口的真正优点。

如第3章《自定义应用层通信协议》所述,一个简单的解决方法就是为每个封包添加一个协议头。协议头主要是一个封包的实际字节数,如自定义Package包的第一个字段m_nCmdLen就是这个包占用的字节数。通信的接受方通过分析协议头分析本次通信有多少数据要接收,然后继续读后面的数据,直到一个封包被完整接收完才接收下一个封包。

当服务器一次仅做一个异步调用时,上述封包协议头的解决方案是很有效的。但是,如果要充分发挥IOCP服务器的潜力,肯定有多个未决的异步读操作等待数据的到来。这意味着,多个一步操作不能按顺序完成,未决读I/O返回的字节流不能按顺序处理,接收到的字节流可能组合成正确的封包,也有可能组合成错误的封包。因此,要解决这个问题,还必须为提交的读I/O分配序列号。

 

说明:

本文主要译自《Network programming for microsoft windows》一书的6.2节《可伸缩的服务器体系结构》和6.3节《资源管理》。

其中包重新排序问题,参考王艳平著Windows网络与通信程序设计4.3.4节《包重新排序问题》

阅读(6048) | 评论(2)


版权声明:编程爱好者网站为此博客服务提供商,如本文牵涉到版权问题,编程爱好者网站不承担相关责任,如有版权问题请直接与本文作者联系解决。谢谢!

评论

loading...
您需要登录后才能评论,请 登录 或者 注册