摘自 趣谈网络协议 | 极客时间

目录

1. TCP 包头格式

TCP 包头格式
TCP 包头格式
  • 源端口号目标端口号
  • 包的序号确认序号
  • 状态位SYN发起连接ACK回复RST重新连接FIN结束连接
  • 窗口大小:用于流量控制、拥塞控制

2. TCP 的三次握手

TCP 的连接建立,我们称之为“三次握手”,即“请求 -> 应答 -> 应答之应答”

三次握手除了确保双方建立连接以外,主要还为了沟通 TCP 包的序号问题

A 要告诉 B,自己发起的包的序号起始是从哪个号开始的,B 同样也要将自己的起始序号告诉 A。为了确保互相包的序号不发生冲突,TCP 的每个连接都要有不同的序号,这个序号的起始序号是随着时间变化的

当双方终于建立了信任,建立了连接之后,为了维护这个连接,双方都要维护一个状态机。在连接建立的过程中,双方的状态变化时序图如下所示:

TCP 建立连接时的状态变化时序图
TCP 建立连接时的状态变化时序图
  1. 一开始,客户端服务端都处于CLOSED状态
  2. 先是服务端主动监听某个端口,处于LISTEN状态
  3. 然后客户端主动发起连接SYN,之后处于SYN-SENT状态
  4. 服务端收到发起的连接,返回SYN,并且ACK客户端的SYN,之后处于SYN-RCVD状态
  5. 客户端收到服务端发送的SYNACK之后,发送ACKACK,之后处于ESTABLISHED状态,因为客户端一发一收已经成功了
  6. 最后,服务端收到ACKACK之后,处于ESTABLISHED状态,因为它也一发一收成功了

3. TCP 的四次挥手

类似的,TCP 在断开连接时,也需要进行四次挥手,如下图所示:

