关于TCP的几个问题

今天聊聊TCP,老规矩,为了更符合读者的思考逻辑,文章依然由问题来组织:

  1. 在一个不可靠的网络中,如何做到可靠的传输?
  2. TCP的连接到底是啥?
  3. “三次握手”做了什么?
  4. “四次挥手”做了什么?
  5. 丢包重传是怎么做的?
  6. 服务器处理不过来了, 你能发慢点吗?
  7. 好慢啊,网络卡了?

先补充一点前置知识,我们讨论的TCP,属于TCP/IP模型的传输层(第四层),向下基于IP层,向上支撑了应用层。

就像本文的结构一样,这个世界是由问题组成的,协议的诞生是为了解决问题。TCP解决了这样一个问题:

问题1: 在一个不可靠的网络中,如何做到可靠的传输?

这里说的可靠,并不是说发送的数据一定能收到,下层的IP包该丢还是丢;仅仅是指对方收到了我的包,会发一个响应包给我,告诉我收到了;只要是没收到响应包,都按丢了处理,检测到丢包后按照一定的逻辑进行重发,如果实在是收不到,就按照失败来处理了。

关于可靠传输还有一个问题就是:乱序,TCP的包有严格的顺序,如果后面的包先到了,接收端要能够检测到,并且正确的处理。乱序的原因可能是前面的包丢了,或者后面的包先到了。

为了便于理解,我们来看一下TCP Header的格式:


TCP Header格式

这个图会多次出现,这里我们只关注两个数据:

  • Sequence Number: 包的序号,用于解决乱序的问题。
  • Acknowledgment Number: ACK,就是响应包,用于解决丢包的问题。

所以,做到可靠的底层逻辑是:增加冗余。
与UDP相比,TCP的(几乎)每个包都有响应包,这已经让包的数量增加了一倍。另外,丢包时要重发,甚至多次重发,做到可靠的方式就是有组织地增加冗余。


TCP宣称自己是面向连接的,传输数据之前要先建立连接,那么问题来了:

问题2: TCP的连接到底是啥?

按照老美的套路,我们先聊一聊它不是什么?

有一种相当普遍的错误理解

TCP连接是互联网上的一条专属通道,就像是在两端建立了一座桥。

上面的错误说法非常流行,建立TCP的连接对于IP层没有任何改造。IP层也不关心这个包是是不是TCP的包。

TCP建立连接的含义是: 两端的设备创建了一些数据结构,这些数据结构包含了对方的信息(IP和端口和状态等),建立连接的过程就是数据结构中连接状态变为“已连接”的过程,后面发送端发送数据到接收端,接收端经过检查发现在自己数据结构中包含了对方的IP和端口,就会正常地接收这个包,仅此而已。


所以,TCP连接仅仅是两端维护的“连接状态”,都说建立连接的过程叫“三次握手”,那么问题来了:

问题3: “三次握手”做了什么?

既然TCP的连接仅仅是“状态维护”,那么TCP就有一套状态集合,包含了TCP的所有状态,这套状态集合就是TCP的状态机

回到TCP Header格式图:

TCP Header格式

这次关注的数据是:

  • TCP Flags: 包的标识,主要用于状态机的维护

关于TCP Flags的取值,可以扫一眼下面这张图,先注意一下SYNFIN这两值。

TCP Flags of TCP Header

直接讲TCP的状态变换非常生硬,我们穿插在连接建立和断开的过程中来讲,这样比较直观。

从包的发送角度来看,建立连接过程就是三个包发送且到达的过程,如下图:


TCP连接状态图

