3. TCP协议详解

第3章 TCP协议详解

1. TCP服务的特点

传输层协议主要有两个:TCP协议和UDP协议。TCP协议相对于UDP协议的特点是:面向连接、字节流和可靠传输。

使用TCP协议通信的双方必须先建立连接,然后才能开始数据的读写。双方都必须为该连接分配必要的内核资源,以管理连接的状态和连接上数据的传输。TCP连接是全双工的,即双方的数据读写可以通过一个连接进行。完成数据交换之后,通信双方都必须断开连接以释放系统资源。

TCP协议的这种连接是一对一的,所以基于广播和多播(目标是多个主机地址)的应用程序不能使用TCP服务。而无连接协议UDP则非常适合于广播和多播。

字节流服务和数据报服务的区别。

  • 这种区别对应到实际编程中,则体现为通信双方是否必须执行相同次数的读、写操作(当然,这只是表现形式)。当发送端应用程序连续执行多次写操作时,TCP模块先将这些数据放入TCP发送缓冲区中。当TCP模块真正开始发送数据时,发送缓冲区中这些等待发送的数据可能被封装成一个或多个TCP报文段发出。因此,TCP模块发送出的TCP报文段的个数和应用程序执行的写操作次数之间没有固定的数量关系。
  • 当接收端收到一个或多个TCP报文段后,TCP模块将它们携带的应用程序数据按照TCP报文段的序号(见后文)依次放入TCP接收缓冲区中,并通知应用程序读取数据。接收端应用程序可以一次性将TCP接收缓冲区中的数据全部读出,也可以分多次读取,这取决于用户指定的应用程序读缓冲区的大小。因此,应用程序执行的读操作次数和TCP模块接收到的TCP报文段个数之间也没有固定的数量关系。
  • 综上所述,发送端执行的写操作次数和接收端执行的读操作次数之间没有任何数量关系,这就是字节流的概念:应用程序对数据的发送和接收是没有边界限制的。
  • UDP则不然。发送端应用程序每执行一次写操作,UDP模块就将其封装成一个UDP数据报并发送之。接收端 必须及时针对每一个UDP数据报执行读操作(通过recvfrom系统调用),否则就会丢包(这经常发生在较慢的服务器上)。并且,如果用户没有指定足够的应用程序缓冲区来读取UDP数据,则UDP数据将被截断。 图3-1和图3-2显示了TCP字节流服务和UDP数据报服务的上述区 别。两图中省略了传输层以下的通信细节。

  • TCP传输是可靠的。
    • 首先,TCP协议采用发送应答机制,即发送端发送的每个TCP报文段都必须得到接收方的应答,才认为这个TCP报文段传输成功。
    • 其次,TCP协议采用超时重传机制,发送端在发送出一个TCP报文段之后启动定时器,如果在定时时间内未收到应答,它将重发该报文段。
    • 最后,因为TCP报文段最终是以IP数据报发送的,而IP数据报到达接收端可能乱序、重复,所以TCP协议还会对接收到的TCP报文段重排、整理,再交付给应用层。
  • UDP协议则和IP协议一样,提供不可靠服务。它们都需要上层协议来处理数据确认和超时重传。

2. TCP头部结构

TCP头部信息出现在每个TCP报文段中,用于指定通信的源端端口,目的端端口,管理TCP连接等

2.1 TCP固定头部结构

TCP头部结构如图3-3所示,其中的诸多字段为管理TCP连接和控制数据流提供了足够的信息。

16位端口号(port number):告知主机该报文段是来自哪里(源端口)以及传给哪个上层协议或应用程序(目的端口)的。进行TCP通信时,客户端通常使用系统自动选择的临时端口号,而服务器则使用知名服务端口号。所有知名服务使用的端口号都定义在/etc/services文件中。

