TCP/IP协议—TCP 传输控制协议

风尘

文章目录

  1. 1. TCP 首部
  2. 2. TCP 连接的建立与终止
  3. 3. 状态机转换解释
    1. 3.1. TCP 端口号
    2. 3.2. TCP 选项
  4. 4. 数据传输
    1. 4.1. 交互式输入
      1. 4.1.1. 时延的确认
      2. 4.1.2. Nagle 算法
      3. 4.1.3. 关闭 Nagle 算法
    2. 4.2. 成块数据
      1. 4.2.1. 滑动窗口
      2. 4.2.2. 糊涂窗口组合症
  5. 5. 拥塞控制
  6. 6. 超时与重传
    1. 6.1. 设置重传超时
    2. 6.2. RTT 的计算
    3. 6.3. RTO 计算示例

[TOC]

尽管TCPUDP使用相同的网络层,TCP却向应用层提供了与UDP完全不同的服务。

TCP提供了一种面向连接、可靠的字节流服务

面向连接 意味着两个使用TCP的应用(通常是一个客户和一个服务器)在彼此交换数据之前必须先建立一个TCP连接。这一过程与打电话很相似,先拨号振铃,等待对方摘机说“喂”,然后才说明是谁。

字节流服务 这种设计方案的结果是,没有由TCP自动插入的记录标志或消息边界。一个记录标志对应着一个应用程序的写范围指示。如,应用程序在一端写入10字节,随后写入20字节,最后写入50字节。在连接的另一端的应用程序是不知道每次写入的字节是多少,它可能会以每次20字节,分四次读入这80字节或以其他的一些方式读入。因此,每个端点独立选择自己的读和写大小。

TCP对字节流的内容不作任何解释。 TCP不知道传输的数据字节流是二进制数据,还是ASCII字符、EBCDIC字符或者其他类型数据。对字节流的解释由TCP连接双方的应用层解释。

这种对字节流的处理方式与Unix操作系统对文件的处理方式很相似。Unix的内核对一个应用读或写的内容不作任何解释,而是交给应用程序处理。对Unix的内核来说,它无法区分一个二进制文件与一个文本文件。

可靠性

  • 应用数据被分割成TCP认为最适合发送的数据块。这和UDP完全不同,应用程序产生的数据报长度将保持不变。由TCP传递给IP的信息单位称为报文段或段(segment)。
  • TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。
  • TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒。
  • TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错, TCP将丢弃这个报文段和不确认收到此报文段(希望发端超时并重发)。
  • TCP报文段作为IP数据报来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层。
  • IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。
  • TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲区溢出。

TCP 首部

TCP数据被封装在一个IP数据报中,如下图:

TCP数据在IP数据报中封装TCP数据在IP数据报中封装

TCP首部数据格式如下图:

TCP首部TCP首部

源端口目的端口号 字段,用于寻找发端和收端应用进程。这两个值加上IP首部中的源端IP地址和目的端IP地址唯一确定一个TCP连接。

一个IP地址和一个端口号也称为一个套接字(socket)。这个术语出现在最早的TCP规范(RFC793)中,后来它也作为表示伯克利版的编程接口。

每个TCP连接由一对套接字(socket pair)(包含客户IP地址、客户端口号、服务器IP地址和服务器端口号的四元组 )组成,可唯一确定互联网络中每个TCP连接的双方。

序号 字段,TCP是面向字节流的,在一个TCP连接中,传送的字节流中的每个字节都被顺序编号。序号是32bit无符号数,序号到达2^32-1后从0开始。该字段标识从TCP发端向收端发送的数据字节流中的第一个字节的序号。

当建立一个新的连接时, SYN标志被打开值为1。序列号字段包含此主机为此连接选择的 初始序号(Initial Sequence Number,ISN) 。该主机要发送数据的第一个字节序号为这个ISN1,因为SYN标志消耗了一个序号。

RFC 793中指出ISN被绑定在一个(可能是虚假的)32位时钟上,其低位大约每4微秒递增一次。直到超过2^32后又从0开始,这个周期大概是4.55小时。所以,如果TCP Segment 报文段最大生存时间(Maximum Segment Lifetime,MSL) 即在网络上的存活时间不超过 4.55小时,那么就不会用到重复的ISN

确认序号 字段,包含发送确认的一端所期望收到的下一个序号,即应当是上次已成功收到数据字节序号加1。只有ACK标志为1时该字段才有效。

发送ACK无需任何代价,因为32bit的确认序号字段和ACK标志一样,总是TCP首部的一部分。因此,我们看到一旦一个连接建立起来,这个字段总是被设置, ACK标志也总是被设置为1

TCP为应用层提供 全双工服务 。这意味数据能在两个方向上独立地进行传输。因此,连接的每一端必须保持每个方向上的传输数据序号。

全双工 : 指可以同时(瞬时)进行信号的双向传输(A→BB→A)。指A→B的同时B→A,是瞬时同步的。

半双工 :指一个时间内只有一个方向的信号传输(A→BB→A)。

长度 字段,首部中32 bit的数目。占4bit,因此最大值为1111,十进制表示为15,所以以TCP首部最大字节为15*(32/8)=60字节。

标志 比特字段,当前,为TCP头部定义了8个位字段,一些老的实现只实现了它们中的最后6位,它们可同时被设置为1,具体如下:

CWR 拥塞窗口减少(发送方降低它的发送速率)

ECE ECN回显(发送收到了一个更早的的拥塞通告)

URG 紧急指针(urgent pointer)有效

ACK 确认序号有效。

PSH 接收方应该尽快将这个报文段交给应用层。

RST 重建连接。

SYN 同步序号用来发起一个连接。

FIN 发端完成发送任务。

窗口大小 字段,TCP的流量控制由连接的每一端通过声明的窗口大小来提供。窗口大小为字节数,起始于确认序号字段指明的值,这个值是接收端真正期望接收的字节。窗口大小占16bit,因此窗口大小最大字节为2^16-1=65535字节。

检验和 字段,覆盖了整个TCP报文段(TCP首部+数据)。这是一个强制性字段,由发端计算和存储,并由收端进行验证。计算方法与UDP类似,使用一个伪首部。