过程大概是这样(其实图已经非常直观了):

  • 准备阶段,B要先监听一个端口,通常服务器监听的固定端口,状态机变化: CLOSED -> LISTEN
  • A发送(1)(一个TCP Segment),TCP Flags为SYN, 表示希望同步初始序列号; seq(上面提到的Sequence Number)为x,表示来自A的包起始的序列号是x. 状态机变化: CLOSED -> SYN_SEND
  • (收到(1)之后) B发送(2), TCP Flags 为SYN, 表示同步初始序列号;seq=y, 表示来自B的包起始序列号是y; ACK(上面提到的Acknowledgment Number)为x+1, 表示收到了A发来的seq=x的包。 状态机变化: LISTEN -> SYN_RCVD
  • (收到(2)之后) A发送(3), ACK=y+1, 表示收到B发来的seq=y的包, 状态机变化: SYN_SEND -> ESTABLISHED, 此时A端连接建立,A可以主动发送数据了。
  • (收到(3)之后) B端状态机变化: SYN_RCVD -> ESTABLISHED, 此时B端建立连接,B可以主动发送数据了。

看到这里,那个经典的问题又来了:

为什么非要三次呢?两次不行吗?四次不行吗?

先给答案: 两次真不行,四次不划算

从连接过程中可以看到,TCP三次握手主要解决确定了下面的问题:

  1. 确认对方是可以连通的, 也愿意让我连(我发送的包,收到了对方的ACK)
  2. 确认对方已经知道了我的初始序列号(ISN),后面我发送的数据包seq与这个值有关系的

如果改成两次,就不能确认A已经知道了B的ISN。
如果改成四次,是可以知道对方收到了(3),但也不能因此让网络更加可靠,所以不划算。

细心的同学可能意识到一个问题: A 与 B连接连接的时间点是不同的,一般情况下(3)丢了肯定是重发(2), 但是

如果A建立连接后立马发数据,此时(3)丢了,B端还没有建立连接,那该怎么办呢?

这位同学很刁钻啊,A发的数据到了,说明A建立连接了,说明A收到(2)了, 此时A发的数据包就起到了(3)的确认作用,B会立马建立连接然后处理这些数据。


说完了建立连接,我们看看关闭连接的过程:

问题4: “四次挥手”做了什么?

既然建立了连接,就要能够断开连接,服务器里为对端设备维护的数据结构也需要释放。

连接的断开比建立要复杂一些,正常情况下,连接的断开都是主动的,过程如下图::


TCP-Disconnect-Single
  • 前置条件,两端都处于ESTABLISHED状态(建立已建立)
  • 一方发起断开请求,这里A发起断开: A发送(1), TCP Flags 为FIN(FINISH), seq=x, 表示请求断开连接, 状态机变化: ESTABLISHED -> FIN_WAIT_1, 之后A不再向B发送应用层数据。
  • (收到(1)之后) B发送(2) ACK=x+1表示收到A发送的seq=x的包,状态机变化: ESTABLISHED -> CLOSE_WAIT
  • (收到(2)之后) A的状态机变化: FIN_WAIT_1 -> FIN_WAIT_2
  • (B应用层数据传输完成后) B发送(3), TCP Flags 为FIN, seq=y, 表示请求断开连接。状态机变化: CLOSE_WAIT -> LAST_ACK, 同时B不再向A发送应用层数据。
  • (收到(3)之后) A发送(4) ACK=y+1, 表示收到B发送的seq=y的包,状态机变化: FIN_WAIT_2 -> TIME_WAIT, 等待2MSL(Maximum Segment Lifetime, TCP Segment的最大生存时间),之后状态机变化: TIME_WAIT -> CLOSED, 此时A断开连接。
  • (收到(4)之后) B的状态机发生变化: LAST_ACK -> CLOSED, 此时B断开连接。

看到这里,同样的问题又来了:

为什么是四次挥手,为什么不是三次?

答案显而易见: 三次不行

因为A无法确认,在自己请求关闭时,B是否还有应用层数据需要发送。所以需要B确认没有应用层数据发送时,再发起一个断开请求(3),如果B发送(3)之后直接关闭,在(3)丢包的时候,A会以为B还有数据要发,A永远处于FIN_WAIT_2状态。当然Linux会设一个超时,处理这种异常,但是大部分的流程是正常的,不应该按照异常来处理。

