上一篇中介绍了TCP数据传输中涉及的一些基本知识点。本文让我们看看TCP中的4种定时器。
TCP定时器
对于每个TCP连接,TCP管理4个不同的定时器,下面看看对4种定时器的简单介绍。
重传定时器使用于当希望收到另一端的确认。
该定时器是用来决定超时和重传的。
由于网络环境的易变性,该定时器时间长度肯定不是固定值;该定时器时间长度的设置依据是RTT(Round Trip Time),根据网络环境的变化,TCP会根据这些变化并相应地改变超时时间。
坚持定时器(persist)使窗口大小信息保持不断流动,即使另一端关闭了其接收窗口。
保活定时器(keepalive)可检测到一个空闲连接的另一端何时崩溃或重启。
2MSL定时器测量一个连接处于TIME_WAIT状态的时间。
参见"动手学习TCP:TCP特殊状态"中对TIME_WAIT的介绍
下面就介绍一下坚持定时器和保活定时器。
坚持定时器
TCP通过让接收方指明希望从发送方接收的数据字节数(即窗口大小)来进行流量控制。
如果窗口大小为 0会发生什么情况呢?这将有效地阻止发送方传送数据,直到窗口变为非0为止。
但是,由于TCP不对ACK报文段进行确认(TCP只确认那些包含有数据的ACK报文段),如果上图中通知发送方窗口大于0的[ACK]丢失了,则双方就有可能因为等待对方而使连接死锁。接收方等待接收数据(因为它已经向发送方通告了一个非0的窗口),而发送方在等待允许它继续发送数据的窗口更新。
为防止这种死锁情况的发生,发送方使用一个坚持定时器 (persist timer)来周期性地向接收方查询,以便发现窗口是否已增大。这些从发送方发出的报文段称为窗口探查(window probe)。
实验代码
下面通过Python socket实现一个快的发送端和慢的接收端,然后通过Wireshark抓包来看看窗口更新通知和窗口探查。
客户端代码如下,用户输入字符,客户端将用户输入重复1000次然后发送给服务端,通过这种简单的重复来模拟一个快的发送端:
from socket import * import time HOST = "192.168.56.102" PORT = 8081 ADDR = (HOST, PORT) client = socket(AF_INET, SOCK_STREAM) client.connect(ADDR) while True: input = raw_input() if input: client.send(input*1000) else: client.close() break
对于服务端,通过制定一个小的接收BUFFER,以及一个延时来模拟一个慢的接收端:
import sys from socket import * import time HOST = "192.168.56.102" PORT = 8081 BUFSIZ = 100 ADDR = (HOST, PORT) server = socket(AF_INET, SOCK_STREAM) print "Socket created" try: server.bind(ADDR) except error, msg: print 'Bind failed. Error Code : ' + str(msg[0]) + ' Message ' + msg[1] sys.exit() server.listen(1) print 'Socket now listening' conn, addr = server.accept() while True: time.sleep(3) try: data = conn.recv(BUFSIZ) if data: print data else: conn.close() break except Exception, e: print e break
#p#
在开始运行代码之前还需要进行一些设置,默认情况下接收端的window size很大,实验中很难耗尽。
所以,为了看到实验效果,需要对系统进行一些设置。打开虚拟机中的注册表设置"regedit",然后找到选项"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters",设置"TcpWindowSize"为4096Bytes。
注意,实验结束后,一定要恢复"TcpWindowSize"的原始设置,不然可能会影响正常的网络访问。
关于更多TCP相关的注册表设置,可以参考这个链接。
运行效果
下面运行代码,分别输入两个字符"a"和"b",通过Wireshark可以看到,在进行连接确认的时候,接收端已经给出了我们跟新后的可用窗口4096Bytes。
经过***轮发送后,接收方的window size减少了1000;当两个数据包都处理完成后,window size又恢复到了4096。
第二轮测试中,发送端发送"1234567890"十个字符,从接收端的***一个[ACK]包可以看到,***接收端window size为1393,此次传输到此结束。
过了一段时间,当慢接收端处理完数据之后,接收端会发送窗口更新,通知发送端可以窗口为4096Bytes。
第三轮测试中,发送端发送更多的字符"1234567890987654321",这次接收端的可用窗口就被耗尽了,然后接收端发送一个[TCP ZeroWindow]的通知;这时,发送端停止发送,然后通过发送窗口探查。
当接收端有可用窗口的时候,接收端会发送窗口更新,数据传输继续。
注意,[TCP ZeroWindowProbe]和[TCP ZeroWindowProbeAck]的Seq和Ack号。
糊涂窗口综合症
基于窗口的流量控制方案,会导致一种"糊涂窗口综合症SWS(Silly Window Syndrome)"的状况。
当发送端应用进程产生数据很慢、或接收端应用进程处理接收缓冲区数据很慢,或二者兼而有之;就会使应用进程间传送的报文段很小,特别是有效载荷很小。 极端情况下,有效载荷可能只有1个字节;而传输开销有40字节(20字节的IP头+20字节的TCP头),加上物理帧头后,有效的数据传输比例就更小了,这就浪费了网络带宽,表现为糊涂窗口综合症。
糊涂窗口综合症可能由接收端或者发送端引起,不同的起因需要不同的解决方案,更多内容可以参考此处。
保活定时器
跟据TCP协议,当发送端和接收端都不主动释放一个TCP连接的时候,该连接将一直保持。即使一端出现了故障,由于另一端没有收到任何通知,TCP连接也会一直保持,这样就会造成TCP连接资源的浪费。
TCP keepalive
为了解决这个问题,大多数的实现中都是使服务器设置保活计时器。
保活计时器通常设置为2小时。若服务器过了2小时还没有收到客户的信息,它就发送探测报文段。若发送了10个探测报文段(每一个相隔75秒)还没有响应,就假定客户出了故障,因而就终止该连接。
在Linux系统中,有三个跟TCP keepalive相关的参数:
tcp_keepalive_intvl (integer; default: 75; since Linux 2.4) The number of seconds between TCP keep-alive probes. tcp_keepalive_probes (integer; default: 9; since Linux 2.2) The maximum number of TCP keep-alive probes to send before giving up and killing the connection if no response is obtained from the other end. tcp_keepalive_time (integer; default: 7200; since Linux 2.2) The number of seconds a connection needs to be idle before TCP begins sending out keep-alive probes. Keep- alives are sent only when the SO_KEEPALIVE socket option is enabled. The default value is 7200 seconds (2 hours). An idle connection is terminated after approximately an additional 11 minutes (9 probes an interval of 75 seconds apart) when keep-alive is enabled.
在Socket编程中,可以通过设置"TCP_KEEPCNT","TCP_KEEPIDLE"和"TCP_KEEPINTVL"选项来更改上述的三个系统参数:
from socket import * import time HOST = "192.168.56.102" PORT = 8081 ADDR = (HOST, PORT) client = socket(AF_INET, SOCK_STREAM) #TCP_KEEPCNT overwrite tcp_keepalive_probes,默认9(次) #TCP_KEEPIDLE overwrite tcp_keepalive_time,默认7200(秒) #TCP_KEEPINTVL overwrite tcp_keepalive_intvl,默认75(秒) client.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1) client.setsockopt(SOL_TCP, TCP_KEEPCNT, 5) client.setsockopt(SOL_TCP, TCP_KEEPINTVL, 5) client.setsockopt(SOL_TCP, TCP_KEEPIDLE, 10) client.connect(ADDR) while True: input = raw_input() if input: client.send(input*1000) else: client.close() break
#p#
TCP keepalive 包
下面是一段网络上抓取的TCP keepalive包,接下来看看TCP keepalive包的内容。
根据规范,TCP keepalive保活包不应该包含数据,但也可以包含1个无意义的字节,比如0x0。
TCP保活探测包Seq号是将前一个TCP包的Seq号减去1。
当然,也有人认为保活定时器不合理,给出了不使用保活定时器的理由:
在出现短暂差错的情况下,这可能会使一个非常好的连接释放掉
耗费了不必要的带宽
在按分组计费的情况下会在互联网上花掉更多的钱
HTTP Keep-Alive
在HTTP早期 ,每个HTTP请求都要求打开一个TCP连接,并且使用一次之后就断开这个TCP连接。
这种方式会带来一些问题,尤其是包含图片,JS,CSS的复杂网页,一个完整的页面需要很多个请求才能完成,如果每一个HTTP请求都需要新建并断开一个TCP,这样就会消耗很多服务器的TCP连接资源。
为了缓解这个问题,HTTP 1.1中出现了Keep-Alive这个特性,开启HTTP Keep-Alive之后,能复用已有的TCP链接,当前一个请求已经响应完毕,服务器端没有立即关闭TCP链接,而是等待一段时间接收浏览器端可能发送过来的第二个请求,开启Keep-Alive能节省的TCP建立和关闭的消耗。
下面看看我访问一个网页后,通过Wireshark抓取的数据包。
HTTP/1.1之后默认开启Keep-Alive, 在HTTP的头域中增加Connection选项。当设置为"Connection:keep-alive"表示开启,设置为"Connection:close"表示关闭。
在上图中,服务器经过了大概2分钟的时间,然后发出关闭TCP连接的请求。
现在,基本所有的应用服务器都支持设置打开Keep-Alive,以及Keep-Alive timeout的设置。
总结
本文介绍了TCP中的4种定时器,并详细的介绍了坚持定时器和保活定时器。
在保活定时器的介绍中,对比介绍了HTTP的Keep-Alive特性。HTTP协议的Keep-Alive意图在于连接复用;TCP的keepalive机制在于保活、心跳,检测连接错误,两者的作用完全不同。
因为TCP keepalive不能满足实时性的要求,很多应用程序会在应用层实现heart beat(心跳包)来确认TCP连接的可用性。