紧急指针 字段,只有当URG被设置(即值为1时),该字段才生效。它是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。

选项 字段,长度可变,最长可达4字节。

TCP最初只规定了一种选项,即 最大报文段长度(Maximum Segment Size,MSS)MSS是每一个TCP报文段中的数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是整个TCP报文段的最大长度,而是TCP报文段长度减去TCP首部长度”。

TCP 连接的建立与终止

使用telnet命令建立一个TCP连接:

$ telnet 192.168.1.134 13101 discard # discard 是一个服务类似于 Linux 中 /dev/null 作用,用于 tcp/ip 测试
Trying 192.168.1.134..
Connected to 192.168.1.134.
Escape character is '^]'.
^]            # 进入 telnet 命令行
telnet> quit  # 退出 telnet
Connection closed.

-----------------------------------------------
# tcpdump 命令监控
$ tcpdump
78: 192.168.31.62.54258 > 39.106.86.134.13101: S , seq 3984939603, win 65535, length 0
74: 39.106.86.134.13101 > 192.168.31.62.54258: S , seq 2131422913, ack 3984939604, win 28960, length 0
66: 192.168.31.62.54258 > 39.106.86.134.13101: . , ack 1, win 2058, length 0
144: 39.106.86.134.13101 > 192.168.31.62.54258: P , seq 1:79, ack 1, length 78
66: 192.168.31.62.54258 > 39.106.86.134.13101: . , ack 79, win 2057, length 0

对于TCP段,每行输出格式如下:

源 > 目的 : 标志

其中标志代表TCP首部6个标志中的4个,字符含义如下:

字符 标志
S SYN
F FIN
R RST
P PSH
. 表示以上四个标志比特均置 0

上表中四个标志比特中的多个可能同时出现在一个报文段中,但通常一次只见到一个。

TCP首部中的其他两个标志比特—ACKURGtcpdump将作特殊显示。

第一行中,seq表示序号,ack表示确认序号,win表示窗口通告大小,length表示发送数据长度(上例中没有发送任何数据所以为0)。

为什么,第三行,ack值为1,而不是前一个seq+1

是因为tcpdump命令默认显示相对序号值,如果想强制显示绝对序列值可以加上-S选项。

第四行,序号为1:79,表示 开始序号:结尾序号(不包含), 。这种格式只在相对序号模式下显示,结尾序号值为 开始序号+length 的和,这样显示方便看出数据的长度。因为是不包含关系,所以它就的第五行确认序号ack,也是下一次的请求序号。

状态机与连接、传输数据、断开连接状态机与连接、传输数据、断开连接

如上图,建立一个链接需要 三次握手 ,因为通信双方要相互通知对方自己的初始化序号,作为以后数据通信序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序(TCP会用这个序号来拼接数据)。

断开一个链接需要 四次挥手 ,这是由TCP的半关闭(half-close)造成的。一个TCP连接是全双工,因此每个方向必须单独地进行关闭。只不过,有一方是被动的。

当一端收到一个FIN只意味着在这一方向上没有数据流动。一个TCP连接在收到一个FIN后仍能发送数据。而这对利用半关闭的应用来说是可能的,尽管在实际应用中只有很少的TCP应用程序这样做。