细心的同学可能发现一个问题:

A收到(3)之后,没有马上关闭,而是进入了一个TIME_WAIT状态,等待了2MSL才关闭?为什么?

主要是B没收到(4)时,有足够的时间再发一次。这里的2MSL,TCP协议定的是2分钟,实际中一般使用30秒。

这时候,一个经验丰富的同学占了起来,问:

你说的都是正常情况,生活中的异常情况太多了,比如拔网线,手机关机,坐在车上切换了基站,这些情况根本不会按照正常的流程走的。

这位同学你说的对, 我们上面讨论的都是主动断开连接,事实上,被动断开连接的情况也会出现,而且会出现在各个阶段,这个时候我们一般能够检测到目标(ip+端口)不可达,不过这涉及到一个叫ICMP的协议(就是ping命令使用的协议),这里就不做太多介绍了。


实际上,断开连接还有一种情况: 双方同时断开,这也是一种正常情况,如下图:

TCP-Disconnect-Both

双方同时断开并不要求双方在同一时间点发送断开的请求,只要是对方的断开请求还没收到,这时发出断开请求,都算是同时断开。
我们看到同时断开的场景双方的状态变化是一致的(不要求时间一致),我们只讲一端的状态变化:

  • A发出(1), TCP Flags为FIN, seq=j, 表示请求断开连接。状态机变化: ESTABLISHED -> FIN_WAIT_1, A不在向B发送应用层数据.
  • A收到(2), 表示B请求断开连接, 未收到断开请求ACK,先收到了对方的断开请求。A端意识到此时是同时断开场景,发送(2)', 表示收到了B的断开请求,状态机变化: FIN_WAIT_1 -> CLOSING
  • A收到(1)', 表示B收到了A的断开连接请求,状态机变化: CLOSING -> TIME_WAIT, 等待2MSL后状态机变化: TIME_WAIT -> CLOSED,此时A断开连接。

聊到这里,我们可以对TCP的状态机做个总结了,我们可以对TCP的状态机做个总结了。这是一张重要又复杂的图,不过如果前面的内容都读懂了,你理解这张图就就不成问题了。

The TCP Finite State Machine (FSM)

在终端输入netstat命令,现在你可以明白最后一列是什么意思了:

netstat 命令截图


说完了TCP连接和断开,就要聊聊传输过程中的事儿了,最先想到的就是丢包重传了:

问题5: 丢包重传是怎么做的?

丢包重传是TCP保证可靠的重要机制,在TCP协议的实现中,会综合很多机制来做到这一点:

超时重传机制

发送端会动态地给数据包设置超时时间,如果超过这个时间没有收到ACK,就会重新发送数据包,当然,重新发送后超时时间又会调整。
超时重传机制有一个问题,如果遇到乱序(reordering),后面的包先到了,由于中间的包没有到,超时重传机制会认为后面的包也没到。这时候有两种选择:

  • 只重传第一个超时的,死等ACK
  • 将所有的包重传

两种选择都不够好,第一种会导致传输慢,第二种是浪费带宽。

快速重传机制

为了解决超时重传的缺陷,TCP引入了快速重传机制(Fast Retransmit),简单讲,如果出现了乱序,就连续ACK三次第一个丢失的包,发送单连续收到3个ACK,明白这个包丢了,马上重传,而不是等待超时。

快速重传只是解决了不用等超时的问题,还是没解决“后面的包要不要重传”的问题。因为不能确认后面到底是哪几个包到了。

SACK机制

还有一种更好的机制叫SACK(Selective Acknowledgment), 就是在TCP Header中加入SACK,标记乱序时后面收到的包,比如(ACK6, SACK8, SACK9),发送方就清楚第7个包丢了。


问题6: 服务器处理不过来了, 你能发慢点吗?

我们前面讨论的都是怎么连接,怎么断开,丢包之后怎么重传的问题,其实还有一个重要的问题,如何保证我的网络处理程序达到一种状态:
在条件允许的情况下,以最快的速度发送和接受数据。