32位序号(sequence number):一次TCP通信(从TCP连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。假设主机A和主机B进行TCP通信,A发送给B的第一个TCP报文段中,序号值被系统初始化为某个随机值ISN(Initial Sequence Number,初始序号值)。那么在该传输方向上(从A到B),后续的TCP报文段中序号值将被系统设置成ISN加上该报文段所携带数据的第一个字节在整个字节流中的偏移。另外一个传输方向 (从B到A)的TCP报文段的序号值也具有相同的含义。

32位确认号(acknowledgement number):用作对另一方发送来的TCP报文段的响应。其值是收到的TCP报文段的序号值加1。假设主机A和主机B进行TCP通信,那么A发送出的TCP报文段不仅携带自己的序号,而且包含对B发送来的TCP报文段的确认号。反之,B发送出的TCP报文段也同时携带自己的序号和对A发送来的报文段的确认号。

4位头部长度(header length):标识该TCP头部有多少个32bit字(4字节)。因为4位最大能表示15,所以TCP头部最长是60字节。

6位标志位包含如下几项:

❑URG标志,表示紧急指针(urgent pointer)是否有效。

❑ACK标志,表示确认号是否有效。我们称携带ACK标志的TCP报文段为确认报文段。

❑PSH标志,提示接收端应用程序应该立即从TCP接收缓冲区中读走数据,为接收后续数据腾出空间(如果应用程序不将接收到的数据读走,它们就会一直停留在TCP接收缓冲区中)。

❑RST标志,表示要求对方重新建立连接。我们称携带RST标志的TCP报文段为复位报文段。

❑SYN标志,表示请求建立一个连接。我们称携带SYN标志的TCP报文段为同步报文段。

❑FIN标志,表示通知对方本端要关闭连接了。我们称携带FIN标志的TCP报文段为结束报文段。

16位窗口大小(window size):是TCP流量控制的一个手段。这里说的窗口,指的是接收通告窗口(Receiver Window,RWND)。它告诉对方本端的TCP接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度。

16位校验和(TCP checksum):由发送端填充,接收端对TCP报文段执行CRC算法以检验TCP报文段在传输过程中是否损坏。注意,这个校验不仅包括TCP头部,也包括数据部分。这也是TCP可靠传输的一个重要保障。

16位紧急指针(urgent pointer):是一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏移,不妨称之为紧急偏移。TCP的紧急指针是发送端向接收端发送紧急数据的方法。

2.2 TCP头部选项

TCP头部的最后一个选项字段(options)是可变长的可选信息。这部分最多包含40字节,因为TCP头部最长是60字节(其中还包含前面讨论的20字节的固定部分)。典型的TCP头部选项结构如图3-4所示。

选项的第一个字段kind说明选项的类型。有的TCP选项没有后面两个字段,仅包含1字节的kind字段。

第二个字段length(如果有的话)指定该选项的总长度,该长度包括kind字段和length字段占据的2字节。

第三个字段info(如果有的话)是选项的具体信息。

常见的TCP选项有7种,如图3-5所示。

  • kind=0是选项表结束选项。
  • kind=1是空操作(nop)选项,没有特殊含义,一般用于将TCP选项的总长度填充为4字节的整数倍。
  • kind=2是最大报文段长度选项。TCP连接初始化时,通信双方使用该选项来协商最大报文段长度(Max Segment Size,MSS)。TCP模块通常将MSS设置为(MTU-40)字节(减掉的这40字节包括20字节的 TCP头部和20字节的IP头部)。这样携带TCP报文段的IP数据报的长度就不会超过MTU(假设TCP头部和IP头部都不包含选项字段,并且这也是一般情况),从而避免本机发生IP分片。对以太网而言,MSS值是1460(1500-40)字节。
  • kind=3是窗口扩大因子选项。TCP连接初始化时,通信双方使用该选项来协商接收通告窗口的扩大因子。在TCP的头部中,接收通告窗口大小是用16位表示的,故最大为65 535字节,但实际上TCP模块允许的接收通告窗口大小远不止这个数(为了提高TCP通信的吞吐量)。窗口扩大因子解决了这个问题。假设TCP头部中的接收通告窗口大小是N,窗口扩大因子(移位数)是M,那么TCP报文段的实际接收通告窗口大小是N乘2M,或者说N左移M位。注意,M的取值范围是0~14。我们可以通过修改/proc/sys/net/ipv4/tcp_window_scaling内核变量来启用或关闭窗口扩大因子选项。
    • 和MSS选项一样,窗口扩大因子选项只能出现在同步报文段中,否则将被忽略。但同步报文段本身不执行窗口扩大操作,即同步报文段头部的接收通告窗口大小就是该TCP报文段的实际接收通告窗口大小。当连接建立好之后,每个数据传输方向的窗口扩大因子就固定不变了。关于窗口扩大因子选项的细节,可参考标准文档RFC 1323。
  • kind=4是选择性确认(Selective Acknowledgment,SACK)选项。TCP通信时,如果某个TCP报文段丢失,则TCP模块会重传最后被确认的TCP报文段后续的所有报文段,这样原先已经正确传输的TCP报文段也可能重复发送,从而降低了TCP性能。SACK技术正是为改善这种情况而产生的,它使TCP模块只重新发送丢失的TCP报文段,不用发送所有未被确认的TCP报文段。选择性确认选项用在连接初始化时,表示是否支持SACK技术。我们可以通过修改/proc/sys/net/ipv4/tcp_sack内核变量来启用或关闭选择性确认选项。
  • kind=5是SACK实际工作的选项。该选项的参数告诉发送方本端已经收到并缓存的不连续的数据块,从而让发送端可以据此检查并重发丢失的数据块。每个块边沿(edge of block)参数包含一个4字节的序号。其中块左边沿表示不连续块的第一个数据的序号,而块右边沿则表示不连续块的最后一个数据的序号的下一个序号。这样一对参数(块左边沿和块右边沿)之间的数据是没有收到的。因为一个块信息占用8字节,所以TCP头部选项中实际上最多可以包含4个这样的不连续数据块(考虑选项类型和长度占用的2字节)。
  • kind=8是时间戳选项。该选项提供了较为准确的计算通信双方之间的回路时间(Round Trip Time,RTT)的方法,从而为TCP流量控制提供重要信息。我们可以通过修改/proc/sys/net/ipv4/tcp_timestamps内核变 量来启用或关闭时间戳选项。

2.3 使用tcpdump观察TCP头部信息

代码清单3-1 用tcpdump抓取数据包

1
2
3
4
5
IP 127.0.0.1.41621>127.0.0.1.23:Flags[S],seq 3499745539,win 32792,options[mss 16396,sackOK,TS val 40781017 ecr 0,nop,wscale 6],length 0
0x0000:4510 003c a5da 4000 4006 96cf 7f00 0001
0x0010:7f00 0001 a295 0017 d099 e103 0000 0000
0x0020:a002 8018 fe30 0000 0204 400c 0402 080a
0x0030:026e 44d9 0000 0000 0103 0306

tcpdump输出Flags[S],表示该TCP报文段包含SYN标志,因此它是一个同步报文段。如果TCP报文段包含其他标志,则tcpdump也会将该标志的首字母显示在“Flags”后的方括号中。

seq是序号值。因为该同步报文段是从127.0.0.1.41621(客户端IP地址和端口号)到127.0.0.1.23(服务器IP地址和端口号)这个传输方向上的第一个TCP报文段,所以这个序号值也就是此次通信过程中该传输方向的ISN值。并且,因为这是整个通信过程中的第一个TCP报文段,所以它没有针对对方发送来的TCP报文段的确认值(尚未收到任何对方发送来的TCP报文段)。

win是接收通告窗口的大小。因为这是一个同步报文段,所以win值反映的是实际的接收通告窗口大小。

options是TCP选项,其具体内容列在方括号中。

  • mss是发送端(客户端)通告的最大报文段长度。通过ifconfig命令查看回路接口的MTU为16436字节,因此可以预想到TCP报文段的MSS为16396(16436-40)字节。
  • sackOK表示发送端支持并同意使用SACK选项。
  • TS val是发送端的时间戳。
  • ecr是时间戳回显应答。因为这是一次TCP通信的第一个TCP报文段,所以它针对对方的时间戳的应答为0(尚未收到对方的时间戳)。
  • 紧接着的nop是一个空操作选项。
  • wscale指出发送端使用的窗口扩大因子为6。

接下来我们分析tcpdump输出的字节码中TCP头部对应的信息,它从第21字节开始,如表3-1所示。

从表3-1中可见,TCP报文段头部的二进制码和tcpdump输出的TCP报文段描述信息完全对应。

3. TCP连接的建立和关闭

3.1 使用tcpdump观察TCP连接的建立和关闭

首先从ernest-laptop上执行telnet命令登录Kongming20的80端口,然后抓取这一过程中客户端和服务器交换的TCP报文段。具体操作过程如下:

1
2
3
4
5
6
7
sudo tcpdump-i eth0-nt'(src 192.168.1.109 and dst 192.168.1.108)or(src 192.168.1.108 and dst 192.168.1.109)'
telnet 192.168.1.109 80
Trying 192.168.1.109...
Connected to 192.168.1.109.
Escape character is'^]'. ^](回车)#输入ctrl+]并回车
telnet>quit(回车)
Connection closed.

当执行telnet命令并在两台通信主机之间建立TCP连接后(telnet输出“Connected to 192.168.1.109”),输入Ctrl+]以调出telnet程序的命令提示符,然后在telnet命令提示符后输入quit以退出telnet客户端程序,从而结束TCP连接。整个过程中(从连接建立到结束),tcpdump输出的内容如代码清单3-2所示。

代码清单3-2 建立和关闭TCP连接的过程

1
2
3
4
5
6
7
1.IP 192.168.1.108.60871>192.168.1.109.80:Flags[S],seq 535734930,win 5840,length 0
2.IP 192.168.1.109.80>192.168.1.108.60871:Flags[S.],seq 2159701207,ack 535734931,win 5792,length 0
3.IP 192.168.1.108.60871>192.168.1.109.80:Flags[.],ack 1,win 92,length 0
4.IP 192.168.1.108.60871>192.168.1.109.80:Flags[F.],seq 1,ack 1,win 92,length 0
5.IP 192.168.1.109.80>192.168.1.108.60871:Flags[.],ack 2,win 91,length 0
6.IP 192.168.1.109.80>192.168.1.108.60871:Flags[F.],seq 1,ack 2,win 91,length 0
7.IP 192.168.1.108.60871>192.168.1.109.80:Flags[.],ack 2,win 92,length 0

