tcp1

本文深入探讨TCP协议,包括如何在不可靠网络中实现可靠传输、TCP连接的三次握手和四次挥手过程、丢包重传机制、流量控制以及拥塞控制。通过理解TCPHeader中的关键字段,如SequenceNumber和AcknowledgmentNumber,解释了TCP如何处理乱序和丢包。此外,还讨论了TCP状态机、滑动窗口和拥塞窗口的概念,以及它们在确保数据传输效率和网络稳定性中的作用。
摘要由CSDN通过智能技术生成

今天聊聊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建立连接的含义是: 两端的设备创建了一些数据结构(socket),这些数据结构包含了对方的信息(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可以主动发送数据了。

 

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

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

先给答案: 两次真不行,四次(指的是第二次握手分成两部分第一部分对客户端发送来的连接请求表示确ACK=1(确认SYN=1)、ack=seq+1(表示seq已经收到你可以从缓冲区移除了),第二部分是表示自己想要建立连接(SYN=1),并且自己的seq=随机数)不划算

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

  1. 确认对方是可以连通的, 也愿意让我连(我发送的包,收到了对方的ACK)

  2. 确认对方已经知道了我的初始序列号(ISN),后面我发送的数据包seq与这个值有关系的

如果改成两次,就不能确认A已经知道了B的ISN。(只有两次握手如果A收到的B发送过来的seq序列号不对(它不知道对不对),他也会建立连接,如果建立了连接,那么如果b发送第二次握手的过程中seq=1,但a收到的是seq=5,那么正式传输信息时,发送seq=2就会出错,a会把小于5的部分丢弃掉,如果有了第三次握手的过程,a回给b会送一个ack=5+1,b就知道a收到了错误的信息)

 

两次握手主要的原因有两个:防止失效连接,同步正确的序列号

如果改成四次,是可以知道对方收到了(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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值