毕竟网络的性能是影响用户体验的重要因素,这里的“条件允许”指的是:

  • 接收端来得及处理
  • 不会造成网络拥堵(这个在问题7中讨论)

换句话说,这个问题就是如何做好数据发送量的控制, 让TCP在接收方有能力处理时,做到最大化的数据传输, 这个控制就是流量控制

TCP引入的机制是滑动窗口(Sliding Window),窗口的大小会根据运行状态变化,通过这个窗口的大小来决定当前可以发送多少数据,从而进行流量的控制。

如下图所示,对于发送端,黑框就是滑动窗口:


Figure-AdvertisedWindow

有了滑动窗口,发送端数据可以分为4部分:

  • (#1): 已发送且收到ACK
  • (#2): 已发送未收到ACK
  • (#3): 可发送还未发送
  • (#4): 不可以发送(大于窗口了,对方处理不过来)

窗口是看到了,那窗口的大小怎么改变呢?

回到这张熟悉的图:


TCP Header格式

TCP Header中有一个字段叫Window, 也叫AdvertisedWindow(滑动窗口), 接收方会在这个字段中传入窗口的大小,也就是自己的可以处理数据的最大值。就是说伴随着ACK包的接收,发送方的的窗口大小可能会不断改变,下图描述了接收端一步一步把发送端滑动窗口变为0的过程。

AdvertisedWindow Change Process

如果细心的话,会发现有很多细节的问题,比如:

滑动窗口变为0了,那是不是永远都不发包了?

当然不是,滑动窗口变为0之后,发送方还是会发几次特殊的包,这个包的用途就是问问接收方窗口有没有变化。

会不会有这样的情况: #3部分(上图Figure-AdvertisedWindow)为1个字节,滑动窗口不变,那是不是后面每个包都发一个字节?

这个问题叫"糊涂窗口综合症"(Silly Window Syndrome), 实际上IP Header + TCP Header 就有几十Byte, 每次只发送数据很少的包会很浪费带宽,最终也会导致很差的性能。
协议实现的过程中,这种细节非常多,各种实现中,统一的思路都是等到可发送的数据足够多时再进行发送。

关于滑动窗口说一句题外话,左耳朵说"不了解TCP的滑动窗口等于不了解TCP协议",作为一个很好的机制,滑动窗口作为一个很好的机制也被沿用到了QUIC(HTTP/3, based on UDP)中。


问题7: 好慢啊,网络卡了?

刚刚已经提到了,影响网络处理性能的因素中,除了两端的处理速度,还有网络中间的拥堵情况。

网络拥堵时,TCP能够知道的就是丢包增加,这个时候如果一味地重传会加重网络的拥堵,恶性循环下去,可能导致大面积的网络瘫痪。基于这一点的考虑,TCP在处理网络拥堵的时候,奉行了这样的思路: 发现拥堵,先出让自身的资源。考虑到TCP协议现在的流行程度,看得出TCP设计者的高瞻远瞩。

应对网络拥堵的具体处理过程,就是TCP 的拥塞控制

说到拥塞控制,要先介绍一个拥塞窗口(Congestion Window, cwnd)的概念, 与滑动窗口共同控制发送端的发送速度,具体的关系是已发送未接受数据量(上图Figure-AdvertisedWindow中#2部分)不可以大于cwnd。

TCP的拥塞控制主要用了下面几种策略:

1 慢启动(Slow Start)

慢启动的逻辑是:刚刚开始发送数据的连接,慢慢地提速到峰值,而不是一上来就拉到滑动窗口的最大值。这样能够尽可能地避免新加入网络的连接导致网络拥堵。

慢启动处理过程是:

  • 连接建立时,cwnd初始值为1(不同实现该值可能不同)
  • 收到一个ACK,cwnd+1 ( 一个包ACK后,可以发两个包;两个包都收到ACK后,就可以发四个包,指数增长)
  • 指数增长有上限,叫ssthresh(slow start threshold), 通常是65535Byte,cwnd超过ssthresh后,cwnd的变化会由"拥塞避免算法"处理。

我们看到,慢启动的过程只是为了不要一下子占满了带宽,如果网络状况好的话(快速ACK,一直不丢包),它的速度增长还是很快的,如果过程中遇到丢包,也会迅速降速(参考下面的3 拥塞状态算法),也不会造成网络的进一步拥堵。

这里有一点需要注意,cwnd在实际工作中是以Byte为单位,刚刚的规则中把cwnd设为1,这里的1不是指1个字节,为了理解的方便,这里指1个MSS(Maximum Segment Size 最大数据段尺寸, 数值上MSS = MTU(1500Byte)-IP Header-TCP Header),当前cwnd的大小是1个MSS的字节数,大约是1460Byte。

2 拥塞避免算法(Congestion Avoidance)

cwnd总不能永远指数增长,到了ssthresh后,就会降低增长速度,按照如下流程处理:

  • 收到一个ACK时,cwnd = cwnd + 1/cwnd

这样如果所有的数据都收到ACK,那么下一次请求书可以增加(1/cwnd*cwnd=)1,指数增长变成了线性增长,慢慢地增加到峰值。

3 拥塞状态算法

TCP能够检测到拥堵的方式是基于丢包,正如问题5,丢包时通常有两种情况:

超时重传

TCP会认为超时重传的情况比较严重,处理的方式非常极端:

  • sshthresh = cwnd /2
  • cwnd = 1
  • 重新进入慢启动过程

我们看到超时重传的场景,TCP对于拥塞的处理非常激进,上限减半,窗口变为最小,可谓"一超时回到解放前".

快速重传(Fast Retransmit)

前面也说过了,在收到3个duplicate ACK时开启了快速重传,此时cwnd会有如下变化:

  • cwnd = cwnd /2
  • sshthresh = cwnd
  • 进入“快速恢复算法”

不同场景,不同逻辑; 还没到超时就抽到了3个duplicate ACK,看来网络情况也没有那么糟糕,那就不用回到解放前了,降为一半就行了,关于快速恢复,见下文。

4 快速恢复算法(Fast Recovery)

快速恢复算法配合快速重传算法,基于这样的认知: 既然3个duplicate ACK都收到了,看来网络情况也没有那么糟糕,速度都已经减半了,可以以适当快的速度进行速度的恢复。
具体的算法如下:

  • cwnd = sshthresh + 3 * MSS
  • 重传Duplicated ACKs指定的数据包
  • 如果再收到 duplicated Acks,那么cwnd = cwnd +1(一个ack,窗口+1)
  • 如果收到了新的Ack,那么,cwnd = sshthresh ,然后进入拥塞避免的算法(一个ack,窗口加1/cwnd)

从这算法的过程我们发现,还是没有解决快速重传算法的问题:不知道丢了一个包还是多个包。于是按照只要收到一个Duplicate Acks就折半(cwnd减半,sshthresh=cwnd),这就导致多个包Duplicate Acks会导致cwnd指数级下降。理论上,已经折半了,就应该把这个区间内丢的包全部快速重传,基于这样的逻辑,后面快速恢复算法也进行了变更-TCP New Reno.

另外,对于支持SACK的连接,还可以使用FACK(Forward Acknowledgment)算法。


我们看到,TCP对于拥塞控制的核心依据是丢包,发现Duplicate Acks就折半,发现丢包就直接“回到解放前”,TCP这样设计的核心是基于当时的场景,认为丢包的原因就是网络拥堵。

这样的逻辑,有问题吗?

Reference

https://nmap.org/book/tcpip-ref.html
http://www.serverframework.com/asynchronousevents/2011/01/time-wait-and-its-design-implications-for-protocols-and-scalable-servers.html
https://coolshell.cn/articles/11564.html
https://coolshell.cn/articles/11609.html

你可能感兴趣的:(关于TCP的几个问题)