TCP详解
学习自 小林Coding 网络篇的记录
1. 预备知识
1.1 连接
在客户端与服务端传输数据之前,它们必须建立连接,基于一个连接才能进行数据传输。一个连接由四元组标识:源IP+源Port ——目的IP+目的Port。
理论上一台服务器一个端口能产生的最大连接数为 ,因为服务器目的IP+Port已经固定,能变化的只有客户端的源IP+Port。而IP地址在IP报头中有32位,Port在TCP报头中有16位,因此源IP+Port的组合有组。
但这只是理论上的分析,实际上服务器最大并发TCP连接数远远达不到理论值。因为一个TCP连接都是一个文件,如果文件描述符被占满,则创建不了连接;同时一个TCP连接都要占用一定内存,而操纵系统的内存是有限的。
1.2 可靠连接/无连接
TCP(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
面向连接
:TCP建立的是一对一连接,不像UDP可以一个主机同时向多个主机发送消息,也就是一对多。可靠的
:无论网络链路中出现了怎样的链路变化,TCP都可以保证一个报文一定能够到达接收端。基于字节流
:应用层消息可能被操作系统分组成多个TCP报文进行有序传输,当前一个TCP报文没有到达,即使接收端收到了后面的TCP报文,传输层也无法组合成一个完整的应用层消息。TCP确保能够组成一个完整的消息再交给应用层处理,且对重复的TCP报文会自动丢弃。
而建立一个可靠的TCP连接需要三要素作为支撑:(1) 套接字:IP+Port用于标识一个连接。(2) 序列号:用于标识一个TCP报文,解决乱序和丢包问题。(3) 窗口大小:用于做流量控制。
TCP和UDP的区别
UDP设计的目的非常简单,就是为了传输报文。它不保证传输数据包的完整性,不提供复杂的流量控制机制,仅仅在IP协议的基础上提供面向无连接的通信服务。
TCP和UDP的区别:
连接
:TCP是面向连接的传输层协议,传输数据前要先建立连接;UDP是无连接协议,即刻传输数据。服务对象
:TCP是一对一通信;UDP支持一对多,多对多交互通信。功能
:TCP保证应用层消息可靠传输,消息无差错完整交付,且具备流量控制功能;UDP尽最大努力发送,不保证消息完整性,且不会调整发送速率。分片
:TCP在传输层设计了分片机制MSS(Maxitum Segment Size),如果中途丢失了一个TCP分片,只需传输这个丢失的TCP分片即可;UDP没有在传输层设计分片机制,只能利用网络层分片机制,如果中途丢失了一个IP分片,则必须重新传输整个应用层消息。
TCP和UDP的应用场景:
可靠连接
:FTP文件传输,HTTP/HTTPS无连接
:处理逻辑简单,效率高。DNS、SNMP、直播、视频会议等多媒体通信。
2. 报文结构
TCP头部固定字段为20字节:
下面介绍头部一些重要字段:
序列号
:seq,数据包id,用于保证数据包传输顺序,解决包乱序问题。在首次建立TCP连接时,随SYN包发送给服务端,初始值为 X,随后每发送一个TCP包,seq累加本次发送数据包的字节数大小作为下一个包的序列号。(客户端发包的初始序列号为X,服务端发包的初始序列号为Y,两端数据包的序列号不存在联系)确认号
:ack,指下一次接收端期望收到数据包的序列号,解决丢包问题。发送端收到确认号后,可以认为这个确认号以前的数据包都被正常接收。如果确认号与将要发送数据包的序列号不一致,为了保证传输可靠性,将触发重传机制。控制位
:- SYN:该位为1,表示期待建立TCP连接。SYN包是建立TCP连接的第一个数据包。
- ACK:该位为1,表示确认号有效。TCP规定除了第一次建立连接时的SYN包中ACK为0外,该位必须设置为1。
- FIN:该位为1,表示希望断开连接,今后不会再有数据包发送。通常TCP连接断开时,通信双方的主机相互交换 FIN 为1的TCP数据包。
- RST:该位为1,表示TCP连接中出现异常必须强制断开连接。
3.★ 建立/断开连接
3.1 建立连接
TCP通过三次握手建立连接:
一开始,客户端和服务端都处于 closed
状态。先是服务器主动监听某个端口,处于 listen
状态。
- 1:客户端初始化一个SYN报文,SYN=1,seq=x(客户端初始化序列号)。接着把第一个SYN报文发送给服务端,向服务端请求TCP连接,之后客户端处于
syn_send
状态。 - 2:服务端接收到客户端的SYN报文后,初始化一个SYN响应报文,SYN=1,ACK=1,seq=y(服务端初始化序列号),ack=x+1。接着把SYN响应报文报送给客户端,之后服务端处于
syn_rcvd
状态。 - 3:客户端端收到服务端的SYN响应报文后,初始化一个应答报文,ACK=1,seq=x+1,ack=y+1。接着把应答报文发送给服务端,之后客户端处于
established
状态。
服务端收到客户端的应答报文后,也处于 established
状态。双方都处于 established 状态后就可以相互发送数据了。
注意:前两次握手报文体是空的,不能携带数据,第三次握手才可以携带数据。
为什么建立TCP连接需要三次握手?
比较基础的解释是 "至少要三次握手才能确保双方的接收和发送能力",这样解释是片面的,必须从建立TCP连接初衷说起:
之前说过建立TCP连接是为了保证数据报的可靠传输,而可靠传输依赖三要素:套接字,序列号,窗口大小。三次握手就是为了确认两端的起始序列号。此外,三次握手最重要的原因还是避免历史连接。
试想这样一个场景:客户端先发送了SYN报文(seq=100),随之客户端宕机了,而这个SYN报文还因为网络阻塞没有到达服务端。在客户端重启后,又重新向服务端建立连接,又发送一个SYN报文(seq=200)。
如果采用两次握手,情况如下:
在旧的SYN报文(seq=100)到达后,服务端立马回复一个SYN响应报文。同时进入 established 状态,认为可以传送数据,所以立马开始向客户端发数据。而客户端收到SYN响应报文发现 ack 号应该是 201,所以发送RST报文请求重新连接。直到收到RST报文,服务端才会停止发送数据。而二次握手导致了服务端建立无用历史连接,白白发送数据,浪费了服务端资源。
如果采用三次连接,可以避免这种情况:
由于服务端需要客户端第三次握手发送最后的应答报文,才会进入 established 状态发送数据,所以三次握手避免了服务端建立历史连接导致的资源浪费。
3.2 断开连接
TCP通过四次挥手断开连接:
一开始,客户端和服务端都处于 established
状态。
- 1:客户端初始化一个FIN报文,FIN=1,seq=u(此序列号后客户端不再发送数据)。接着调用内核close函数把第一个FIN报文发送给服务端,向服务端请求断开TCP连接。之后客户端处于
fin_wait_1
状态。 - 2:服务端收到客户端FIN报文后,初始化一个FIN响应报文,ACK=1,ack=u+1。接着把FIN响应报文发送给客户端。之后服务端处于
close_wait
状态,等待内核调用close函数关闭连接。 - 客户端收到服务端FIN响应报文后,进入
fin_wait_2
状态。等待服务端处理完最后数据。 - 3:服务端处理完最后要发送的数据后,初始化一个FIN报文,FIN=1,seq=w(此序列号后服务端不再发送数据),ack=u+1。接着调用内核close函数,把FIN报文发送给客户端,向客户端请求断开TCP连接。之后服务端处于
last_ack
状态。 - 4:客户端收到服务端的FIN报文后,初始化一个FIN响应报文,ACK=1,seq=u+1,ack=w+1。接着把FIN响应报文发送给服务端。之后客户端处于
time_wait
状态。 - 服务端收到客户端响应报文后,进入
closed
状态,至此服务端完成TCP连接关闭。客户端等待2MSL没有接收到服务端数据后,自动进入closed
状态,至此客户端完成TCP连接关闭。
为什么断开TCP连接需要四次挥手?
因为服务端不能立即断开连接,需要发送最后的数据。一开始,客户端断开连接需要 FIN+ACK 两次挥手确认不发送数据了,但是还能接收数据。待服务端处理完最后数据后,服务端断开连接也需要 FIN+ACK 两次挥手确认不发送数据。服务端处理最后数据产生的时间间隔,导致双方的挥手流程必须分开,所以最少需要四次挥手。
为什么客户端在发出第四次挥手的ACK包后,还要进入 time_wait
状态等待2MSL
等待2MSL是为了容错,确保连接被正常关闭。MSL即最长报文存活时间,大概为30s,如果报文30s之内没有被接收,则会认定被丢失。客户端发送最后一个ACK包时,只有服务端收到ACK包才能确保连接被关闭。一旦这个ACK被丢失,服务端会重传FIN包再次请求断开连接。确认ACK包被丢失需要1MSL,重传新的FIN包至发送端需要1MSL,所以等待2MSL的目的是一种容错机制,确保连接能被正常关闭。
4. ★ 重传机制
TCP实现可靠传输的方式之一,就是重传机制。TCP报文的序列号和确认号可以帮助发送方找到被丢弃的包。例如:发送端即将发送seq=101(报文体为10字节),111(报文体为20字节),131(报文体为30字节)的三个TCP报文。正常情况下接收端应该回应三个ack=111,131,161的确认包。如果第二个TCP报文中途丢失,则接收端只会回应两个ack=111的确认包,这表示接收端只连续接收了seq=111之前的所有TCP报文,第二个报文需要重传。
TCP的重传机制进行了一步步不断的完善:
超时重传
最简单的重传机制,当发送端每次发送一个TCP报文,都会设定一个定时器并等待对方的ACK应答报文。如果定时器走完还没有收到对方的应答报文,发送端就会重传该TCP报文,这就叫做超时重传。超时重传发生的情况有两种:TCP报文丢失和应答报文丢失。
超时重传机制存在一个核心问题:定时器时间 RTO(Retransmission Timeout)应该设置为多少?
RTO应该和一个正常数据包的往返时间 RTT(Round-Trip Time)有关,RTT指的是数据包发送时刻 到接收到确认时刻 的差值 。如果 RTO 设置的值远比 RTT 大,重发效率低下,会阻塞接收方应用层读取数据;如果 RTO设置的值比 RTT 小,会发生没必要的重传,增加网络负担。
所以,RTO 的值应该略大于报文往返时间 RTT 的值。但 , 本然就是两个变量,所以RTT也是一个动态变化的值。RTO的值也得跟着动态变化,且不好设置。
快速重传
为了解决重传时间难以设置的问题,快速重传机制出现了。它不以时间为驱动,而是以数据为驱动进行重传。快速重传机制:如果发送端接收到除原本的ACK确认包外,还接收到额外三个相同的ACK确认包,则会触发重传。举个例子:
上图中,发送端发出了1,2,3,4,5 份数据:
- 第一份 Seq1 先送到,于是 Ack 回 2;
- 结果 Seq2 因为某些原因没被接收方收到,此时没有 Ack回复;
- 后续 Seq3,Seq4,Seq5 都到了,由于Seq2还没到,接收方连续三个 Ack 回 2;
- 发送端收到了三个额外的 Ack = 2的确认,知道了 Seq2 还未被收到,就在定时器过期前,重传丢失的 Seq2;
- 最后,接收端收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了且被存在缓存区中,于是Ack 回 6。
快速重传机制解决了重传时间设置的问题,但它依然面临着另外一个问题:需要重传时,是重传一个数据包,还是重传所有数据包? 举个例子:
假设发送方发了6个数据包,序列号是Seq1~Seq6,但是Seq2 和 Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,会回复三个冗余的Ack2给发送方,从而开启发送方的快速重传机制。那么发送方是选择重传Seq2一个报文,还是重传Seq2之后的所有报文(Seq2、Seq3、Seq4、Seq5、Seq6)呢?
- 如果只重传Seq2一个报文,重传效率很低。因为对于丢失的Seq3报文,后续还得收到三个冗余的Ack3才能触发快速重传机制。
- 如果重传Seq2之后所有的报文,重传开销很大。因为对于Seq4、Seq5、Seq6已经在接收方缓存中,重传这三个包相当于做了一次无用功,浪费网络资源。
所以无论选择重传一个报文,还有重传之后所有报文,都存在弊端。
SACK
为了解决不知道重传哪些报文的问题,SACK(Selective Acknowledgment)选择性确认机制出现了。SACK还是基于快速重传机制的:通过在TCP可选字段头部加一个SACK字段,表示接收方已接收报文的编号,这样发送方就知道哪些数据包已被接收方获得,并选择性地重传丢失的报文。
如上图所示,接收方收到三个冗余的ACK=200的确认包后,知道seq=200以后的数据包有丢失,触发重传机制;接收方又通过 SACK=300~600知道只有200~299这段数据丢失,只选择属于这段数据的TCP报文进行重发。
D-SACK
上面SACK机制就是重传机制的完整内容,如果SACK的起始值比ACK的值要大,说明中间数据包丢失,需要选择性重传;如果SACK的终止值比ACK的值要小,说明接收方收到了重复的包,而SACK就指出了哪些数据包被接收方重复接收,这时我们称SACK字段为D-SACK(Duplicate SACK)。举一个例子说明其应用情况:
上图中,ACK数据包丢失导致发送方对3000~3499的TCP报文进行重传,接收方已经接收到完整的3000~3999的两个TCP报文,所以回复ACK=4000应答包,同时利用SACK=3000~3500说明重复接受了此TCP报文段。
5. ★ 流量控制
5.1 滑动窗口
等停ARQ协议
ARQ(Automatic Repeat Request)自动重传请求协议通常是保证网络传输可靠性协议的统称,这种协议的特点是重传数据包是发送方自动进行的,接受方不需要请求发送方重传某个出错的分组。
等停ARQ协议就是典型的 “问答” 模式协议,发送方每发送一个数据包,都得在收到接收方的确认包后,才会继续发送下一个数据包:
这种协议工作模式简单,但串行的工作方式很容易阻塞传输进度,导致传输效率低下。
连续ARQ协议
连续ARQ协议引入了滑动窗口的概念,发送方会维持一个固定大小的滑动窗口,提出滑动窗口是为了实现并行:在窗口内的数据包可以同时连续发送,而无需等待接受方的确认。一旦发送方接收到接收方的累计确认包,就会滑动发送窗口继续并行发送数据包。
这种协议提出了滑动窗口的思路,但滑动窗口大小固定,难以应对复杂的网络环境的变化。
可变滑动窗口
TCP改进了传统的滑动窗口方案,在固定头部加入 window
字段描述窗口大小,从而实现基于可变滑动窗口的流量控制协议。
TCP协议规定发送方和接收方各有一个滑动窗口,实际上是操作系统开辟的一个缓存空间。双方的滑动窗口大小由接收方控制:接收方在给发送方的TCP报文头中加入 windows 字段,告诉发送方自己还有多少缓存区可以接收数据,确定了接收方滑动窗口的大小;发送方收到 windows 字段后,将自己的滑动窗口大小调整至与接收方一致,根据接收方的处理能力来发送数据,而不会导致接收方处理不过来。
发送方窗口:
上图是发送方的缓存区,深蓝色部分是整个发送窗口,紫色部分是可用窗口。整个缓存区以字节为最小单位对数据进行划分,分为四个部分。
上图为发送方将剩余数据全部发出,可用窗口大小变为0,这表示可用窗口耗尽,在没收到接收方的ACK确认报文之前无法继续发送数据。
上图为发送方收到接收方的ACK确认报文,确认之前发送的32~36字节的数据已经被接收,且发送窗口大小不变。则发送方将发送窗口向右移动5个字节,可用窗口变为5,表示可以继续发送52~56这5个字节数据。
程序用三个指针即可完整表示一个发送窗口:
SND.UNA
:发送窗口的起始指针,可以滑动发送窗口。SND.NXT
:可用窗口的起始指针,可以滑动可用窗口。SND.WND
:发送窗口的大小(由接收方指定)。
接收窗口:
上图是接收方的缓存区,深蓝色部分是整个接收窗口。整个缓存区以字节为最小单位对数据进行划分,分为三个部分。当连续的字节数被缓存区接收时,接收窗口会在调整大小后向右滑动。
程序用两个指针即可完整表示一个接收窗口:
RCV.NXT
:接收窗口的起始指针,可以滑动接收窗口。RCV.WND
:接收窗口的大小(通知给发送方)。
📌接收窗口和发送窗口的大小并不是完全相等的,而是约等于关系。 因为网络延迟,接收窗口的变化并不能立即同步给发送方让其调整发送窗口大小。比如,当接收方的应用进程读取数据速度非常快的话,接收窗口的大小会立即进行调整,接收端通过ACK响应报文中的 windows 字段告诉发送方做出相应调整。网络延时导致发送窗口大小的调整会慢于接收窗口,所以它们的大小是约等于关系。
5.2 流量控制算法
TCP利用 序列号
和 确认号
实现了无差错传输,保证了数据包的完整性。
但试想如果发送方无脑地、以固定速度地发送数据给接收方,一旦接收方处理数据能力不足,不得不拒绝一些已经到达的数据包。那么就会触发重传机制,从而导致网络流量的无端浪费。
因此,TCP提供了流量控制机制:TCP的流量控制使得【接收方】可以根据自身的实际接收能力来控制【发送方】的发送速率,从而避免网络资源的浪费。
下面用一个实际场景解释流量控制算法:
上图发送方为客户端,接收方为服务端。一开始,发送窗口和接收窗口大小都为360。:
第一次交互
- 客户端发送140字节数据,可用窗口变为220
- 服务端收到140字节数据,但服务器十分繁忙,应用进程只读取了40字节数据,剩余100字节占用着缓冲区,于是将接受窗口收缩到了260(360-100),最后发送确认包时,将窗口大小变化通知给客户端
- 客户端收到确认包后,滑动发送窗口并将发送窗口同样收缩到260
第二次交互
- 客户端发送180字节数据,可用窗口变为80
- 服务端收到180字节数据,但服务端依然繁忙,应用程序在传输间隙读取上次剩余100字节后不再读取,这180字节占用着缓冲区,于是接收窗口收缩到了80(260-180),最后发送确认包时,将窗口大小变化通知给客户端
- 客户端收到确认包后,滑动发送窗口并将发送窗口同样收缩到80
第三次交互
- 客户端发送80字节数据,可用窗口变为0
- 服务端收到180字节数据,但服务端依然繁忙,应用程序没有读取任何数据,这80字节占用着缓冲区,于是接收窗口收缩到了0(80-80),最后发送确认包时,将窗口大小变化通知给客户端
- 客户端收到确认包后,滑动发送窗口并将发送窗口同样收缩到0
上面展示了由于接收方处理数据不过来,导致发送方发送窗口逐渐变小,直至关闭发送窗口。正常情况下,只要接收方处理数据及时,接收窗口能够保持大小不变并快速滑动,从而发送窗口大小也不会改变。
所以流量控制机制是接收方通过TCP报头的windows字段,根据自身数据处理能力调节发送方的发送速率,避免造成网络资源浪费。但是流量控制算法也存在以下两个问题需要解决:
问题1:窗口关闭
根据上图描述,当接收方处理不了数据时,会将接收窗口关闭;同时,发送窗口也会随之关闭。等接收方处理完缓冲区内的数据后,会将接收窗口打开;同时发送一个ACK确认包带上字段 windows=非0
,通知发送方将发送窗口打开进行数据传输。
如果这个ACK确认包在网络中丢失了,会造成发送窗口永远无法打开的危害:
为了解决这个问题,每个TCP连接都设有一个持续定时器,只要TCP连接的发送窗口关闭,该持续定时器就会被启动。一旦在规定时间内没有收到发送方重开窗口的ACK确认包,发送方就会发送窗口探测报文,请求打开发送窗口。
如果发送方发送窗口探测报文后还没有收到接收方的ACK确认包,就会重新进行计时。一旦发送方发出窗口探测报文超过 3 次,那么它就会发送 RST
报文来中断本次TCP连接。
问题2:糊涂窗口综合征
如果接收方的应用进程读取数据的速度 < 接受方接收数据的速度,那么就会造成接收窗口越来越小,同时导致发送窗口也越来越小。到最后,如果接收窗口大小只有几个字节并告知发送方,而发送方会义无反顾地发送这几字节的数据。这就是糊涂窗口综合征。
糊涂窗口综合征会导致网络资源浪费。要知道,光 TCP+IP 头部至少有固定40字节,为了传输那么几个字节数据,要搭上这么大的开销,显然是不划算的。以下是糊涂窗口综合症的场景举例:
接收窗口和发送窗口起始大小都是360字节,接收方处理数据的速度跟不上接收数据的速度,假设接收方的应用层的读取能力如下:
- 接收方处理数据的速度是接收数据的 , 即接收方每接收3个字节,应用程序就只能从缓冲区读取1个字节数据
- 在下一个发送方的TCP段到达之前的这段网络时延,应用程序还能从缓冲区读取额外40字节的数据
上图接收方的接收窗口越来越小,发送窗口也跟着越来越小,出现了糊涂窗口综合症。导致这种现象的原因有两个:
- 接收方可以通知发送方调到一个小窗口
- 发送方频繁发送小数据包
于是,要解决糊涂窗口综合征,同时解决以上两个问题就可以:
- 让接收方不通知小窗口调整消息给发送方
- 发送方尽可能避免发送小数据包
接收方的优化:
当【接收窗口大小】小于 min(MSS,缓存空间/2)
时,也就是小于MSS与缓存一半大小之间的最小值时,会直接通知发送方将发送窗口大小调为 0
,从而避免了发送窗口出现小窗口现象。等接收方应用层处理了一些数据后,窗口大小 >= MSS 或者 接收方缓存空间超过一半可以用,就把窗口打开让发送方发送数据过来。
发送方优化:
发送方采用 Nagle 算法,核心思想是延迟发送TCP报文,而收到ACK包就立即发送。发送数据包的条件有两个:
- 条件一:发送窗口大小 >= MSS 且 可发送数据大小 >=MSS;即可发送数据超过MSS就发送。
- 条件二:收到前面数据的ACK确认包才可发送;即如果可发送数据小于MSS,只有前面的数据都被接收,才能发送。
只要上面两个条件都不满足,就会延迟发送方发送数据,直到满足二者之一
Nagle的伪代码:
if 有数据要发送 {
if 可用窗口大小 >= MSS and 可发送的数据 >= MSS {
立刻发送MSS大小的数据
} else {
if 有未确认的数据 {
将数据放入缓存等待接收ACK
} else {
立刻发送数据
}
}
}
总结来说就是:发送窗口内的MSS段可以直接发送(成本低);小于MSS的TCP报文(成本高),必须等前面的报文全部确认接收才能发送。所以,ack回包也不能回复太快,这样发送方还是会不停发送小数据包,最好发送窗口内的数据刚好凑成一个MSS段(进行粘包),这样发送性价比才最高。
6. ★ 拥塞控制
流量控制是为了协商一个TCP连接发送方的发送速率。
但是多个TCP连接都处在一个共享的网络环境,多个发送方之间发送的数据包全都通过一个网络信道传输,那么发送方之间就会相互影响,从而影响整个网络的传输性能。在网络出现拥堵时,如果发送方继续发送大量数据包,可能会导致数据包时延、丢失等,这时TCP就会重传数据,但是一旦重传会导致网络负担更重,于是会导致更大的延迟以及更多的丢包,这种情况就会进入恶性循环不断放大... 于是,TCP设计了拥塞控制,通过观察网络的通信质量调整发送方的发送速率,从而避免网络拥塞。
拥塞控制是为了协商一个网络中,多个TCP连接发送端的发送速率。
流量控制是根据一个TCP连接中接收方处理数据的能力调整对应发送方的发送速率,而拥塞控制是根据一个网络中的拥塞情况调整所有发送方的发送速率。
拥塞窗口
拥塞窗口 cwnd
是发送方维护的一个状态变量,它会根据网络的拥塞程度进行动态变化。
在流量控制中有发送窗口 swnd
,每台主机在一个TCP连接中的发送窗口大小随着对应接收方的接收窗口rwnd
大小的变化而变化。由于TCP又加入了拥塞控制,加入了拥塞窗口的概念后,此时发送窗口的值是:
也就是拥塞窗口和接收窗口中的最小值。
只要网络中没有出现拥塞,cwnd就会变大;一旦网络中出现拥塞,cwnd就会变小。那么发送方如何知道当前网络中出现拥塞呢?只要发送方没有在规定时间内收到ACK确认包,或者收到三个冗余的ACK确认包,也就是发生了超时重传或快速重传,就会认为网络中出现了拥塞。
6.1 慢启动
TCP在刚建立完连接后,首先有一个慢启动过程。慢启动是一点一点提高 cwnd
的大小,如果一上来发送方就发大量数据包,这不是给网络添堵吗?
慢启动的规则是 cwnd 指数级增长,直到长到一个阈值 ssthresh
。如下图所示:
假设发送窗口大小不受流量控制影响,那么发送窗口 swnd
和 拥塞窗口 cwnd
就是恒等关系:
-
建立完连接后, 初始化
cwnd = 1
,这里的 1 不是指一个字节,而是一个MSS
的字节数 -
发送方收到一个ACK确认包后,cwnd 增加1,于是下一轮能发2个
-
发送方收到两个ACK确认包后,cwnd 增加2,于是下一轮能发4个
-
发送方收到四个ACK确认包后,cwnd 增加4,于是下一轮能发8个
... 一直指数级增长到门限,ssthresh 的大小一般是 65535字节,MSS的大小是双方协商的一般是 1460字节,所以ssthresh 的值大概是44个。这里我们为了解释方便,假设
cwnd = 8
。
6.2 拥塞避免算法
当 cwnd >= ssthresh,便会启动拥塞避免算法,拥塞避免的规则是 cwnd 线性增长,直到网络中发生拥塞。如下图所示:
-
发送方收到八个ACK确认包后,cwnd增加1,于是下一轮能发9个
-
发送方收到九个ACK确认包后,cwnd增加1,于是下一轮能发10个
... 一直线性增长直到网络发生拥塞。
6.3 拥塞发生算法
网络发生拥塞即发送方进行了重传,而重传有两种机制:超时重传和快速重传。超时重传是定时器走完,还没有收到ACK确认包;快速重传是定时器还没走完,却收到了三个冗余的ACK确认包,它们都会将数据包进行重传并重置定时器。相比较来说,发生超时重传说明网络的拥塞情况更严重,至少快速重传还能接收到三个冗余的ACK确认包,而超时重传一个都接收不到。
所以针对不同的拥塞原因,TCP也会采用不同的拥塞控制算法。拥塞发生算法对应的是超时重传。一旦发生超时重传,cwnd 和 ssthresh 的值就会进行调整:
- ssthresh 设置为
当前cwnd/2
- cwnd 重置为
1
(是恢复cwnd的初始值,因为cwnd在慢启动中初始值假设为1)
如下图所示:
ssthresh = 当前cwnd/2 = 12/2 =6,cwnd =1。设置好值后重新开启慢启动。所以TCP对于超时重传采用的拥塞控制十分严格,一旦发生【超时重传】就一夜回到解放前,所以这时发送速率像急转弯一样变化,用户会明显感觉到卡顿后恢复。
6.4 快速恢复
对于前面介绍过的【快速重传算法】。当发送方收到三个冗余的相同ACK确认包时,会将数据包快速重传并重置定时器,而不必等待超时再重传。这种情况也侧面说明,网络的拥塞状况并不是很严重。快速恢复对应的是快速重传。一旦发生快速重传,cwnd 和 ssthresh 的值就会进行调整:
- ssthresh =
当前cwnd/2
- cwnd = cwnd/2,变为原来值的一半
在对值进行调整后,开始快速恢复算法:
- 1:cwnd = ssthresh + 3 (因为收到了三个冗余ACK确认包,所以加3)
- 2:重传丢失的数据包
- 3:如果再收到第四个冗余的ACK,那么cwnd 增加1,第五个冗余的ACK,cwnd 增加1,... 直到该数据包被确认重传成功
- 4:如果重传成功,收到新的 ACK确认包后, 重新设置cwnd为第1步的ssthresh,cwnd = ssthresh。同时再次进入拥塞避免状态,呈线性增长趋势。
如下图所示:
ssthresh = 发生快速重传时的cwnd / 2 = 12 / 2 = 6;开始快速恢复算法 cwnd = ssthresh + 3 = 6 + 3 = 9,重传丢失的数据包,并保持线性增长;丢失数据包重传成功后,cwnd = ssthresh = 6,进入拥塞避免状态开始线性增长。
通过图像可以看出,快速恢复并不像拥塞避免算法那样,cwnd 变化剧烈。
📌快速恢复算法,为什么不直接进入拥塞避免状态,中间多了一个重传丢失数据包阶段(上图红色部分)? 增加这么一个阶段的目的是为了尽快将丢失的数据包发送给目标,从而解决导致拥塞的根本问题(三次相同的ACK确认包导致的快速重传),所以这个阶段的cwnd反而是不断增大的。 而拥塞发生算法则没有这个优化阶段,试想:网络中发生超时重传,发送方启动拥塞发生算法直接进入慢启动阶段。而慢启动阶段一开始只发送1个数据包,这个包必然是需要重传的丢失数据包,如果这个数据包再次被丢失,发送端没有收到ACK确认包,cwnd 也不会进行指数增长,从而影响了cwnd的恢复过程。
7. 保活机制
保活机制用于检测一个TCP连接是否有效。TCP连接一般是通信两端进行四次挥手后就会断开,如果TCP连接没有正常断开或该连接上长时间没有数据传输,就会触发保活机制探测TCP连接是否正常。
发送方会定义一个时间段,在这个时间段内,如果一个TCP连接上没有任何数据传输,TCP保活机制就会启动。每隔一个时间间隔,发送方会发送一个【心跳包】,如果连续几个心跳包都没有得到相应,则发送方认为当前的TCP连接已经死亡,将连接主动关闭。
以Linux内核为例,它可以设置保活时间、心跳包的探测次数,心跳包的时间间隔:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
tcp_keepalive_time = 7200
:保活时间是7200s(2小时),如果2小时内没有任何数据传输,启动保活机制。tcp_keepalive_probes = 9
:心跳包传输次数,如果发送9个心跳包还无响应,则认为对方是不可达的,从而中断本次连接。tcp_keepalive_intvl = 75
:心跳包的重传时间间隔为75s。
所以在Linux系统中,如果2个小时没有数据传输,且9次心跳包之后没有回应,则系统才能发现一个死亡的TCP连接。发现一个死亡的TCP连接的总耗时是2个小时11分15秒。
8. 面向字节流
UDP是【面向报文流】协议,而TCP是【面向字节流】协议。之所以这么说,是因为操作系统对 TCP和 UDP 协议规定发送方的发送机制不同。
面向报文流
当应用层消息通过UDP协议进行传输时,操作系统不会对其进行拆分,直接将一个完整的消息加上UDP报头丢给网络层进行处理,所以接收方接收到一个完整的UDP报文即是一个完整的应用层消息。
如果接收到两个以上UDP报文,接收方的操作系统会用一个队列进行维护,队列里的每一个元素就是一个UDP报文。应用程序每次读取队列内的一个元素即可读取一个完整的应用层消息。
面向字节流
当应用层消息通过TCP协议进行传输时,操作系统会对其进行拆分,MSS即为一个TCP最大报文段,即一个应用层消息可能被拆分成多个MSS段进行传输,所以接收方收到一个完整的TCP报文不一定是一个完整的应用层消息。
因为TCP传输的一个MSS段无法对应一个应用层消息,即没有明确的消息边界;所以只能说TCP以连续的字节数进行传输,也即是【面向字节流】传输。
粘包/拆包
举一个例子说明:假设发送方准备发送 Hi. 和 I am Xiaolin 这两个应用层消息,接着这两个消息按顺序写入发送缓冲区中。由于TCP发送报文根据 发送窗口=min(接收窗口,拥塞窗口)
,发送窗口的大小受网络环境、接收方处理数据能力影响,所以实际的发送情况可能是如下几种:
- 两个消息被分到同一个TCP报文中(<=MSS),多个应用层消息拼接到一个TCP报文中发送叫做粘包,像这样:
- I am Xiaolin 的一部分拼接至第一个消息 Hi 中随一个TCP报文发送(<=MSS),一个应用层消息被拆分成多个TCP报文发送叫做拆包, 像这样:
- Hi的一部分被拼接至第二个消息中随一个TCP报文发送(<=MSS),像这样:
无论 粘包
还是 拆包
,都是为了满足TCP的分段机制而对一个完整的应用层消息进行拆分,所以解决粘包/拆包 问题是 通过设置每个应用层消息的边界:
- 在应用层固定每个消息的长度。假设应用层协议每个消息固定为40字节;那么每从缓冲区中读取40字节就能得到一个应用层消息,固定长度就是应用层消息的边界。
- 在应用层插入特殊字符作为边界。假设应用层协议每个消息尾部都会加一个特殊字符;那么每从缓冲区中读到一个特殊字符就能得到一个应用层消息,特殊字符就是应用层消息的边界。
- 在应用层插入消息长度字段。假设应用层协议每个消息头部都会加一个固定长度字段(假设4字节)描述该消息长度;那么首先从缓冲区读取前4字节获取到当前消息长度,并表示开始读取一个新的应用层消息,当读取到对应长度数据时就能得到一个应用层消息,消息长度字段就是应用层消息的边界。
9. 缺点
队头阻塞
TCP因为必须保证收到的字节数据是完整且有序的,如果序列号较低的TCP段在网络传输中丢失,即使序列号较高的TCP段已经被接收了,应用层也无法从缓冲区读取到这部分数据。
HTTP/2中采用多路复用技术,多个HTTP流在一个TCP连接上进行并发传输。假设有stream1,stream2,stream3三个HTTP流,且stream2的一个frame丢失;由于TCP的队头阻塞,即使 stream3的所有数据在接收方缓冲区中,因为stream2被丢失的那个frame的序列号较低,应用层也必须等待这个frame重传成功才能完整读取stream3。
升级困难
TCP协议是在内核中实现的,应用程序只能使用不能修改,如果想升级TCP协议,那么只能升级内核。而升级内核是很麻烦的,因为内核是应用程序的底层支撑,一旦内核版本升级,就必须考虑所有应用程序是否兼容新的内核版本,所以内核的升级比较保守和缓慢。
本文是学习过程中记录,文章图片转载至 小林Coding,如涉及著作权限问题,可联系作者下架删除文章