因为整个过程并没有发生应用层数据的交换,所以TCP报文段的数据部分的长度(length)总是0。为了更清楚地表示建立和关闭TCP连接的整个过程,我们将tcpdump输出的内容绘制成图3-6所示的时序图。

  • 第1个TCP报文段包含SYN标志,因此它是一个同步报文段,即ernest-laptop(客户端)向Kongming20(服务器)发起连接请求。同时,该同步报文段包含一个ISN值为535734930的序号。
  • 第2个TCP报文段也是同步报文段,表示Kongming20同意与ernest-laptop建立连接。同时它发送自己的ISN值为2159701207的序号,并对第1个同步报文段进行确认。确认值是535734931,即第1个同步报文段的序号值加1。前文说过,序号值是用来标识TCP数据流中的每一字节的。但同步报文段比较特殊,即使它并没有携带任何应用程序数据,它也要占用一个序号值。
  • 第3个TCP报文段是ernest-laptop对第2个同步报文段的确认。至此,TCP连接就建立起来了。建立TCP连接的这3个步骤被称为TCP三次握手。
  • 从第3个TCP报文段开始,tcpdump输出的序号值和确认值都是相对初始ISN值的偏移。当然,我们可以开启tcpdump的-S选项来选择打印序号的绝对值。
  • 后面4个TCP报文段是关闭连接的过程。第4个TCP报文段包含FIN标志,因此它是一个结束报文段,即ernest-laptop要求关闭连接。结束报文段和同步报文段一样,也要占用一个序号值。Kongming20用TCP报文段5来确认该结束报文段。紧接着Kongming20发送自己的结束报文段6,ernest-laptop则用TCP报文段7给予确认。实际上,仅用于确认目的的确认报文段5是可以省略的,因为结束报文段6也携带了该确认信息。确认报文段5是否出现在连接断开的过程中,取决于TCP的延迟确认特性。
  • 在连接的关闭过程中,因为ernest-laptop先发送结束报文段(telnet客户端程序主动退出),故称ernest-laptop执行主动关闭,而称Kongming20执行被动关闭。
  • 一般而言,TCP连接是由客户端发起,并通过三次握手建立(特殊情况是所谓同时打开)的。TCP连接的关闭过程相对复杂一些。可能是客户端执行主动关闭,比如前面的例子;也可能是服务器执行主动关闭,比如服务器程序被中断而强制关闭连接;还可能是同时关闭(和同时打开一样,非常少见)。

3.2 半关闭状态

TCP连接是全双工的,所以它允许两个方向的数据传输被独立关闭。换言之,通信的一端可以发送结束报文段给对方,告诉它本端已经完成了数据的发送,但允许继续接收来自对方的数据,直到对方也发送结束报文段以关闭连接。TCP连接的这种状态称为半关闭(halfclose)状态,如图3-7所示。

请注意,在图3-7中,服务器和客户端应用程序判断对方是否已经关闭连接的方法是:read系统调用返回0(收到结束报文段)。当然,Linux还提供其他检测连接是否被对方关闭的方法,这将在后续章节讨论。

socket网络编程接口通过shutdown函数提供了对半关闭的支持,我们将在后续章节讨论它。这里强调一下,虽然我们介绍了半关闭状态,但是使用半关闭的应用程序很少见。

3.3 连接超时

前面我们讨论的是很快建立连接的情况。如果客户端访问一个距离它很远的服务器,或者由于网络繁忙,导致服务器对于客户端发送出的同步报文段没有应答,此时客户端程序将产生什么样的行为呢?

显然,对于提供可靠服务的TCP来说,它必然是先进行重连(可能执行多次),如果重连仍然无效,则通知应用程序连接超时。为了观察连接超时,我们模拟一个繁忙的服务器环境,在ernest-laptop上执行下面的操作:

1
2
sudo iptables-F
sudo iptables-I INPUT-p tcp--syn-i eth0-j DROP

iptable命令用于过滤数据包,这里我们利用它来丢弃所有接收到的连接请求(丢弃所有同步报文段,这样客户端就无法得到任何确认报文段)。

接下来从Kongming20上执行telnet命令登录到ernest-laptop,并用tcpdump抓取这个过程中双方交换的TCP报文段。具体操作如下:

1
2
3
4
5
6
sudo tcpdump-n-i eth0 port 23#仅抓取telnet客户端和服务器交换的数据包
date;telnet 192.168.1.108;date#在telnet命令前后都执行date命令,以计算超时时间
Mon Jun 11 21:23:35 CST 2012
Trying 192.168.1.108...
telnet:connect to address 192.168.1.108:Connection timed out
Mon Jun 11 21:24:38 CST 2012

从两次date命令的输出来看,Kongming20建立TCP连接的超时时间是63s。本次tcpdump的输出如代码清单3-3所示。代码清单3-3 TCP超时重连

1
2
3
4
5
6
7
8
9
10
11
12
1.21:23:35.612136 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0
2.21:23:36.613146 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0
3.21:23:38.617279 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0
4.21:23:42.625140 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0
5.21:23:50.641344 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0
6.21:24:06.673331 IP 192.168.1.109.39385>
192.168.1.108.telnet:Flags[S],seq 1355982096,length 0

这次抓包我们保留了tcpdump输出的时间戳(不使用其-t选项),以便推理Linux的超时重连策略。

  • 我们一共抓取到6个TCP报文段,它们都是同步报文段,并且具有相同的序号值,这说明后面5个同步报文段都是超时重连报文段。
  • 观察这些TCP报文段被发送的时间间隔,它们分别为1s、2s、4s、8s和16s(由于定时器精度的问题,这些时间间隔都有一定偏差),可以推断最后一个TCP报文段的超时时间是32s(63s-16s-8s-4s-2s-1s)。
  • 因此,TCP模块一共执行了5次重连操作,这是由/proc/sys/net/ipv4/tcp_syn_retries内核变量所定义的。每次重连的超时时间都增加一倍。
  • 在5次重连均失败的情况下,TCP模块放弃连接并通知应用程序。在应用程序中,我们可以修改连接超时时间。

4. TCP状态转移

TCP连接的任意一端在任一时刻都处于某种状态,当前状态可以通过netstat命令(见第17章)查看。

图3-8中的粗虚线表示典型的服务器端连接的状态转移;粗实线表示典型的客户端连接的状态转移。CLOSED是一个假想的起始点,并不是一个实际的状态。

4.1 TCP状态转移总图

  • 服务器通过listen系统调用(见第5章)进入LISTEN状态,被动等待客户端连接,因此执行的是所谓的被动打开。
  • 服务器一旦监听到某个连接请求(收到同步报文段),就将该连接放入内核等待队列中,并向客户端发送带SYN标志的确认报文段。此时该连接处于SYN_RCVD状态。
  • 如果服务器成功地接收到客户端发送回的确认报文段,则该连接转移到ESTABLISHED状态。ESTABLISHED状态是连接双方能够进行双向数据传输的状态。
  • 当客户端主动关闭连接时(通过close或shutdown系统调用向服务器发送结束报文段),服务器通过返回确认报文段使连接进入CLOSE_WAIT状态。这个状态的含义很明确:等待服务器应用程序关闭连接。通常,服务器检测到客户端关闭连接后,也会立即给客户端发送一个结束报文段来关闭连接。这将使连接转移到LAST_ACK状态,以等待客户端对结束报文段的最后一次确认。一旦确认完成,连接就彻底关闭了。