TCP 断开连接时的状态变化时序图
TCP 断开连接时的状态变化时序图
  1. 断开的时候,当 A 说 “不玩了”,就进入了FIN_WAIT_1状态
  2. B 收到 A “不玩了” 的消息后,发送ACK,就进入CLOSE_WAIT状态
  3. A 收到 B 的ACK后,就进入FIN_WAIT_2状态。这时如果 B 直接跑路,则 A 将永远停留在这个状态。可以在 Linux 中调整tcp_fin_timeout这个参数,设置一个超时时间
  4. 如果 B 没有跑路,发送 “B 也不玩了” 的请求到达 A 时,A 发送 “知道 B 也不玩了” 的ACK后,FIN_WAIT_2状态结束,进入TIME_WAIT状态,等待时间为2MSL(Maximum Segment Lifetime,报文最大生存时间
  5. 最后还有一个异常情况就是,B 超过了2MSL的时间,仍然没有收到它发的FINACK,按照 TCP 的原理,B 就会重发这个FIN,只不过当 A 再收到这个包之后,A 就直接发送RST回复给 B,这时候 B 就会知道 A 早就跑了

4. TCP 状态机

令人头秃的 TCP 状态机
令人头秃的 TCP 状态机

5. TCP 如何保证传输可靠

TCP 协议为了保证包的顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但这个应答并不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答(cumulative acknowledgment)。

为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。发送端里的缓存是按照包的 ID 一个个排列的,根据处理的情况分成四个部分:

  1. 发送了并且已经确认
  2. 发送了并且尚未确认
  3. 没有发送,但是已经等待发送
  4. 没有发送,并且暂时还不会发送

在 TCP 里,接收端会给发送端报一个窗口的大小,叫Advertised Window。它的大小应该等于上面的第二部分加上第三部分,即已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。

为此,发送端需要保持下面的数据结构:

  • LastByteAcked「已发送已确认」的最后一个字节
  • LastByteSent「已发送未确认」的最后一个字节
  • AdvertisedWindow:黑框部分,「已发送未确认」+「未发送可发送」

对于接收端来讲,其缓存里的记录和内容要更简单一些:

  1. 接收并确认过
  2. 还没接收,但是马上就能接收
  3. 还没接收,也没法接收的,即超过窗口的部分

对应缓存的数据结构如下图所示:

  • LastByteRead:之后是已经接收了,但是还没被应用层读取的
  • NextByteExpected:第一部分和第二部分的分界线
  • MaxRcvBuffer最大缓存的量,图中黑框部分

第二部分窗口大小AdvertisedWindow即为MaxRcvBuffer减去第一部分「接收已确认」的大小

6. 顺序问题与丢包问题

TCP 传输的过程中,顺序问题丢包问题都可能发生:有些包可能丢了,有些包可能还在路上。还有些可能已经到了,还是因为出现了乱序,所以只能先缓存着但是没办法ACK

为了解决这些问题,TCP 有一套确认与重发的机制

假设4的确认收到了,不幸的是,5ACK丢了,67的数据包丢了,这种时候该怎么办?

6.1 超时重试

一种解决方法就是超时重试,即对每一个发送了但是没有收到ACK的包,都设置一个定时器,超过一定时间必须重新尝试。这个时间必须大于往返时间 RTT,否则会引起不必要的重传,也不宜过长,导致访问变慢。

6.2 自适应重传算法

估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值。由于重传时间是不断变化的,我们称之为自适应重传算法(Adaptive Retransmission Algorithm)。

6.3 超时间隔加倍

当一个包再次超时,又需要重传的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送

6.4 快速重传

TCP 还有一个可以快速重传的机制:当接收方收到一个序号大于下一个所期望的报文段时,就检测到了数据流中的一个间格,于是发送三个冗余的ACK。客户端收到后,就在定时器过期之前,重传丢失的报文段

6.5 SACK

还有一种方式称为 Selective Acknowledgement(SACK)。这种方式需要在 TCP 头里加上SACK,可以将缓存的地图发送给发送方。例如可以发送ACK6SACK8SACK9,有了地图,发送方就可以看出是序号为7的包丢失了。

7. 流量控制问题

TCP 还有流量控制机制,在接收方对于包的确认中,同时会携带一个窗口的大小

7.1 窗口不变的情况

假设窗口不变的情况,窗口始终为94的确认来的时候,会右移一格,这时候第13个包也可以发送了:

这个时候,假设发送端发送过猛,会将第三部分的10111213全部发送完毕。由于已发送未确认的包已经占满整个窗口,因此之后就停止发送了,未发送可发送的部分变为0

当对于5的确认到达时,在客户端相当于窗口再滑动了一格,这个时候,14个包就可以发送了

如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为0,则发送方将暂时停止发送

7.2 窗口变化的情况

再假设一个极端情况:接收端的应用一直不读取缓存中的数据,当数据包6确认后,窗口大小就要缩小一个变为8

新的窗口大小8通过6的确认消息到达发送端之后,发送端的窗口就不会再右移,而是仅仅左边的边右移,窗口大小也从9变成了8

如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口也会越来越小,直到为0

当这个窗口通过包14的确认到达发送端的时候,发送端的窗口也调整为0,停止发送

除了被动接收窗口信息之外,发送方也会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止底能窗口综合征:可以当窗口太小的时候就停止更新窗口直到达到一定大小,或者缓冲区一半为空,才更新窗口

8. 拥塞控制问题

最后来谈一下 TCP 的拥塞控制问题,也是通过窗口的大小来控制的。前面流量控制滑动窗口rwnd(Receive Window,接收窗口)是怕发送方把接收方缓存塞满,而拥塞窗口cwnd(Congestion Window,拥塞窗口)是怕把网络塞满

TCP 的发送速度拥塞窗口滑动窗口共同控制:

swnd = LastByteSent - LastByteAcked <= min {cwnd, rwnd}

对于 TCP 协议来讲,整个网络路径就是一个黑盒TCP 发送包常被比喻为往一个水管里面灌水,而 TCP 的拥塞控制就是在不堵塞、不丢包的情况下,尽量发挥带宽

如果设置发送窗口swnd,使得发送但未确认的包通道的容量,就能撑满整个管道

TCP 的拥塞控制主要用来避免两种现象包丢失超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。

  • 一条 TCP 连接开始cwnd设置为 1 个报文段,一次只能发送一个
  • 收到这一个确认的时候,cwnd加 1,于是一次能够发送两个
  • 这两个的确认到来的时候,两个确认cwnd加 2,于是一次能够发送四个
  • 当这四个的确认到来的时候,四个确认cwnd加 4,于是一次能够发送八个

这个过程称为 TCP 的慢启动阶段

可以看出在慢启动阶段cwnd指数性的增长。但当swnd超过阈值ssthresh(默认为 65535 字节,即swnd为 16 时刚好超过),就要改为线性增长,即每收到一个确认后,cwnd增加1/cwnd

这个过程称为 TCP 的拥塞避免阶段

还是看上图,当cwnd为 20 时,出现了拥塞(例如丢包,需要超时重传),这个时候,需要将ssthresh设为cwnd/2即 10,将cwnd设为 1,重新开始慢启动。下图是另外一个例子:

虽然这种方式避免了拥塞,但是大大降低了原本处于高速状态的传输速度,还可能造成网络卡顿

之前提到,TCP 还有一个快速重传算法:当接收端发现丢了一个中间包的时候,发送三次前一个包的ACK,于是发送端就会快速的重传,不必等待超时再重传,这时cwnd减半变为cwnd/2,然后再令ssthresh=cwnd,当三个包返回时,cwnd=sshthresh+3,继续维持线性增长(即图中的橙线部分)。

正是 TCP 这种知进退的特点,使得在时延很重要的情况下,反而降低了速度。但其实,TCP 的拥塞控制主要来避免的两个现象都是有问题的:

  1. 丢包并不代表着通道满了,例如公网上带宽不满也会丢包,这个时候就认为拥塞了、退缩了,其实是不对的
  2. TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这个时候已经晚了,其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满

为了优化这两个问题,后来有了 TCP BBR 拥塞算法(Bottleneck Bandwidth and Round-trip propagation time,由 Google 设计,于 2016 年发布)。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样会导致时延增加,在这个平衡点可以很好的达到高带宽和低时延的平衡

9. 小结

  • TCP 的 顺序问题、丢包问题、流量控制问题都是通过滑动窗口来解决的
  • 拥塞控制是通过拥塞窗口cwnd来解决的

参考文章

  1. 趣谈网络协议 | 极客时间
  2. TCP/IP 拥塞控制 | 简书
  3. TCP 的流量控制和拥塞控制 | CSDN