计网基础-TCP进阶

loading 2023年01月31日 70次浏览

1. TCP重传

TCP 实现可靠传输的方式之一,是通过序列号与确认应答。

在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。(TCP头部的序列号和确认应答号)

但是在复杂的网络中,数据并不一定能那么顺利正常地传输,万一数据在传输过程中丢失了呢?此时TCP引入重传机制来解决:

  • 超时重传
  • 快速重传
  • SACK
  • D-SACK

1.1 超时重传

在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,这就是我们常说的超时重传

引入一个概念:RTO(超时重传时间)RTO的值应该略大于RTT的值。,否则:

  • RTO过大: 很久才重发一次,丢包丢了老半天才等到重发,效率低
  • RTO过小: 可能包只是跑得慢了一点,包还没有丢就又重发了,增加网络拥塞,浪费资源

RTT:数据发送时刻到接收到确认的时刻的差值,即包的往返时间

如果超时重传的数据又超时了,那么就又需要重传,此时TCP的策略是超时间隔加倍,也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。

超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?

于是就可以用「快速重传」机制来解决超时重发的时间等待。

1.2 快速重传

TCP 还有另外一种快速重传机制,它不以时间为驱动,而是以数据驱动重传

如上图

  • 第一份 Seq1 先送到了,于是就 Ack 回 2;
  • 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
  • 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
  • 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
  • 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。

所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段

但是快速重传存在这么一个问题:重传的时候是只重传丢失的那个还是后面的全部重传呢?

举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方:

  • 只传丢失的,那么在SEQ2重传好后,会发现SEQ3也是丢失的,那么又得在后续收到三个重复的 ACK3 才能触发重传。
  • 如果后面的全部重传,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。

为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。

1.3 SACK

SACK( Selective Acknowledgment), 选择性确认

这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。

2. 滑动窗口

我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。

如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。

所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低

为解决这个问题,TCP 引入了窗口这个概念,有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

TCP头里有一个16位的字段就是用来存窗口大小的,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答

举个易懂的例子,看图就能明白滑动窗口是怎么运作的了:

发送方的滑动窗口

接收方的滑动窗口

发送窗口的大小等于接收窗口和拥塞窗口中的较小值

3. 流量控制

发送方不能无脑的发数据给接收方,要考虑接收方处理能力。

如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。

为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制

看懂这张图即可

4. 拥塞控制

拥塞控制和上面讲到的流量控制有什么区别呢?

流量控制的目的是避免发送方的数据填满接收方的缓存,是面向结果的,但是并不知道发送数据的途中发生了什么。

实际中网络环境是十分复杂的,在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大...

因此,拥塞控制随之而生,目的就是避免「发送方」的数据填满整个网络

为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口cwnd」的概念,拥塞窗口是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。

  • 只要网络中没有出现拥塞,cwnd 就会增大;
  • 但网络中出现了拥塞,cwnd 就减少;

只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。

接下来讲一下拥塞控制的四个算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

4.1 慢启动

TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?

慢启动的算法记住一个规则就行:发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。因此可以得知,慢启动阶段发包个数是指数增长的,为啥?

如图,随着窗口增大,每次可以发的包也翻倍,导致了发包数量的指数增长。

那总不能一直指数增长下去吧,因此TCP引入一个状态变量称为慢启动门限 ssthresh

  • 当 cwnd < ssthresh 时,使用慢启动算法
  • 当 cwnd >= ssthresh 时,就会使用拥塞避免算法

一般来说 ssthresh 的大小是 65535 字节。

4.2 拥塞避免

当 cwnd >= ssthresh 时,就会使用拥塞避免算法。

该算法的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd

现假定ssthresh为8,当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。

所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。

随着cwnd的一直增长,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。

当触发了重传机制,也就进入了「拥塞发生算法」

4.3 拥塞发生

我们上面知道超时重传分为两种:超时重传和快速重传,这两种重传对应的拥塞发生算法也是不一样的。

4.3.1 发生超时重传的拥塞发生算法

  • ssthresh = cwnd/2
  • cwnd恢复成初始值
  • 重新开始慢启动算法

接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。

4.3.2 发生快速重传的拥塞发生算法

还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。

TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:

  • ssthresh = cwnd / 2
  • cwnd = ssthresh
  • 进入快速恢复算法

4.4 快速恢复

快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像超时重传那样反应这么强烈。

快速恢复算法如下:

  • 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
  • 重传丢失的数据包;
  • 如果再收到重复的ACK,那么 cwnd 增加 1;
  • 如果收到新数据的ACK,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;

也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

5. 其他问题

5.1 已建立连接的TCP,收到SYN会发生什么?

一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?

(1) 如果客户端的 SYN 报文里的端口号与历史连接不相同
服务端会认为要建立新的连接,于是通过三次握手来建立新的连接。

那旧连接里处于 Established 状态的服务端最后会怎么样呢?

如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。

如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。

(2) 客户端的 SYN 报文里的端口号与历史连接相同
处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。

接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。

5.2 TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?

完全不同。

  • HTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接

详细讲解见HTTP篇

  • TCP 的 Keepalive,是由 TCP 层(内核态) 实现的,称为 TCP 保活机制

TCP 的 Keepalive 这东西其实就是 TCP 的保活机制

如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文

如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。

如果对端主机宕机 (注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机) ,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。

5.3 TCP和UDP能绑定相同的端口吗?

可以。

在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。

所以,传输层的端口号的作用,是为了区分同一个主机上不同应用程序的数据包

传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。

当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

因此, TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。

那么多个TCP进程能不能绑定同一个端口呢?答案自然是只要这些进程绑定的IP地址不同的话是可以的。

5.4 服务端没有 listen,客户端发起连接建立,会发生什么?

服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。(其实可以发现很多情况下都是返回RST报文,也许可以找到一些共通之处。)

5.5 半连接队列和全连接队列

  • 半连接队列(SYN队列),服务端收到第一次握手后,会将socket加入到这个队列中,队列内的socket都处于SYN_RECV 状态。
  • 全连接队列(ACCEPT队列),在服务端收到第三次握手后,会将半连接队列的socket取出,放到全连接队列中。队列里的socket都处于 ESTABLISHED状态。这里面的连接,就等着服务端执行accept()后被取出了。

虽然都叫队列,但其实全连接队列是个链表,而半连接队列是个哈希表

为什么呢?因为全连接队列它里面放的都是已经建立完成的连接,这些连接正等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就行,所以直接从队列头取就行了。这个过程算法复杂度为O(1)。

而半连接队列却不太一样,因为队列里的都是不完整的连接,嗷嗷等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列里把相应IP端口的连接取出,如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是O(n)。