下面讨论客户端的典型状态转移过程,此时我们说的连接状态都是指该连接的客户端的状态。

客户端通过connect系统调用(见第5章)主动与服务器建立连接。connect系统调用首先给服务器发送一个同步报文段,使连接转移到SYN_SENT状态。此后,connect系统调用可能因为如下两个原因失败返回:

❑如果connect连接的目标端口不存在(未被任何进程监听),或者该端口仍被处于TIME_WAIT状态的连接所占用(见后文),则服务器将给客户端发送一个复位报文段,connect调用失败。

❑如果目标端口存在,但connect在超时时间内未收到服务器的确认报文段,则connect调用失败。

connect调用失败将使连接立即返回到初始的CLOSED状态。如果客户端成功收到服务器的同步报文段和确认,则connect调用成功返回,连接转移至ESTABLISHED状态。

当客户端执行主动关闭时,它将向服务器发送一个结束报文段,同时连接进入FIN_WAIT_1状态。若此时客户端收到服务器专门用于确认目的的确认报文段(比如图3-6中的TCP报文段5),则连接转移至FIN_WAIT_2状态。当客户端处于FIN_WAIT_2状态时,服务器处于CLOSE_WAIT状态,这一对状态是可能发生半关闭的状态。此时如果服务器也关闭连接(发送结束报文段),则客户端将给予确认并进入TIME_WAIT状态。

图3-8还给出了客户端从FIN_WAIT_1状态直接进入TIME_WAIT状态的一条线路(不经过FIN_WAIT_2状态),前提是处于FIN_WAIT_1状态的服务器直接收到带确认信息的结束报文段(而不是先收到确认报文段,再收到结束报文段)。这种情况对应于图3-6中的服务器不发送TCP报文段5。前面说过,处于FIN_WAIT_2状态的客户端需要等待服务器发送结束报文段,才能转移至TIME_WAIT状态,否则它将一直停留在这个状态。如果不是为了在半关闭状态下继续接收数据,连接长时间地停留在FIN_WAIT_2状态并无益处。连接停留在FIN_WAIT_2状态的情况可能发生在:客户端执行半关闭后,未等服务器关闭连接就强行退出了。此时客户端连接由内核来接管,可称之为孤儿连接(和孤儿进程类似)。Linux为了防止孤儿连接长时间存留在内核中,定义了两个内核变量:/proc/sys/net/ipv4/tcp_max_orphans和/proc/sys/net/ipv4/tcp_fin_timeout。前者指定内核能接管的孤儿连接数目,后者指定孤儿连接在内核中生存的时间。

至此,我们简单地讨论了服务器和客户端程序的典型TCP状态转移路线。对应于图3-6所示的TCP连接的建立与断开过程,客户端和服务器的状态转移如图3-9所示。 image-20241217185733486

4.2 TIME_WAIT状态

从图3-9来看,客户端连接在收到服务器的结束报文段(TCP报文段6)之后,并没有直接进入CLOSED状态[1],而是转移到TIME_WAIT状态。在这个状态,客户端连接要等待一段长为2MSL(Maximum Segment Life,报文段最大生存时间)的时间,才能完全关闭。MSL是TCP报文段在网络中的最大生存时间,标准文档RFC 1122的建议值是2min。

TIME_WAIT状态存在的原因有两点:

❑可靠地终止TCP连接。

❑保证让迟来的TCP报文段有足够的时间被识别并丢弃。

第一个原因很好理解。假设图3-9中用于确认服务器结束报文段6的TCP报文段7丢失,那么服务器将重发结束报文段。因此客户端需要停留在某个状态以处理重复收到的结束报文段(即向服务器发送确认报文段)。否则,客户端将以复位报文段来回应服务器,服务器则认为这是一个错误,因为它期望的是一个像TCP报文段7那样的确认报文 段。

在Linux系统上,一个TCP端口不能被同时打开多次(两次及以上)。当一个TCP连接处于TIME_WAIT状态时,我们将无法立即使用该连接占用着的端口来建立一个新连接。反过来思考,如果不存在TIME_WAIT状态,则应用程序能够立即建立一个和刚关闭的连接相似的连接(这里说的相似,是指它们具有相同的IP地址和端口号)。这个新的、和原来相似的连接被称为原来的连接的化身(incarnation)。新的化身可能接收到属于原来的连接的、携带应用程序数据的TCP报文段(迟到的报文段),这显然是不应该发生的。这就是TIME_WAIT状态存在的第二个原因。

另外,因为TCP报文段的最大生存时间是MSL,所以坚持2MSL时间的TIME_WAIT状态能够确保网络上两个传输方向上尚未被接收到的、迟到的TCP报文段都已经消失(被中转路由器丢弃)。因此,一个连接的新的化身可以在2MSL时间之后安全地建立,而绝对不会接收到属于原来连接的应用程序数据,这就是TIME_WAIT状态要持续2MSL时间的原因。

有时候我们希望避免TIME_WAIT状态,因为当程序退出后,我们希望能够立即重启它。但由于处在TIME_WAIT状态的连接还占用着端口,程序将无法启动(直到2MSL超时时间结束)。考虑一个例子:在测试机器ernest-laptop上以客户端方式运行nc(用于创建网络连接的工具,见第17章)命令,登录本机的Web服务,且明确指定客户端使用2345端口与服务器通信。然后从终端输入Ctrl+C终止客户端程序,接着又立即重启nc程序,以完全相同的方式再次连接本机的Web服务。具体操作如下:

1
2
3
4
5
6
nc-p 12345 192.168.1.108 80 ctrl+C#中断客户端程序
nc-p 12345 192.168.1.108 80#重启客户端程序,重新建立连接
nc:bind failed:Address already in use#输出显示连接失败,因为12345端口仍被占用
netstat-nat#用netstat命令查看连接状态
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 192.168.1.108:12345 192.168.1.108:80 TIME_WAIT

这里我们使用netstat命令查看连接的状态。其输出显示,客户端程序被中断后,连接进入TIME_WAIT状态,12345端口仍被占用,所以客户端重启失败。

对客户端程序来说,我们通常不用担心上面描述的重启问题。因为客户端一般使用系统自动分配的临时端口号来建立连接,而由于随机性,临时端口号一般和程序上一次使用的端口号(还处于TIME_WAIT状态的那个连接使用的端口号)不同,所以客户端程序一般可以立即重启。上面的例子仅仅是为了说明问题,我们强制客户端使用12345端口,这才导致立即重启客户端程序失败。

但如果是服务器主动关闭连接后异常终止,则因为它总是使用同一个知名服务端口号,所以连接的TIME_WAIT状态将导致它不能立即重启。不过,我们可以通过socket选项SO_REUSEADDR来强制进程立即使用处于TIME_WAIT状态的连接占用的端口,这将在第5章讨论。

5. 复位报文段