为了使用这个特性,编程接口必须为应用程序提供一种方式来说明 “ 我已经完成了数据传送,因此发送一个文件结束(FIN)给另一端,但我还想接收另一端发来的数据,直到它给我发来文件结束(FIN ”。

如果应用程序不调用close而调用shutdown,且第二个参数值为1,则socket接口支持半关闭。

典型的例子是Unix中的rsh命令,它将完成在另一个系统上执行一个命令。它的操作很简单,就是将输入的指令复制给TCP连接,并将结果从TCP链接中复制给标准输出。当输入指令后,rsh客户端执行半关闭,并继续接收来自TCP另一端的数据直到结束。

状态机转换解释

建立链接三次握手:


  • CLOSED

    起点,当超时或连接关闭时进入该状态。它并不是一个真正状态,而是这个状态图的假想起点和终点。

  • LISTEN

    服务器等待连接的状态,服务器进入该状态后开始监听客户发送的连接请求。这被称为“被动打开”。

  • SYN_SENT

    当第一次握手时,客户启动一个连接。客户调用connect(),并向服务器发送一个SYN消息,然后进入该状态,等待服务器的确认。如果服务器不能被连接,它将进入CLOSED状态。

  • SYN_RCVD

    第二次握手时发生,服务器从客户接收SYN,服务器从LISTEN进入该状态。然后向客户发送一个SYN + ACK

    状态图还描述一种情况,当客户发送一个SYN,并且从服务器接收了一个SYN请求。也就是,同时发起两个连接请求( 同时打开 ),这时客户状态将从SYN_SENT状态转换到SYN_RCVD状态。同时打开与正常连接建立过程相比,需要增加一个报文段,因此会变成四次握手。

  • ESTABLISHED

    在第三次握手阶段,客户从服务器接收SYN + ACK后,它将发送一个ACK确认给服务器,客户进入ESTABLISHED状态,指示客户已经准备好。但是TCP需要两端都做好传输数据准备,因此在服务器接收到客户的ACK后从SYN_RCVD状态进入ESTABLISHED状态。此时就可以后续的数据传输了。

TCP建立链接状态转换图TCP建立链接状态转换图

如上图,建立连接一系列状态变化:

  • 客户服务器 初始状态是CLOSED

  • 程序调用UNIX “listen()”将触发 “被动打开动作”,这个状态转换不触发任何响应。服务器 进入LISTEN状态。

  • 当要建立一个TCP链接时,客户程序调用UNIX “connect()”,这个事件被称为“主动打开”。这个转换将向 服务器 传送一个SYN信息。客户 进入SYN_SENT状态。

  • SYN信息被 服务器 接收后,它会向 客户 发送SYN + ACK信息。服务器 进入SYN_RCVD状态。

  • SYN + ACK客户 接收,它会向 服务器 发送一个ACK信息。客户 进入ESTABLISHED状态。

    客户完成,客户可以发送和接收数据消息。

  • 最后,当ACK服务器 接收后,服务器 进入ESTABLISHED状态。

    服务器完成,服务器也可以发送和接收消息。


断开链接四次挥手


  • FIN_WAIT_1

    第一次挥手,客户执行主动关闭,就会从ESTABLISHED进入该状态。然后发送一个FIN信息。

  • CLOSE_WAIT

    第二次挥手,接收客户发送来的FIN,同时发送出ACK,此时服务器进入该状态。

  • FIN_WAIT_2

    客户接收到服务器的ACK消息,客户进入该状态。

  • LAST_ACK

    第三次挥手,服务器发起一个关闭请求,并从CLOSE_WAIT状态进入该状态。

  • TIME_WAIT

    第四次挥手,客户端完成它自己发起的关闭请求后,接收了服务器发送的FIN,然后响应了ACK后从FIN_WAIT_2进入该状态。

    通过状态图,可以看出还有两种状态可以进入该状态:

    1. CLOSING进入

      同时关闭时,当客户和服务器都收到ACK时,会进入该状态。

    2. FIN_WAIT_1进入

      客户执行主动关闭,已经发送了FIN并且等待ACK;此时服务端也发起了一个主动关闭,并且发送了FIN。客户端收到了前一个ACK,也收到了服务器FIN,并且发送了ACK。此时进入该状态。

      该状态转换与进入CLOSING接收FINACK顺序不一样。

  • CLOSING

    当客户与服务器同时发起一个关闭请求( 同时关闭 ), 两边都在等待接收到对方发给自己的ACK之前收到对方的FIN,此时两边都进入CLOSING状态。

  • 2MSL

    TIME_WAIT状态也称为2MSL,每个实现必须选择一个值作为 报文段最大生存时间(Maximum Segment Lifetime,MSL) ,它是任何报文段被丢弃前在网络内的最长时间。

    RFC 793[Postel 1981c]指出MSL2分钟。然而,实现中的常用值是30秒,1分钟或2分钟。

    对于一个具体实现给定的MSL值,处理原则是:当一个TCP执行主动关闭时,并发送最后一个ACK,该连接必须保持TIME_WAIT状态为两倍的MSL。这样可以让TCP再次发送最后的ACK以防这个ACK丢失(另一端超时并重发最后的FIN)。

    这种2MSL等待的另一种影响是,socket定义的双向连接(客户IP、客户端口、服务器IP、服务器端口)在2MSL等待结束前不能被重新使用。大多数TCP实现强加了更严格的限制,在2MSL等待期间,Socket中使用的本地端口默认情况下不能再被使用。

    某些实现和API提供了一种避开该限制的方法,使用Socket API时可以指定SO_REUSEADDR选项。它允许调用者对处于2MSL等待的本地端口进行赋值。但TCP原则上仍将避免使用仍处于2MSL连接中的端口。

    客户执行主动关闭并进入TIME_WAIT是正常的。服务器通常执行被动关闭,不会进入TIME_WAIT状态。如果终止一个客户程序,并立即重新启动这个客户程序,则这个新客户程序将不能重用相同的本地端口。这不会带来什么问题,因为客户使用临时本地端口,而并不关心这个临时端口号是什么。

    然而,对于服务器,情况就有所不同,因为服务器使用熟知端口。如果我们终止一个已经建立连接的服务器程序,并试图立即重新启动这个服务器程序,服务器程序将不能把它的这个熟知端口赋值给它的端点,因为那个端口是处于2MSL连接的一部分。在重新启动服务器程序前,它需要在1 ~ 4分钟。

    如果我们试图从其他主机来建立这个连接会如何?

    首先我们必须在sun服务器上以-A标记来重新启动服务器程序,因为它需要的端口还处于2MSL等待连接的一部分。

    [root@study ~]# sock -A -s 6666
    

    接着,在2MSL等待结束前,我们在另一台主机上启动客户程序:

    [root@study ~]# sock -b1098 sun 6666
    connected on 38.106.86.134.1098 to 38.106.86.133.6666
    

    结果连接成功了!这违反了TCP规范,但被大多数的伯克利版实现所支持。这些实现允许一个新的连接请求到达仍处于TIME_WAIT状态的连接,只要新的序号大于该连接前一个替身的最后序号。

TCP 端口号

# 查看 TCP 所有连接信息
[root@study ~]netstat -ant
Active |nternet c ゚nneCti ゚ns (SerVerS and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp   0      O      :::22         :::★            LISTEN

:::22表示本地地址是全0地址,这种表示法也称为通配符地址,使用的端口号是22。这意味着一个针对22号端口的连接进入请求(即一个SYN)会被任何本地接口接受。

如果主机是多宿主的,我们可以为本地IP地址指定一个单一的地址(主机IP地址中的一个地址),并且只有被该接口接收到的连接才能够被接受。

:::*表示外部地址是一个通配符地址与端口号。此处节点状态是LISTEN,正在等待一个连接到来,因此外部地址与端口号尚不知晓。

此时,在主机10.0.0.3上启动两个SSH连接,再次查看连接状态,此时两个连接的状态为ESTABLISHED,由于外部端口号不同,所以并不冲突。

[root@study ~] netstat -ant
Active |nternet c ゚nneCti ゚ns (SerVerS and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp   0      O      :::22         :::★            LISTEN
tcp   0      O      10.0.0.1:22   10.0.0.3:16140  ESTABLISHED
tcp   0      O      10.0.0.1:22   10.0.0.3:16137  ESTABLISHED

TCP 选项

TCP头部包含了多个选项,其中 选项列表结束(End of Option List,EOL)无操作(No Operation,NOP) 以及 最大报文段长度(Maximum Segment Size,MSS) 是定义于原始TCP规范中的选项。后来又有若干选项被定义,整个选项列表由 互联网编号分配机构(IANA) 维护,每个选项都包含种类和长度信息。

  • 最大报文段长度选项(MSS)

    是指TCP转文所允许的从对方收到的最大报文段,这也是通信双方在发送数据时能够使用的最大报文段。其只记录TCP数据的字节数而不包括其他相关的TCPIP头部。其种类代码为2,长度是4字节。当建立一条TCP连接时,通信的每一方都要在SYN报文段的MSS选项中说明自己允许的最大报文段长度。在没有指定的情况下,默认大小为536字节。

    一个主机要求至少能够处理576字节的IPv4数据报。如果按照最小的IPv4TCP头部计算,TCP协议要求在每次发送的最大报文段大小为536字节,这样正好能够组成一个576字节(IP头部20字节+TCP头部20字节+536字节)的IPv4数据报。

    MSS并不是TCP通信双方的协商结果,而是一个限定的数值。当通信的一方将自己的MSS发送给对方时,它已表明自己不愿在整个连接过程中接收任何大于该尺寸的报文段。

  • 选择确认选项(SACK)

    TCP通过滑动窗口采用累积ACK确认,TCP不能正确地确认之前已经接收的数据。由于接收的数据是无序的,所以接收到的数据序号也不是连续的。在这种情况下,TCP接收方的数据队列中会出现空洞的情况。当接收智育的数据时, 选择确认选项(SACK) 保存接收方已经成功接收的数据块的序号范围(每一个范围被称作一个SACK块,由一对32位的序号表示),一个SACK选项包含了nSACK块,长度为(8n+2)个字节。增加2个字节用于保存SACK选项的种类与长度。其种类代码为5,长度可变。

    由于TCP头部选项的空间是有限的,因此一个报文段中发送的最大SACK块数目为3(假设使用了时间戳选项)。
    只有SYN报文段才能包含SACK选项,但只要发送方已经发送了该选项,SACK块就能够通过任何报文段发送出去。

  • 窗口缩放选项(WSOPT)

    窗口缩放选项(WSCALEWSOPT),能够有效地将TCP窗口通告字段范围从16位增加至30位。TCP头部不需要改变窗口通告字段大小,仍然维持16位字节。同时,使用另一个选项作为这16位数值的比例因子。该值可以用014(包含)来计数,表示将窗口数值扩大至原先的 2s2^s 倍,所以它能够提供一个最大为 65535214=107372544065535 * 2^{14} = 1073725440 字节(实际是1GB)的窗口。该选项种类代码为3,长度是3字节。TCP使用一个32位的值来维护这个真实的窗口大小。

    该选项只能出现于一个SYN报文段中,因此当连接建立以后比例因子是与方向绑定的。主动打开连接的一方利用自己的SYN中发送该选项,被动打开连接的五主只能在接收到的SYN指出该选项时才能发送该选项。每个方向数值可不同,如果主动打开一方发送了一个非0的比例因子,但却没有接收到来自对方的窗口缩放选项,它会将自己发送与接收的比例因子数值都设为0,这使得不支持该选项的系统与支持该选项的系统间可以交互操作。

  • 时间戳选项(TSOPT)

    时间戳选项(TSOPTTSopt)要求发送方在每一个报文段中添加24字节的时间戳数值。接收方将会在确认中反映这些数值,允许发送方针对每一个接收到的ACK估算TCP连接的往返时间(RTT)。该选项种类代码为8,长度为10字节。当使用该选项时,发送方将一个32位的数值填充到时间戳字段,作为时间戳选项的第一个部分(称作TSVTSval);接收方在确认中将收到的时间戳数值原封不动的填充至第二部分的时间戳回显重试字段(称作TSERTSecr)中返回。TCP头部长度会增长10字节(8字节用于保存2个时间戳数值,另2字节用于指明选项的值和长度)

    时间戳是一个单调增加的数值,由于接收者只会对它接收到的信息做出响应,所以它并不关系时间戳单元或数值到底是什么,也不要求在两台主机之间进行任何形式的时钟同步。RFC1323推荐发送者每秒钟至少将时间戳数值加1

数据传输

TCP通常需要处理两类数据,一类是成块数据(如:FTP电子邮件),一类是交互数据(如TelnetRlogin)。按分组数量计算,两类数据各占一半;按照字节计算比例约为9:1。这是因为成块数据的报文段基本上都是满长度(通常为512字节数据),而交互数据则小的多(通常小于10个字节)。TCP需要同时处理这两类数据,但使用的处理算法则有所不同。

交互式输入

TCP数据交互式按键回显TCP数据交互式按键回显

上图是一个Rlogin连接上键入一个交互命令时所产生的数据流。注意到通常每一个交互按键都会产生一个数据分组,也就是说,每次从客户传到服务器的是一个字节的按键(而不是每次一行)。并且,Rlogin 需要远程服务器回显客户键入的字符,这样就会产生4 个报文段:

  1. 来自客户的交互按键。
  2. 来自服务器的按键确认。
  3. 来自服务器的按键回显。
  4. 来自客户的按键回显确认。

Rlogin 每次总是从客户发送一个字节到服务器,Telnet 则有一个选项允许客户发送一行到服务器,通过这个选项可以减少网络负载。BDS/386 通过设置一个Rlogin连接的TOS来获得最小时延。

时延的确认

通常可以将Rlogin报文段23进行合并,将按键确认和按键回显一起发送,这种合并的技术称为时延的确认TCP 在接收到数据时并不立即发送ACK,它会推迟发送以便将ACK与需要沿该方向发送的数据一起发送(这种现象有时也称为数据稍带ACK)。

绝大多数实现采用的时延为200ms,也就是说,TCP将以最大200ms 的时延等待是否有数据一起发送。

观察下图客户接到数据和发送ACK之间的时间差,就会发现它们似乎是随机的:123.5、65.6、109.0。而发送ACK的实际时间(从 0 开始):139.9、539.3、940.1(用星号标出),这些时间差则是200ms整数倍。出现这两种现象的原因是TCP使用了一个200ms定时器,该定时器以相对内核引导的200ms固定时间溢出;由于要确认的数据是随机到达的(16.4、474.3、831.1 等),所以TCP在内核的200ms 定时器的下一次溢出时得到通知可能是将来的1~200ms中的任何时刻。

时延的确认时延的确认

Host Requirements RFC声明TCP需要实现一个经受时延的ACK,但时延必须小于500ms

Nagle 算法

在一个Rlogin连接上客户一般每次发送一个字节到服务器,这就产生了一些41字节长的分组(20字节的IP首部、20字节的TCP首部和1个字节的数据)。在局域网上,这些小分组(被称为微小分组(tinygram))通常不会引起麻烦,因为局域网一般不会出现拥塞。但在广域网上,则会增加拥塞出现的可能。

一种简单和友好的方法就是采用RFC 896 [Nagle 1984]中所建议的Nagle算法。 该算法要求一个TCP连接上最多只能有一个未被确认的未完成的小分组,在该分组的确认到达之前不能发送其他的小分组。TCP收集这些少量的分组,并在确认到来时以一个分组的方式发出去。 该算法的优越之处在于它是自适应的(即确认到达得越快,数据也就发送的越快),在希望减少微小分组数目的低速广域网上,则会发送更少的分组。

从上图中可以看出,在以太网上一个字节被发送、确认和回显的平均往返时间约为16ms(16.5、16.3、16.5),因此每秒大约传送1000/16=62.51000/16=62.5个字符。为了产生比这个速度更快的数据,我们每秒键入的字符必须多于60个,这表明在局域网环境下两个主机之间发送数据时很少使用这个算法。

当往返时间(RTT)增加时,如在下图,通过一个广域网传输,情况就会发生变化。首先注意到从slip远程服务器不存在经受时延的ACK,这是因为在时延定时器溢出之前总是有数据等待发送。其次,从左到右待发数据长度是不同的,分别为112122313个字节,这是因为客户只有收到前一个数据的确认后才发送已经收集的数据。通过使用Nagle算法,发送16个字节的数据客户端只需要使用9个报文段,而不再使用16个。

广域网 rlogin 数据传输广域网 rlogin 数据传输

报文段1415看起来似乎与Nagle算法相违背,但是通过观察其序号发现,确认序号是54,因此报文段14是报文段12中确认的应答。客户在发送该报文段之前,接收到了来自服务器的报文段13,报文段15中包含了对序号为56的报文段13的确认。因此即使从客户到服务器有两个连续返回的报文段,客户也是遵守Nagle算法的。

上图中,报文段12不包含任何数据,因此可以假定这是一个时延的ACK。服务器当时一定非常忙,因此无法在服务器定时溢出前及时处理所收到的字符。

关闭 Nagle 算法

在一些实时性要求比较高的场景下,采用了Nagle算法会让用户感觉到时延,所以我们可以选择关闭Nagle算法。

在一个交互注册过程中键入终端一个特殊功能键F1,这个功能键通常会产生三个字节的字符序列:一个转义字符、一个左括号[和一个M。如果TCP每次只得到一个字符它可能会发送序列中的第一个字符,然后缓存其他字符并等待。但当服务器接收到该字符后,它并不发送确认,而是继续等待接收序列中的其他字符。因此会经常触发服务器的时延确认算法,对于交互用户而言,这将产生明显的时延。

Socket API可以使用TCP_NODELAY选项来关闭Nagle算法。
Host Requirements RFC声明TCP必须实现Nagle算法,但必须为应用提供一种方法来关闭该算法在某个连接上执行。

成块数据

TFTP使用了停止等待协议(数据发送方在发送下一个数据块之前需要等待接收已发送数据的确认)不同,TCP使用的被称为 滑动窗口协议 的另一种形式的流量控制方法。该协议允许发送方在停止并待确认前可以连续发送多个分组。由于发送方不必每发一个分组就停下来等待确认,因此该协议可以加速数据的传输。

成块数据传输成块数据传输

上图,是从客户单向传输81024个字节的数据到服务器,其中服务器作为一个 sink(吸收)服务器 (从网络上读取并丢弃数据)。其中图123分别展示了三种不同情况。

1

  • 前三个报文段显示了每一端的MSS值。

  • 发送方首先发送了三个数据报文(4~6),下一个报文段(7)仅确认了前两个报文段,这可以从其确认序号为2049而不是3073可以看出来。

    报文段(7)确认序号是2049而不是3073的原因是: 当一个分组到达时,它首先被设备驱动程序的中断服务例程处理,然后放入IP的输入队列中。报文段(456)依次到达并按接收顺序放到IP输入队列。IP将以同样顺序交给TCP

    TCP处理报文段(4)时,该连接被标记为一个产生时延的确认。TCP处理下一报文段(5),由于TCP现在有两个未完成的报文段需要确认,因此产生一个序号为2049ACK(7),并清除该连接产生的经受时延的确认标志。

    TCP处理下一报文段(6),而连接又被标志为产生一个经受时延的确认。在报文段(9)到来之前,由于时延定时器溢出,因此产生一个序号3073ACK(8)。

    报文段(8)中的窗口大小为3072,表明TCP的接收缓存还有1024个字节的数据等待被应用程序读取。

  • 报文段(11~16)说明了通常使用“隔一个报文段确认”的策略。其传输与报文段(4~8)相同。

    报文段(71416)的ACK确认了两个收到的报文段。这是因为使用TCP滑动窗口协议时,接收方不必确认每一个收到的分组。

    TCP中,ACK是累积的,它表示接收方已经收到了一直到确认序号减1的所有字节。

2

通过tcpdump看到的TCP动态活动情况,依赖于很多无法控制的因素:发送方的TCP实现、接收方的TCP实现、接收进程读取数据(依赖于操作系统的调度)和网络的动态性(如以太网冲突和退避等)。对于TCP而言没有单一的、正确的方法来交换给定数量的数据。

2是相同主机在不同时间传送同样数据时的时间线,一些情况发生了变化:

这一次接收方没有再发送一个3073ACK,而是等待并发送4097ACK,接收方仅发送了4ACK(报文段7101215),其中三个确认了2048字节,另一个确认了1024字节。最后1024字节数据的ACK出现在报文段17中,它与FINACK一道发送。

3

这次情况是从一个快的发送到到一个慢的接收方,它的动态活动情况又有所不同:

发送方发送4个背靠背的数据报文段去填充接收方的窗口,然后停下来等待一个ACK。接收方发送ACK(报文段8),但通告其窗口大小为0,这说明接收方已收到所有数据,但这些数据都在接收方的TCP缓存区,因为应用程序还没有机会读取到这些数据。另一个ACK(称为窗口更新),在17.4ms后发送,表明接收方现在可以接收另外的4096字节。虽然这看起来像一个ACK,但由于它并不确认任何新数据,只是用来增加窗口的左右边沿,因此被称为窗口更新。

发送方发送最后4个报文段(10~13),再次填充接收方的窗口,报文段(13)包括了两个比特标志PUSHFIN。随后又从接收方传来另外两个ACK,它们确认了最后的4096字节数据(从40978192字节)和FIN

滑动窗口

TCP连接每一端都可以收发数据,连接的收发数据量是通过一组 窗口结构 来维护的。每个TCP活动连接的两端都维护一个 发送窗口结构接收窗口结构

滑动窗口滑动窗口

TCP以字节(而非包)为单位维护其窗口结构。 以发送窗口为例,发送11个字节的数据,详细传输流程见下图:

滑动窗口流量控制滑动窗口流量控制
  • 报文段(1-3)是双方建立连接,接收方的窗口通告是3,说明发送方的发送窗口大小为3MSS值为1,说明每段报文大小为1字节。
  • 报文段(4-6),连续发送三段报文填满发送窗口,每段报文大小为1字节(MSS值所决定),注意的是报文段(6)发送中丢失。
  • 报文段(7)确认接收了序号为4以前的数据(即字节12),并通告窗口大小为6字节。

    由于窗口大小从之前3字节变成现在的6字节,使得窗口可发送数据量增大,即窗口右边界右移,这种现象称为 窗口打开

  • 报文段(8-12)连续发送5个报文段满发送窗口,由于报文段(6)丢失,接收端未返回确认接收报文,所以继续占用发送窗口1字节大小。
  • 报文段(13)是报文段(6)的重传计时器超时触发的重传。报文段(14)确认接收了报文段序号为10以前的数据,并通告窗口大小为1字节。

    由于窗口大小从之前6字节变成现在的1字节,使得窗口可发送数据量变小,即窗口右边界向左移,这种现在称为 窗口收缩

    RFC1122强烈反对这么做,但TCP必须能够处理它。

  • 报文段(15)发送1字节数据填满发送窗口,报文段(16)确认接收该报文并通告窗口大小为0字节。

    当通告窗大小为0时,称之为 零窗口。此时发送端不能再发送新数据,而是启动一个 持续计时器 用于主动发送零窗口探测报文段。

    当接收商重新获得可用空间时,会给发送端传输一个 窗口更新 告知其可继续发送数据。如果在持续计时器超时前都未收到窗口更新,发送方会主动发送零窗口探测报文段(携带1字节数据)。这种设计可以防止死锁的发生。

    RFC1122建议在一个RTO之后发送第一个窗口探测,随后以指数时间间隔发送。

  • 报文段(17)在发送窗口更新过程中丢失,报文段(18)持续计时器超时并且没有收到窗口更新,因此主动发送零窗口探测报文段。
  • 报文段(19)返回窗口更新,通告大小为2字节,报文段(20-21)填满发送窗口,报文段(22)确认收到序号13之前的数据。

    当已发送数据得到ACK确认时,窗口会减小,即左边界右移,这种现象称为 窗口关闭

接收端也维护一个窗口结构,但比发送端窗口简单。该窗口结构记录了已接收并确认的数据,以及它能够接收的最大序列号,保证其接收数据的正确性。

糊涂窗口组合症

基于窗口流量控制机制,尤其是不使用大小固定的报文段的情况,可能会出现称为 糊涂窗口综合症(Silly Window Syndrome,SWS) 的缺陷。当出现该问题时,交换的数据段大小不是满长度的报文段,而是一些较小的数据段。由于每个报文段中有用的数据相对于头部信息的比例较小,因此耗费的资源也更多,相应的传输效率也更低。
TCP两端都可能导致SWS的出现:接收方可以通告一个小的窗口(而不是一直等到有大的窗口时才通告),而发送方也可以发送少量的数据(而不是等待其他的数据以便发送一个大的报文段)。可以在任何一端采取措施避免出来:

  1. 对于接收方来说,不应通告小窗口。RFC1122描述的接收算法中,接收方不通告一个比当前窗口大的窗口(可以为0),除非窗口可以增加一个报文段大小(即接收端的MSS)或者可以增加接收方缓存空间的一半。
  2. 对于发送方来说,不应发送小的报文段,而且需由Nagle算法控制何时发送。
    a) 可以发送一个满长度的报文段。
    b) 可以发送至少是接收方通告窗口大小一半的报文段。
    c) 可以发送任何数据并且不希望接收ACK(即没有未经确认的在传数据)或者该连接禁用Nagle算法。

拥塞控制

当路由器在单位时间内接收到的数据量多于其可发送的数据量时,它就需要把多余的部分存储起来。假如这种情况持续,最终存储资源将会耗尽,路由器因此只能丢弃部分数据。路由器因无法处理高速率到达的流量而被迫丢弃数据信息的现象称为 拥塞

出现拥塞情况若不采取对策,网络性能将大受影响以致瘫痪。为避免或者在一定程序上缓解这种状况,TCP通信的每一方实行拥塞控制。不同的TCP版本(包括运行TCP/IP协议栈的操作系统)采取的规程和行为有所差异。

根据接收方剩余缓存空间大小,在TCP头部设置了通告窗口大小字段,该数值是TCP发送方调节发送速率的依据。当接收速率或网络传输速率过慢时,需要降低发送速率。为实现上述操作在发送端引入一个窗口控制变量,确保发送窗口大小不超过接收端接收能力和网络传输能力,即TCP发送端的发送速率等于接收速率和传输速率两者中较小值。

反应网络传输能力的变量称为 拥塞窗口(congestion window,cwnd),通告窗口记作awnd,因此发送端实际(可用)窗口W值为:

W=min(cwnd,awnd)W=min(cwnd, awnd)

根据上述等式,TCP发送端发送的数据中,还没有收到ACK回复的数据量不能多于W。这种已经发出但还未经确认的数据量大小有时称为 在外数据值(flight size) ,它总是小于等于W

拥塞窗口(cwnd)维护原则:只要网络没有出现拥塞,拥塞窗口就再增大一些,但只要网络出现拥塞,拥塞窗口就减少一些。

判断出现网络拥塞的依据:没有按时收到应当到达的确认报文(即发生超时重传)。

发送方将拥塞窗口作为发送窗口(swnd),即 swnd=cwndswnd = cwnd

拥塞控制主要算法有, 慢启动拥塞避免快重传快恢复 算法。

其中慢启动和拥塞避免算法的切换是通过维护一个 慢启动阈值(slow start threshold,ssthresh) 变量进行控制的。

  • cwnd<ssthreshcwnd < ssthresh 时,使用慢启动算法。
  • cwnd=ssthreshcwnd = ssthresh 时,即可使用慢启动算法,也可使用拥塞避免算法。
  • cwnd>ssthreshcwnd > ssthresh 时,停止使用慢启动算法而改用拥塞避免算法。

慢启动的初始阈值可以任意设定(如awnd或更大),这会使用TCP总是以慢启动状态开始传输。当有重传情况发生,无论是超时重传还是快速重传,ssthresh值自动按下面方式更新:

ssthresh=max(/2,2SMSS)ssthresh = max(在外数据值/2, 2*SMSS)

微软最近的TCP/IP协议栈中,上述等式变为: ssthresh=max(min(cwnd,awnd)/2,2SMSS)ssthresh = max(min(cwnd, awnd)/2, 2*SMSS)

当一个新的TCP连接建立或检测到由重传超时(RTO)导致的丢包时,需要执行慢启动。TCP发送端长时间处于空闲状态也可能调用慢启动算法。

TCP以发送一定数目的数据段开始慢启动(在SYN交换之后),称为 初始窗口(Initial Window,IW)IW初始值设为一个SMSS,但在RFC5681中设为一个稍大的值,公式如下:

IW={2(SMSS)2SMSS>21903(SMSS)32190SMSS>10954(SMSS)4}IW = \begin{Bmatrix} \begin{aligned} &2*(SMSS)\quad 且小于等于2个数据段(当 SMSS > 2190 字节) \\ &3*(SMSS)\quad 且小于等于3个数据段(当 2190 \geqq SMSS > 1095 字节) \\ &4*(SMSS)\quad 且小于等于4个数据段(其他) \\ \end{aligned} \end{Bmatrix}

拥塞控制算法拥塞控制算法

上图为TCP拥塞控制算法使用的模拟,其中 传输轮次 就是指发送给接收方发送报文段后,接收方发回相应的确认报文段。一个传输轮次所经历的时间其实就是往返时间(RTT),注意的是往返时间并非是恒定的数值,使用传输轮次就是为了强调把拥塞窗口所允许发送的报文段都连续发送出去,并收到了对已发送的最后一个报文段的确认。

为简单起见,只讨论 IW=1SMSSIW = 1 \, SMSS 的情况。TCP连接初始的 cwnd=1SMSScwnd=1 \, SMSS,意味着初始可用窗口W也为1 SMSSssthreash初始值设置为16

  • 数据传输开始,使用慢启动算法,每一轮次窗口(W)大小呈指数增加 W=2kk=log2WW = 2^k,即 \, k=log_2W。当第4轮时窗口大小增大到16达到慢启动阈值,开始使用拥塞避免算法。

  • TCP进入拥塞避免阶段,窗口大小每次增长值近似等于成功传输的数据段大小(大小加1)。这种呈线性增长方式与慢启动相比缓慢许多,当第12轮窗口大小增大到24时,发生重传计时器超时现象判断可能出现了拥塞。

    更准确地说,每接收一个新的ACK后,cwnd会做以更新:

    cwndt+1=cwndt+SMSSSMSS/cwndtcwnd_{t+1} = cwnd_t+SMSS*SMSS/cwnd_t

  • TCP判断出现了拥塞后,会将窗口大小重新减小为初始值(即1),以达到慢启动的目的,将ssthresh值减小到当前窗口大小的一半(即 24/2=1224/2=12)。

  • TCP继续重复上述步骤,使用慢启动算法直至窗口大小达到慢启动阈值(12),切换使用拥塞避免算法,直到出现拥塞。

    有时个别报文段会在网络中丢失,但实际上网络并未发生拥塞。发送方把拥塞窗口cwnd又设置为最小值1,并错误的启动慢开始算法,因而降低了传输效率。

    采用快重传算法可以让发送方尽早知道发生了个别报文段的丢失。所谓的快重传是指,在接收端收到一个擒的报文段时,TCP立即需要产生一个重复的ACK并且这个ACK不应该被延迟发送。该重复的ACK的目的在于让对方知道收到一个失序的报文段,并告诉对方自己希望收到的序号。当发送方连续收到3个或以上的重复ACK时就重传丢失的数据报文段,而无需等待超时定时器溢出。

  • 21轮次时,发生报文段丢失,当发送方收到3个重复的确认时,就进行快重传。

    当接收方收到快速重传的报文段后,不再执行慢启动算法,而是将ssthresh值设置为当前窗口的一半,设置cwnd值为ssthresh加上3倍报文段大小(这么做的原因是TCP认为既然发送方收到3个重复的确认,就表明3个数据报文段已经离开网络,因此可以适当将拥塞窗口扩大3个报文段大小),然后继续执行拥避免算法。这个操作就是快恢复算法。

  • 22轮次中,ssthresh值变为8cwnd11,继续使用拥塞避免算法线性增长拥塞窗口发送报文段。

超时与重传

TCP在发送数据时会设置一个计时器,若至计时器超时仍未收到数据确认信息,则会引发相应的超时或基于计时器的重传操作,称为 重传超时(RTO) 。重传操作每次尝试时间间隔会加倍,称为 二进制指数退避(binary exponential backoff) 。自首次请求至连接完全失效后,客户端会显示Connection close by foreign host.

RFC1122描述了两个阈值:R1表示TCP在向IP层传递"消极建议"(如重新评估当前的IP路径)前,愿意尝试重传的次数(或等待时间);R2(大于R1)指示TCP应放弃当前连接的时机。这两个阈值应分别至少设为三次重传和100sLinux系统中,R1R2可以通过应用程序或使用系统变量net.ipv4.tcp_retries1net.ipv4.tcp_retries2设置。变量值为重传次数,而不是以时间为单位。

设置重传超时

RTO的值小于RTT的值,可能会在网络中引入不必要的重复数据。反之,若延迟至远大于RTT的间隔发送重传数据,整体网络利用率会随之下降。下面是一些常见的RTO方法:

  • 经典方法

    最初TCP规范采用如下公式计算得到平滑的RTT估计值,称为SRTT

    SRTTa(SRTT)+(1a)RTTsSRTT \leftarrow a(SRTT)+(1-a)RTT_s

    当采集第一次样本时,SRTT直接取值为RTT值,之后SRTT是基于现在值和新的样本值 RTTsRTT_s 得到更新结果的。常量a为平滑因子,推荐值为0.8~0.9。从a的设定值可以看到,新的估计值80%~90%来自现存值,10%~20%来自新测量值,这种估算方法称为 指数加权移动平均(Exponentially Weighted Moving Average,EWMA)

    考虑到SRTT估计器得到的估计值会随RTT而变化,RFC0793推荐根据如下公式设置RTO

    RTO=min(ubound,max(lbound,(SRTT)β))RTO=min(ubound,max(lbound,(SRTT)β))

    这里β为时延离散因子,找茬值为1.3~2.0uboundRTO的上边界(可设定建议值,如1分钟),lboundRTO的下边界(可设定为建议值,如1秒),该算法被称为 经典方法

    这种方法使得RTO的值设置为1秒或约两位的SRTT。对于稳定的RTT分布来说,这种方法能取得不错的性能。然而,若TCP运行于RTT变化较大的网络中,则无法获得期望的效果。

  • 标准方法

    为了解决经典方法的问题,可通过记录RTT测量值的变化情况以及均值来得到较为准确的估计值。如果将TCP得到的RTT测量样本值考虑为一个统计过程,那么同时测量均值和方差(或标准差)能更好的估计将来值。计算标准差需要对方差进行平方根运算,对于快速TCP实现来说代价较大(并非全部原因),使用平均偏差是对标准差的一种好的逼近,但计算起来却更容易、快捷。因此结合平均值和平均偏差来进行估算,如下:

    SRTT(1g)(SRTT)+(g)RTTsSRTT \leftarrow (1-g)(SRTT_旧)+(g)RTT_s

    rttvar(1h)(rttvar)+(h)(RTTsSRTT)rttvar \leftarrow (1-h)(rttvar_旧)+(h)(|RTT_s-SRTT|)

    RTO=SRTT+4(rttvar)RTO=SRTT+4(rttvar)

    rttvar表示平均偏差,当采集第一次样本时,该值是RTTsRTT_s的一半(即RTTs÷2RTT_s÷2)。增量g为新RTT样本RTTSRTT_SSRTT估计值的权重,取为1/8。增量h为新平均偏差样本(新样本RTTsRTT_s与当前平均值SRTT之间的绝对误差)占偏差估计值rttvar的权重,取为1/4。这是迄今为止许多TCP实现计算RTO的方法,称为 标准方法

RTT 的计算

RTT 计算RTT 计算

在测量RTT样本过程中若出现重传,如一个包的传输出现超时,该数据包会被重传,接着收到一个确认信息,那么该信息是对第一次还是第二次的传输的确认就存在二义性。如上图两种情况:

  • 情况一(图1)

    TCP发送报文段过程中丢失,之后出现超时重传,此时收到报文段确认。此时出现二义性,若源主机误将确认当作是对原报文段的确认,所计算出的RTTsRTT_sRTO就会偏大,降低了传输效率。

  • 情况二(图2)

    TCP发送报文段,之后服务器确认该报文段,此时客户端出现超时重传。此时出现二义性,若源主机误将确认当作是对重传报文段的确认,所计算出的RTTsRTT_sRTO就会偏小,导致报文段发生没必要的重传增大网络负荷。

针对上面两种情况出现的问题,Karn提出了一个算法,当出现重传时,不重新计算RTTsRTT_s,进而超时重传时间RTO也不会重新计算。这样就解决了二义性的问题,但是这样做又引起了新的问题:假如报文段的时延突然增大很多,并且之后很长一段时间都会保持这种时延。因此在原来得出的重传时间内,不会收到确认报文段。于是发生超时重传,但根据Karn算法,超时时间不会被更新,因此导致报文段反复被重传。

为了解决这个问题,TCP在计算RTO过程中采用一个 退避系数(backoff factor) ,每当重传计时器出现超时退避系数加倍,该过程一直持续至接收到非重传数据,此时退避系统重新设为1。这是Karn算法的第二部分。

Karn算法一直作为TCP实现中的必要方法,然而也有例外情况,在TCP使用时间戳选项时,可以避免二义性问题,因此Karn算法的第一部分不适用。

RTO 计算示例

RTO 计算RTO 计算

上图展示了TCP传输的RTO时间,其计算步骤如下:

  • 第一次报文段传输,RTT1RTT_130,由于是第一次传输所以SRTT1=RTT1SRTT_1=RTT_1rttvar值为RTT÷2=15RTT÷2=15,根据RTO计算公式代入得到其值为30+4×15=9030+4×15=90

  • 第二次报文段传输,RTT2RTT_226,因为不是第一次采集样本,所以需要根据公式计算SRTTsSRTT_s的值为(118)×30+18×26=29.5(1-\frac{1}{8})×30+\frac{1}{8}×26=29.5,同样根据公式计算rttvar的值为(114)×15+14(2630)=12.25(1-\frac{1}{4})×15+\frac{1}{4}(|26-30|)=12.25,最后根据RTO计算公式得到新超时时间为29.5+4×12.25=78.529.5+4×12.25=78.5

  • 第三报文段传输,发生超时重传,根据Karn算法第二部分需要将,退避系数加倍,因此RTO的值也会在RTO2RTO_2的基础上加倍即78.5×2=15778.5×2=157