6.2.1 接受连接的方法
Winsock 扩展函数 AcceptEx 是唯一能够使用重叠 I/O 接受客户连接的函数。下面主要深入探讨使用该函数接收连接的问题。
前面已经讨论过,当客户连接进来时,服务器需要创建一个套接字来负责维护与一个客户端的会话。使用 AcceptEx 函数之前必须创建一些套接字,并且这些套接字必须是未绑定、未连接的,即使它们可能在调用 TransmitFile, TransmitPackets, 或 DisconnectEx 后可以重用。
响应服务器必须总是具有足够的 AcceptEx 在站岗,以便在有客户连接请求时调用。但是,并没有具体的数量能够保证服务器能够立即响应连接。我们知道在调用 listen 将监听套接字置于监听状态后, TCP/IP 堆栈会自动接受到来的连接,直到达到 listen 的 backlog 参数设定的限制。对于 Windows NT 服务器而言,支持的 backlog 的最大值为 200 。如果服务器投递了 15 个 AcceptEx 调用,然后突然有 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_SNDBUF 或 SO_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 的占用也是很高的。
对于配置比较高的服务器而言,处理成千上万个连接并不成问题。但是随着连接量的剧增,内存资源的限制将逐渐凸现。最有可能遇到的两个限制因素就是锁定页和非分页池。锁定页的限制不是太严重,更应该避免的是非分页池被耗尽。每一次调用重叠的 send 或 receive 请求,提交的缓冲区都可能被锁住。当内存被锁定时,它就不能从物理内存换出。操作系统对锁定内存的数量是有限制的,当达到极限时,重叠操作将会返回 WSAENOBUFS 错误。如果服务器在每个连接上投递多个重叠接收操作,随着客户连接数量的增多,极限就会达到。如果期望服务器能够处理高并发通信,服务器可以在每个连接上投递一个 0 字节的接受操作,这样就不会有内存锁定。 0 字节的接受完成以后,服务器可以简单地执行一个非阻塞的接收函数来获取缓存在套接字接收缓冲区中的所有数据。当非阻塞接收调用返回 WSAEWOULDBLOCK 时,就表示不再有未决的数据了。这种方法非常适合用来设计那些希望通过牺牲每个套接字上的吞吐率来获取更大规模并发连接的服务器。
当然,最好还要了解客户端与服务器通信的方式。在上面的例子中,当 0 字节的接收完成后,再投递一个异步接收操作,将接收到所有缓存在套接字接收缓冲区中的数据。如果服务器知道客户端将会连续不断发送数据,那么当 0 字节的接收完成后,假如客户端将发送大数据块(超过单套接字缓冲区 8KB 的容量)过来,服务器将抛出一个或多个重叠的接收操作。
另外一个需要重点考虑的问题就是系统所需页的数量。当系统锁定传递给重叠操作的内存时,它是在页边界上进行的。在 x86 体系结构上,内存页的大小为 4KB 。如果一个操作投递了 1KB 的缓冲区,系统实际上会为它锁定 4KB 大小的内存块。为避免这种浪费,重叠发送和接收缓冲区的大小应该是页大小的倍数。可以使用 GetSystemInfo 这个 API 来获知当前系统页的大小。
如果突破非分页池极限,将会导致更严重的错误,并且很难恢复。非分页池是内存的一部分,它常驻内存,并且永远不会被交换出去。内核模式的系统组件,如驱动程序,通常使用非分页池,其中包括 Winsock 和协议驱动程序,例如 tcpip.sys 。每个套接字的创建将消耗一小部分非分页池,用于维持套接字状态信息。当套接字绑定到一个地址后, TCP/IP 堆栈将分配额外的非分页池来保存本地地址的信息。当一个对等套接字接入后, TCP/IP 堆栈也将分配部分非分页池来保存远程地址信息。基本上,一个建立连接的套接字占用 2KB 非分页池内存 , 而 accept 或 AcceptEx 返回的套接字则占用 1.5KB 非分页池内存。之所以出现这个区别,是因为服务器本地地址信息已经存储在监听套接字中,故 accept 或 AcceptEx 返回的套接字只需保存远程主机地址信息。此外,每个在套接字上投递的重叠操作都需要给 I/O 请求包( IRP )分配内存,一个 IRP 使用大约 500B 非分页池内存。
从以上分析可以看出,为每个连接分配的非分页池内存并不是很大。然而,随着客户连接量逐增,服务器对非分页池的使用将是非常大的。考虑运行在只有 1GB 物理内存的 Windows 2000 或以后版本 Windows 系统上的服务器,将有 256MB 的内存非配给非分页池。通常,非分页池大小是机器物理内存的 1/4 , Windows 2000 及以后版本的 Windows 系统上,非分页池大小为 256MB ( /1GB ),而 Windows NT 4.0 限制为 128MB ( 1GB )。拥有 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 节《包重新排序问题》 。