在某些特殊条件下,TCP连接的一端会向另一端发送携带RST标志的报文段,即复位报文段,以通知对方关闭连接或重新建立连接。本节讨论产生复位报文段的3种情况。

5.1 访问不存在的端口

4.1小节提到,当客户端程序访问一个不存在的端口时,目标主机将给它发送一个复位报文段。考虑从kongming20上执行telnet命令登录ernest-laptop上一个不存在的54321端口,并用tcpdump抓取该过程中 两台主机交换的TCP报文段。具体操作过程如下:

1
2
3
4
$sudo tcpdump-nt-i eth0 port 54321#仅抓取发送至和来自54321端口的TCP报文段
$telnet 192.168.1.108 54321
Trying 192.168.1.108...
telnet:connect to address 192.168.1.108:Connection refused

telnet程序的输出显示连接被拒绝了,因为这个端口不存在。tcpdump抓取到的TCP报文段内容如下:

1
2
3
4
1.IP 192.168.1.109.42001>192.168.1.108.54321:Flags[S],seq
21621375,win 14600,length 0
2.IP 192.168.1.108.54321>192.168.1.109.42001:Flags[R.],seq
0,ack 21621376,win 0,length 0

由此可见,ernest-laptop针对Kongming20的连接请求(同步报文段)回应了一个复位报文段(tcpdump输出R标志)。因为复位报文段的接收通告窗口大小为0,所以可以预见:收到复位报文段的一端应该关闭连接或者重新连接,而不能回应这个复位报文段。

实际上,当客户端程序向服务器的某个端口发起连接,而该端口仍被处于TIME_WAIT状态的连接所占用时,客户端程序也将收到复位报文段。

5.2 异常终止连接

前面讨论的连接终止方式都是正常的终止方式:数据交换完成之后,一方给另一方发送结束报文段。TCP提供了异常终止一个连接的方法,即给对方发送一个复位报文段。一旦发送了复位报文段,发送端所有排队等待发送的数据都将被丢弃。

应用程序可以使用socket选项SO_LINGER来发送复位报文段,以异常终止一个连接。

5.3 处理半打开连接

考虑下面的情况:服务器(或客户端)关闭或者异常终止了连接,而对方没有接收到结束报文段(比如发生了网络故障),此时,客户端(或服务器)还维持着原来的连接,而服务器(或客户端)即使重启,也已经没有该连接的任何信息了。我们将这种状态称为半打开状态,处于这种状态的连接称为半打开连接。如果客户端(或服务器)往处于半打开状态的连接写入数据,则对方将回应一个复位报文段。

举例来说,我们在Kongming20上使用nc命令模拟一个服务器程序,使之监听12345端口,然后从ernest-laptop运行telnet命令登录到该端口上,接着拔掉ernest-laptop的网线,并在Kongming20上中断服务器程序。显然,此时ernest-laptop上运行的telnet客户端程序维持着一个半打开连接。然后接上ernest-laptop的网线,并从客户端程序往半打开连接写入1字节的数据“a”。同时,运行tcpdump程序抓取整个过程中telnet客户端和nc服务器交换的TCP报文段。具体操作过程如下:

1
2
3
4
5
6
7
$nc-l 12345#在Kongming20上运行服务器程序 
$sudo tcpdump-nt-i eth0 port 12345
$telnet 192.168.1.109 12345#在ernest-laptop上运行客户端程序
Trying 192.168.1.109...
Connected to 192.168.1.109.
Escape character is'^]'.#此时断开ernest-laptop的网线,并重启服务器 a(回车)#向半打开连接输入字符a
Connection closed by foreign host.

telnet的输出显示,连接被服务器关闭了。tcpdump抓取到的TCP报文段内容如下:

1
2
3
4
5
1.IP 192.168.1.108.55100>192.168.1.109.12345:Flags[S],seq 3093809365,length 0
2.IP 192.168.1.109.12345>192.168.1.108.55100:Flags[S.],seq 1495337791,ack 3093809366,length 0
3.IP 192.168.1.108.55100>192.168.1.109.12345:Flags[.],ack 1,length 0
4.IP 192.168.1.108.55100>192.168.1.109.12345:Flags[P.],seq 1:4,ack 1,length 3
5.IP 192.168.1.109.12345>192.168.1.108.55100:Flags[R],seq 1495337792,length 0

该输出内容中,前3个TCP报文段是正常建立TCP连接的3次握手的过程。第4个TCP报文段由客户端发送给服务器,它携带了3字节的应用程序数据,这3字节依次是:字母“a”、回车符“和换行符“”。

不过因为服务器程序已经被中断,所以Kongming20对客户端发送的数据回应了一个复位报文段5。

6. TCP交互数据流

前面讨论了TCP连接及其状态,从本节开始我们讨论通过TCP连接交换的应用程序数据。TCP报文段所携带的应用程序数据按照长度分为两种:交互数据和成块数据。

  • 交互数据仅包含很少的字节。使用交互数据的应用程序(或协议)对实时性要求高,比如telnet、ssh等。
  • 成块数据的长度则通常为TCP报文段允许的最大数据长度。使用成块数据的应用程序(或协议)对传输效率要求高,比如ftp。

考虑如下情况:在ernest-laptop上执行telnet命令登录到本机,然后在shell命令提示符后执行ls命令,同时用tcpdump抓取这一过程中telnet客户端和telnet服务器交换的TCP报文段。具体操作过程如下:

1
2
3
4
5
6
7
8
9
$tcpdump-nt-i lo port 23 
$telnet 127.0.0.1
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is'^]'.
Ubuntu 9.10
ernest-laptop login:ernest(回车)#输入用户名并回车
Password:(回车)#输入密码并回车
ernest@ernest-laptop:~$ls(回车)

上述过程将引起客户端和服务器交换很多TCP报文段。下面我们仅列出我们感兴趣的、执行ls命令产生的tcpdump输出,如代码清单3-4所示。

1
2
3
4
5
6
7
8
9
10
11
1.IP 127.0.0.1.58130>127.0.0.1.23:Flags[P.],seq 1408334812:1408334813,ack 1415955507,win 613,length 1
2.IP 127.0.0.1.23>127.0.0.1.58130:Flags[P.],seq 1:2,ack 1,win 512,length 1
3.IP 127.0.0.1.58130>127.0.0.1.23:Flags[.],ack 2,win 613,length 0
4.IP 127.0.0.1.58130>127.0.0.1.23:Flags[P.],seq 1:2,ack 2,win 613,length 1
5.IP 127.0.0.1.23>127.0.0.1.58130:Flags[P.],seq 2:3,ack 2,win 512,length 1
6.IP 127.0.0.1.58130>127.0.0.1.23:Flags[.],ack 3,win 613,length 0
7.IP 127.0.0.1.58130>127.0.0.1.23:Flags[P.],seq 2:4,ack 3,win 613,length 2
8.IP 127.0.0.1.23>127.0.0.1.58130:Flags[P.],seq 3:176,ack 4,win 512,length 173
9.IP 127.0.0.1.58130>127.0.0.1.23:Flags[.],ack 176,win 630,length 0
10.IP 127.0.0.1.23>127.0.0.1.58130:Flags[P.],seq 176:228,ack 4,win 512,length 52
11.IP 127.0.0.1.58130>127.0.0.1.23:Flags[.],ack 228,win 630,length 0

TCP报文段1由客户端发送给服务器,它携带1个字节的应用程序数据,即字母“l”。

TCP报文段2是服务器对TCP报文段1的确认,同时回显字母“l”。

TCP报文段3是客户端对TCP报文段2的确认。

第4~6个TCP报文段是针对字母“s”的上述过程。

TCP报文段7传送的2字节数据分别是:客户端键入的回车符和流结束符(EOF,本例中是0x00)。

TCP报文段8携带服务器返回的客户查询的目录的内容(ls命令的输出),包括该目录下文件的文件名及其显示控制参数。

TCP报文段9是客户端对TCP报文段8的确认。

TCP报文段10携带的也是服务器返回给客户端的数据,包括一个回车符、一个换行符、客户端登录用户的PS1环境变量(第一级命令提示符)。

TCP报文段11是客户端对TCP报文段10的确认。

在上述过程中,客户端针对服务器返回的数据所发送的确认报文段(TCP报文段6、9和11)都不携带任何应用程序数据(长度为0),而服务器每次发送的确认报文段(TCP报文段2、5、8和10)都包含它需要发送的应用程序数据。服务器的这种处理方式称为延迟确认,即它不马上确认上次收到的数据,而是在一段延迟时间后查看本端是否有数据需要发送,如果有,则和确认信息一起发出。因为服务器对客户请求处理得很快,所以它发送确认报文段的时候总是有数据一起发送。延迟确认可以减少发送TCP报文段的数量。而由于用户的输入速度明显慢于客户端程序的处理速度,所以客户端的确认报文段总是不携带任何应用程序数据。前文曾提到,在TCP连接的建立和断开过程中,也可能发生延迟确认。

上例是在本地回路运行的结果,在局域网中也能得到基本相同的结果,但在广域网就未必如此了。广域网上的交互数据流可能经受很大的延迟,并且,携带交互数据的微小TCP报文段数量一般很多(一个按键输入就导致一个TCP报文段),这些因素都可能导致拥塞发生。解决该问题的一个简单有效的方法是使用Nagle算法。

Nagle算法要求一个TCP连接的通信双方在任意时刻都最多只能发送一个未被确认的TCP报文段,在该TCP报文段的确认到达之前不能发送其他TCP报文段。另一方面,发送方在等待确认的同时收集本端需要发送的微量数据,并在确认到来时以一个TCP报文段将它们全部发出。这样就极大地减少了网络上的微小TCP报文段的数量。该算法的另一个优点在于其自适应性:确认到达得越快,数据也就发送得越快。

7. TCP成块数据流

下面考虑用FTP协议传输一个大文件。在ernest-laptop上启动一个vsftpd服务器程序(升级的、安全版的ftp服务器程序),并执行ftp命令登录该服务器上,然后在ftp命令提示符后输入get命令,从服务器下载一个几百兆的大文件。同时用tcpdump抓取这一个过程中ftp客户端和vsftpd服务器交换的TCP报文段。具体操作过程如下:

1
2
3
4
5
6
7
8
9
10
11
$sudo tcpdump-nt-i eth0 port 20#vsftpd服务器程序使用端口号20 
$ftp 127.0.0.1
Connected to 127.0.0.1.
220(vsFTPd 2.3.0)
Name(127.0.0.1:ernest):ernest(回车)#输入用户名并回车
331 Please specify the password.
Password:(回车)#输入密码并回车
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>get bigfile(回车)#获取大文件bigfile

代码清单3-5是该过程的部分tcpdump输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205783041:205799425,ack 1,win 513,length 16384
2.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205799425:205815809,ack 1,win 513,length 16384
3.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205815809:205832193,ack 1,win 513,length 16384
4.IP 127.0.0.1.20>127.0.0.1.39651:Flags[P.],seq 205832193:205848577,ack 1,win 513,length 16384
5.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205848577:205864961,ack 1,win 513,length 16384
6.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205864961:205881345,ack 1,win 513,length 16384
7.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205881345:205897729,ack 1,win 513,length 16384
8.IP 127.0.0.1.20>127.0.0.1.39651:Flags[P.],seq 205897729:205914113,ack 1,win 513,length 16384
9.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205914113:205930497,ack 1,win 513,length 16384
10.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205930497:205946881,ack 1,win 513,length 16384
11.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205946881:205963265,ack 1,win 513,length 16384
12.IP 127.0.0.1.20>127.0.0.1.39651:Flags[P.],seq 205963265:205979649,ack 1,win 513,length 16384
13.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205979649:205996033,ack 1,win 513,length 16384
14.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 205996033:206012417,ack 1,win 513,length 16384
15.IP 127.0.0.1.20>127.0.0.1.39651:Flags[.],seq 206012417:206028801,ack 1,win 513,length 16384
16.IP 127.0.0.1.20>127.0.0.1.39651:Flags[P.],seq 206028801:206045185,ack 1,win 513,length 16384
17.IP 127.0.0.1.39651>127.0.0.1.20:Flags[.],ack 205815809,win 30084,length 0
18.IP 127.0.0.1.39651>127.0.0.1.20:Flags[.],ack 206045185,win 27317,length 0

注意,客户端发送的最后两个TCP报文段17和18,它们分别是对TCP报文段2和16的确认(从序号值和确认值来判断)。由此可见,当传输大量大块数据的时候,发送方会连续发送多个TCP报文段,接收方可以一次确认所有这些报文段。那么发送方在收到上一次确认后,能连续发送多少个TCP报文段呢?这是由接收通告窗口(还需要考虑拥塞窗口,见后文)的大小决定的。

TCP报文段17说明客户端还能接收30084×64字节(本例中窗口扩大因子为6),即1925376字节的数据。

而在TCP报文段18中,接收通告窗口大小为1 748 288字节,即客户端能接收的数据量变小了。这表明客户端的TCP接收缓冲区有更多的数据未被应用程序读取而停留在其中,这些数据都来自TCP报文段3~16中的一部分。

服务器收到TCP报文段18后,它至少(因为接收通告窗口可能扩大)还能连续发送的未被确认的报文段数量是1 748288/16 384个,即106个(但一般不会连续发送这么多)。其中,16384是成块数据的长度(见TCP报文段1~16的length值),很显然它小于但接近MSS规定的16 396字节。

另外一个值得注意的地方是,服务器每发送4个TCP报文段就传送一个PSH标志(tcpdump输出标志P)给客户端,以通知客户端的应用程序尽快读取数据。不过这对服务器来说显然不是必需的,因为它知道客户端的TCP接收缓冲区中还有空闲空间(接收通告窗口大小不为0)。

下面我们修改系统的TCP接收缓冲区和TCP发送缓冲区的大小(如何修改将在第16章介绍),使之都为4096字节,然后重启vsftpd服务器,并再次执行上述操作。此次tcpdump的部分输出如代码清单3-6所示。

1
2
3
4
5
1.IP 127.0.0.1.20>127.0.0.1.45227:Flags[.],seq 5195777:5197313,ack 1,win 3072,length 1536
2.IP 127.0.0.1.20>127.0.0.1.45227:Flags[.],seq 5197313:5198849,ack 1,win 3072,length 1536
3.IP 127.0.0.1.45227>127.0.0.1.20:Flags[.],ack 5198849,win 3072,length 0
4.IP 127.0.0.1.20>127.0.0.1.45227:Flags[P.],seq 5198849:5200385,ack 1,win 3072,length 1536
5.IP 127.0.0.1.45227>127.0.0.1.20:Flags[.],ack 5200385,win 3072,length 0

从同步报文段(未在代码清单3-6中列出)得知在这次通信过程中,客户端和服务器的窗口扩大因子都为0,因而客户端和服务器每次通告的窗口大小都是3072字节(没超过4096字节,预料之中)。因为每个成块数据的长度为1536字节,所以服务器在收到上一个TCP报文段的确认之前最多还能再发送1个TCP报文段,这正是TCP报文段1~3描述的情形。

8. 带外数据

有些传输层协议具有带外(Out Of Band,OOB)数据的概念,用于迅速通告对方本端发生的重要事件。因此,带外数据比普通数据(也称为带内数据)有更高的优先级,它应该总是立即被发送,而不论发送缓冲区中是否有排队等待发送的普通数据。

带外数据的传输可以使用一条独立的传输层连接,也可以映射到传输普通数据的连接中。实际应用中,带外数据的使用很少见,已知的仅有telnet、ftp等远程非活跃程序。

UDP没有实现带外数据传输,TCP也没有真正的带外数据。不过TCP利用其头部中的紧急指针标志和紧急指针两个字段,给应用程序提供了一种紧急方式。

TCP的紧急方式利用传输普通数据的连接来传输紧急数据。这种紧急数据的含义和带外数据类似,因此后文也将TCP紧急数据称为带外数据。

我们先来介绍TCP发送带外数据的过程。假设一个进程已经往某个TCP连接的发送缓冲区中写入了N字节的普通数据,并等待其发送。在数据被发送前,该进程又向这个连接写入了3字节的带外数据“abc”。此时,待发送的TCP报文段的头部将被设置URG标志,并且紧急指针被设置为指向最后一个带外数据的下一字节(进一步减去当前TCP报文段的序号值得到其头部中的紧急偏移值),如图3-10所示。

由图3-10可见,发送端一次发送的多字节的带外数据中只有最后一个字节被当作带外数据(字母c),而其他数据(字母a和b)被当成了普通数据。如果TCP模块以多个TCP报文段来发送图3-10所示TCP发送缓冲区中的内容,则每个TCP报文段都将设置URG标志,并且它们的紧急指针指向同一个位置(数据流中带外数据的下一个位置),但只有一个TCP报文段真正携带带外数据。

现在考虑TCP接收带外数据的过程。TCP接收端只有在接收到紧急指针标志时才检查紧急指针,然后根据紧急指针所指的位置确定带外数据的位置,并将它读入一个特殊的缓存中。这个缓存只有1字节,称为带外缓存。如果上层应用程序没有及时将带外数据从带外缓存中读出,则后续的带外数据(如果有的话)将覆盖它。

前面讨论的带外数据的接收过程是TCP模块接收带外数据的默认方式。如果我们给TCP连接设置了SO_OOBINLINE选项,则带外数据将和普通数据一样被TCP模块存放在TCP接收缓冲区中。此时应用程序需要像读取普通数据一样来读取带外数据。那么这种情况下如何区分带外数据和普通数据呢?显然,紧急指针可以用来指出带外数据的位 置,socket编程接口也提供了系统调用来识别带外数据(见第5章)。

9. TCP超时重传

在3.6节~3.8节中,我们讲述了TCP在正常网络情况下的数据流。从本节开始,我们讨论异常网络状况下(开始出现超时或丢包),TCP如何控制数据传输以保证其承诺的可靠服务。

TCP服务必须能够重传超时时间内未收到确认的TCP报文段。为此,TCP模块为每个TCP报文段都维护一个重传定时器,该定时器在TCP报文段第一次被发送时启动。如果超时时间内未收到接收方的应答,TCP模块将重传TCP报文段并重置定时器。至于下次重传的超时时间如何选择,以及最多执行多少次重传,就是TCP的重传策略。我们通过实例来研究Linux下TCP的超时重传策略。

在ernest-laptop上启动iperf服务器程序,然后从Kongming20上执行telnet命令登录该服务器程序。接下来,从telnet客户端发送一些数据(此处是“1234”)给服务器,然后断开服务器的网线并再次从客户端发送一些数据给服务器(此处是“12”)。同时,用tcpdump抓取这一过程中客户端和服务器交换的TCP报文段。具体操作过程如下:

1
2
3
4
5
6
7
8
9
$sudo tcpdump-n-i eth0 port 5001 
$iperf-s#在ernest-laptop上执行
$telnet 192.168.1.108 5001#在Kongming20上执行
Trying 192.168.1.108...
Connected to 192.168.1.108.
Escape character is'^]'.
1234#发送完之后断开服务器网线
12
Connection closed by foreign host

iperf是一个测量网络状况的工具,-s选项表示将其作为服务器运行。iperf默认监听5001端口,并丢弃该端口上接收到的所有数据,相当于一个discard服务器。上述操作过程的部分tcpdump输出如代码清单3-7所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1.18:44:57.580341 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[S],seq 2381272950,length 0
2.18:44:57.580477 IP 192.168.1.108.5001>192.168.1.109.38234:Flags[S.],seq 466032301,ack 2381272951,length 0
3.18:44:57.580498 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[.],ack 1,length 0
4.18:44:59.866019 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 1:7,ack 1,length 6
5.18:44:59.866165 IP 192.168.1.108.5001>192.168.1.109.38234:Flags[.],ack 7,length 0
6.18:45:25.028933 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
7.18:45:25.230034 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
8.18:45:25.639407 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
9.18:45:26.455942 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
10.18:45:28.092425 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
11.18:45:31.362473 IP 192.168.1.109.38234>192.168.1.108.5001:Flags[P.],seq 7:11,ack 1,length 4
12.18:45:33.100888 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
13.18:45:34.098156 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
14.18:45:35.100887 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
15.18:45:37.902034 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
16.18:45:38.903126 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
17.18:45:39.901421 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
18.18:45:44.440049 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
19.18:45:45.438840 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
20.18:45:46.439932 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
21.18:45:50.976710 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
22.18:45:51.974134 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28
23.18:45:52.973939 ARP,Request who-has 192.168.1.108 tell 192.168.1.109,length 28

TCP报文段1~3是三次握手建立连接的过程,TCP报文段4~5是客户端发送数据“1234”(应用程序数据长度为6,包括回车、换行两个字符,后同)及服务器确认的过程。

TCP报文段6是客户端第一次发送数据“12”的过程。因为服务器的网线被断开,所以客户端无法收到TCP报文段6的确认报文段。此后,客户端对TCP报文段6执行了5次重传,它们是TCP报文段7~11,这可以从每个TCP报文段的序号得知。

此后,数据包12~23都是ARP模块的输出内容,即Kongming20查询ernest-laptop的MAC地址。

我们保留了tcpdump输出的时间戳,以便推理TCP的超时重传策略。观察TCP报文段6~11被发送的时间间隔,它们分别为0.2 s、0.4s、0.8 s、1.6 s和3.2 s。由此可见,TCP一共执行5次重传,每次重传超时时间都增加一倍(因此,和TCP超时重连的策略相似)。在5次重传均失败的情况下,底层的IP和ARP开始接管,直到telnet客户端放弃连接为止。

Linux有两个重要的内核参数与TCP超时重传相关:/proc/sys/net/ipv4/tcp_retries1和/proc/sys/net/ipv4/tcp_retries2。前者指定在底层IP接管之前TCP最少执行的重传次数,默认值是3。后者指 定连接放弃前TCP最多可以执行的重传次数,默认值是15(一般对应13~30 min)。在我们的实例中,TCP超时重传发生了5次,连接坚持的时间是15 min(可以用date命令来测量)。虽然超时会导致TCP报文段重传,但TCP报文段的重传可以发生在超时之前,即快速重传,这将在下一节中讨论。

10. 拥塞控制

10.1 拥塞控制概述

TCP模块还有一个重要的任务,就是提高网络利用率,降低丢包率,并保证网络资源对每条数据流的公平性。这就是所谓的拥塞控制。

TCP拥塞控制的标准文档是RFC 5681,其中详细介绍了拥塞控制的四个部分:慢启动(slow start)、拥塞避免(congestion avoidance)、快速重传(fast retransmit)和快速恢复(fast recovery)。拥塞控制算法在Linux下有多种实现,比如reno算法、vegas算法和cubic算法等。它们或者部分或者全部实现了上述四个部分。/proc/sys/net/ipv4/tcp_congestion_control文件指示机器当前所使用的拥塞控制算法。

拥塞控制的最终受控变量是发送端向网络一次连续写入(收到其中第一个数据的确认之前)的数据量,我们称为SWND(Send Window,发送窗口[1])。不过,发送端最终以TCP报文段来发送数据,所以SWND限定了发送端能连续发送的TCP报文段数量。这些TCP报文段的最大长度(仅指数据部分)称为SMSS(Sender Maximum Segment Size,发送者最大段大小),其值一般等于MSS。

发送端需要合理地选择SWND的大小。如果SWND太小,会引起明显的网络延迟;反之,如果SWND太大,则容易导致网络拥塞。前文提到,接收方可通过其接收通告窗口(RWND)来控制发送端的SWND。但这显然不够,所以发送端引入了一个称为拥塞窗口(Congestion Window,CWND)的状态变量。实际的SWND值是RWND和CWND中的较小者。图3-11显示了拥塞控制的输入和输出(可见,它是一个闭环反馈控制)。

10.2 慢启动和拥塞避免

TCP连接建立好之后,CWND将被设置成初始值IW(Initial Window),其大小为2~4个SMSS。但新的Linux内核提高了该初始值,以减小传输滞后。此时发送端最多能发送IW字节的数据。此后发送端每收到接收端的一个确认,其CWND就按照式(3-1)增加:

\(CWND+=\min(N,SMSS) \qquad(3-1)\)

其中N是此次确认中包含的之前未被确认的字节数。这样一来,CWND将按照指数形式扩大,这就是所谓的慢启动。慢启动算法的理由是,TCP模块刚开始发送数据时并不知道网络的实际情况,需要用一种试探的方式平滑地增加CWND的大小。

但是如果不施加其他手段,慢启动必然使得CWND很快膨胀(可见慢启动其实不慢)并最终导致网络拥塞。因此TCP拥塞控制中定义了另一个重要的状态变量:慢启动门限(slow start threshold size,ssthresh)。当CWND的大小超过该值时,TCP拥塞控制将进入拥塞避免阶段。

拥塞避免算法使得CWND按照线性方式增加,从而减缓其扩大。RFC 5681中提到了如下两种实现方式:

❑每个RTT时间内按照式(3-1)计算新的CWND,而不论该RTT时间内发送端收到多少个确认。

❑每收到一个对新数据的确认报文段,就按照式(3-2)来更新CWND。

图3-12粗略地描述了慢启动和拥塞避免发生的时机和区别。该图中,我们以SMSS为单位来显示CWND(实际上它是以字节为单位的),以次数为单位来显示RTT,这只是为了方便讨论问题。此外,我们假设当前的ssthresh是16SMSS大小(当然,实际的ssthresh显然远不止这么大)。

以上我们讨论了发送端在未检测到拥塞时所采用的积极避免拥塞的方法。接下来介绍拥塞发生时(可能发生在慢启动阶段或者拥塞避免阶段)拥塞控制的行为。不过我们先要搞清楚发送端是如何判断拥塞已经发生的。发送端判断拥塞发生的依据有如下两个:

❑传输超时,或者说TCP重传定时器溢出。

❑接收到重复的确认报文段。

拥塞控制对这两种情况有不同的处理方式。对第一种情况仍然使用慢启动和拥塞避免。对第二种情况则使用快速重传和快速恢复(如果是真的发生拥塞的话),这种情况将在后面讨论。注意,第二种情况如果发生在重传定时器溢出之后,则也被拥塞控制当成第一种情况来对待。

如果发送端检测到拥塞发生是由于传输超时,即上述第一种情况,那么它将执行重传并做如下调整:

\(\text{ssthresh} = \max\left(\frac{\text{FlightSize}}{2}, 2 \times \text{SMSS}\right) \tag{3-3}\)

\(\text{CWND} \leq \text{SMSS}\)

其中FlightSize是已经发送但未收到确认的字节数。这样调整之后,CWMD将小于SMSS,那么也必然小于新的慢启动门限值ssthresh(因为根据式(3-3),它一定不小于SMSS的2倍),故而拥塞控制再次进入慢启动阶段。

10.3 快速重传和快速恢复

在很多情况下,发送端都可能接收到重复的确认报文段,比如TCP报文段丢失,或者接收端收到乱序TCP报文段并重排之等。拥塞控制算法需要判断当收到重复的确认报文段时,网络是否真的发生了拥塞,或者说TCP报文段是否真的丢失了。

具体做法是:发送端如果连续收到3个重复的确认报文段,就认为是拥塞发生了。然后它启用快速重传和 快速恢复算法来处理拥塞,过程如下:

1)当收到第3个重复的确认报文段时,按照式(3-3)计算ssthresh,然后立即重传丢失的报文段,并按照式(3-4)设置CWND。

\(\text{CWND} = \text{ssthresh} + 3 \times \text{SMSS} \tag{3-4}\)

2)每次收到1个重复的确认时,设置CWND=CWND+SMSS。此时发送端可以发送新的TCP报文段(如果新的CWND允许的话)。 3)当收到新数据的确认时,设置CWND=ssthresh(ssthresh是新的慢启动门限值,由第一步计算得到)。

快速重传和快速恢复完成之后,拥塞控制将恢复到拥塞避免阶段,这一点由第3步操作可得知。


3. TCP协议详解
http://binbo-zappy.github.io/2024/12/16/Linux高性能服务器编程-游双/3-TCP协议详解/
作者
Binbo
发布于
2024年12月16日
许可协议