目录
TCP协议
TCP协议段格式
六个标志位
确认应答机制(ACK)
超时重传机制
连接管理机制
滑动窗口
延迟应答
捎带应答
流量控制
拥塞控制
TCP总结
拓展
面向字节流
粘包问题
TCP异常情况
UDP实现可靠传输
TCP协议
TCP全称为“传输控制协议(Transmission Control Protocol)”,TCP协议是当今互联网当中使用最为广泛的传输层协议,没有之一。
TCP协议被广泛应用,其根本原因就是提供了详尽的可靠性保证,基于TCP的上层应用非常多,比如HTTP、HTTPS、FTP、SSH等,甚至MySQL底层使用的也是TCP。
怎么理解传输控制协议中的控制?
在双方的操作系统内部,每建立个链接,在操作系统内部,就要建立一个发送缓冲区,一个接收缓冲区。在应用层的时候,是要定义一些缓冲区来接收用户的输入,咱自己定义的就叫做用户级缓冲区
当我们在调用write,read,recv,send...接口的时候,本质上并不是把数据发送到网络当时中,本质是将数据从用户缓冲区拷贝到TCP的发送缓冲区中(拷贝函数)
什么是控制呢?(数据什么时候发送,发送多少,出错了怎么办?缓冲区并不能决定你什么时候发,完全由TCP协议完全决定。这其实就是控制)
为什么要有发送缓冲区呢
如果对方来不及接收了,你怎么知道呢,你不知道。当用户一直写,因为有缓冲区的存在,对于应用层来讲,可能并不影响应用层向操作系统继续拷贝,数据拷贝多了,缓冲区可能快慢了,操作系统就会强迫对方,让对方去接收
TCP协议段格式
TCP如何将报头与有效载荷进行分离?
4位首部长度 - 表示的是报头的总长度是多少 但是我们知道报头标准长度是20字节呀,写20不行吗?为什么还要有4位首部长度呢?因为我们还有选项。4位首部长度描述的是报头的总长度
TCP协议段格式与UDP协议段格式相比来看是稍微复杂些的,TCP的报头并没有像UDP那样有固定的长度,除去选项报头的定长是20字节,报头的真实大小需要通过4位首部长度来确定,而首部长度只有4位,但它的取值绝对不是0000->1111(0->15),必然是要乘上它的单位,不然长度远远不够,其实 首部长度的单位是:4字节 它的真实长度也就是[0->60],所以选项最多是40个字节!
如果标准长度是20字节,那么4位首部长度就是0101(x*4 = 20 x = 5)
因此TCP是这样分离报头与有效载荷的:
- 当TCP获取到一个报文后,首先读取报文的前20个字节,并从中提取出4位首部长度,此时便获得了TCP报头的大小size。
-
如果size的值大于20字节,则需要继续从报文当中读取s i z e − 20字节的数据,这部分数据就是TCP报头当中的选项字段。
- 读取完TCP的基本报头和选项字段后,剩下的就是有效载荷了。
什么是16位窗口大小?
如果应用层因为一些原因,暂时不调用read,resive接口,也就不进行读取数据。发送方一直给对方发送消息。客户端和服务器在通信的时候,是不知道服务端的接受能力的。所以我们的客户端一直给服务器发消息的时候,最终可能导致服务器不能及时接收 导致缓冲区被写满了。一旦被写满客户端也不知道,那就继续发呗。最后就直接导致接收缓冲区没空间了,进而导致大面积丢包的情况,而TCP通信的特点是可靠性的,丢包就属于不可靠了。所以我们在发送和接收数据时,我们要流量控制。所谓流量控制就是发送方要知道接收方的接受能力,而我们的16位窗口大小就是用来流量控制的字段,里面填的就是剩余空间的大小
但是我们知道TCP的可靠性有一种叫做丢包重传/超时重传,如果丢包了,那么服务器是可以进行补发的。就好比你买了个快递 你说你没收到。对方说:行!我给你再补发一个。实际上站在TCP角度来想,TCP害怕不害怕丢包呢?答案是不害怕的,因为可以重传呀。那为什么还要做流量控制呢
当缓冲区无法接收的时候,可能会导致正常报文被直接丢弃,虽然你有可靠机制会补发。但这样明显是不合理的。因为我一个报文千里迢迢消耗了网络里的带宽资源,路由资源,到了你这里。你竟然在没有明显的错误情况下告诉我,要把我丢弃掉,这不合理。凡是浪费效率就不会高
TCP如何决定将有效载荷交付给上层的哪一个协议?
应用层的每一个网络进程都必须绑定一个端口号。
服务端进程必须显示绑定一个端口号。
客户端进程由系统动态绑定一个端口号。
而TCP的报头中涵盖了目的端口号,因此TCP可以提取出报头中的目的端口号,找到对应的应用层进程,进而将有效载荷交给对应的应用层进程进行处理。
说明一下: 内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程ID,进而找到对应的应用层进程。
TCP凭什么保证可靠性?
最基本的一个特点叫做:确认应答机制(ACK)
为了更好地理解TCP确认应答机制的工作原理,我们先来看一个简单的例子:
- 假设你在邮寄一个包裹给朋友小明
- 小明收到包裹后,给你打电话确认:“我收到了你的包裹!”
- 你知道小明收到了包裹,你就不再担心是否丢失。如果你没有收到小明的确认电话,你会重新寄一次包裹,确保包裹能送到他手中
只有对方回应了你的话,你才能确认对方一定接收到了你的信息
那么客户端怎么知道,对方剩余接收缓冲区剩余空间的大小呢?别忘了,他俩在互发消息的时候,发送的可是完整的TCP报文,即一定携带完整的TCP报头。所以发一条消息是会有响应的,当收到了响应再发第二条,而响应会把报头发过来,报头中带有16位窗口大小,填充的就是窗口大小,即服务端接收缓冲区剩余大小。然后客户端再进行判断发送速度
未来有没有可能服务器反过来给客户端发消息呢?我们知道,TCP是全双工的(通信双方可以同时进行发送和接收数据,而不会互相干扰),所以也要进行流量控制。
16位窗口大小填写的是自己的接收缓冲区中剩余空间的大小
确认应答
举个例子
站在小明的角度,能保证收到了小红的应答,即小明给小红发的"我们去吃饭吧"已经被小红收到了
站在小红的角度,她怎么知道她说的"好的"被小明收到了呢?她是不确定的
无论这样反复N次,我们都能得到个结论即 最新的一条消息是没有应答的
站在个人角度,只要我收到了应答,那么我最近发送的一次消息对方是收到了。但是没有应答的数据,我们无法保证可靠性。
因为最新的一条消息是没有应答的 所以我们无法保证发出去的消息是100%可靠的
但是收到应答的消息就能保证可靠性
1,2,3均能保证可靠性,4是最新的消息,没有应答无法保证可靠性
最开始主机A在给主机B发消息,所以在这个过程中最重要的是,A要保证被B收到了。A是主动的,B是被动的,只有保证B收到了,就保证了客户端(A)到服务器(B)上的可靠性。就没必要确认B的应答有没有被收到了 同样的,当B给A发消息,只要保证A收到了消息,就也保证了服务器(B)到客户端(A)的可靠性 也就保证了双方的可靠性
总结论:
但是这种一问一答的方式效率比较低下,在日常生活中,当有人问你:"中午了,我们去吃饭吧",你通常会回答:"好的,我们吃什么呀?"也就是 应答 + TCP数据 一起发回
那么客户端时怎么收到应答的呢?
客户端 一段时间,如果没有收到应答,客户端就认为数据丢失了,就会重传了
客户端再把消息发送到对方的时候,在没有收到应答之前,客户端要把数据暂时的维护一段时间
TCP最原始的通信方式,就是你发一条,我发一条 这样的工作效率太低了
我们的客户端可能向服务器一次发送多条消息,服务器也对每条消息都做应答
这个是TCP工作中,常规的应答方式
客户端把数据按照一定的顺序发出,服务端是否按照一定的顺序收到呢?
顺序是不一定的。因为每个数据在路上路由的时候走的路径是不一样的,甚至可能刚发路由器就坏掉了,发第二个第三个的时候又好了。这个问题就是数据包乱序问题。乱序本身就是不可靠的一种。但是TCP是可靠性的,这个问题该怎么解决呢?
因此发过去的数据都是携带完整报文的,每一个报头都是携带了一个32位序号。因此TCP报头中的32位序号的作用之一实际就是保证数据的按序到达
如果接收到的数据包是乱序的(即序列号不匹配),接收方会将这个数据包缓存起来,并不会立即丢弃它。缓存的目的是为了等待缺失的数据包。当接收方收到的下一个数据包是期望的序列号时,它会开始将缓存中的数据包按照正确的顺序进行重组。
什么是32位序号和32位确认序号?
序号是什么呢?
TCP将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。
TCP发送的时候也不会按照字节去发,太慢了。所以发送是按照一个一个的数据块发送的,每一个数据块都要有序号,这个序号是按照整个数组当中最后一个元素的下标作为序号来发送的
- 比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。
- 此时这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001和2001。
确认序号是接收方用来告诉发送方自己已经成功接收到了哪些字节。它不是直接指向接收到的字节,而是指向下一个期望接收的字节的序号。
可是万一丢包了呢?我怎么知道哪些报文是被对方收到了呢,32位确认序号又是什么意思呢?
我们发送数据的时候是多组发的,收到的响应也是同时收到一大批应答,那我们怎么知道每个应答是对应哪个数据的呢?
TCP为了区分响应,就有了确认信号的概念。确认序号,填充的是,收到报文的序号+1
以刚才的例子为例,当主机B收到主机A发送过来的32位序号为1的报文时,由于该报文当中包含1000字节的数据,因此主机B已经收到序列号为1-1000的字节数据,于是主机B发给主机A的响应数据的报头当中的32位确认序号的值就会填成1001。
为什么要进行序号+1呢?确认序号的意义是什么呢?
- 一方面是告诉主机A,序列号在1001之前的字节数据我已经收到了。另一方面是告诉主机A,下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
- 允许少量的部分丢失,就比如说当你收到了3001的确认序号,就可以认为3001之前的数据已经全部收到了
为什么不直接复用32位序号呢?因为请求你是有效载荷+报头。应答也是新的报头+有效载荷呀。我复用一下不行吗?
举个例子
假设客户端发送的请求包包含: 服务器接收到请求后,发送应答包:
- 序列号
Seq=1000
(请求的序列号) 序列号Seq=1001
(应答的序列号) - 有效载荷
data1
有效载荷data2
回答
如果你直接复用相同的序列号空间(例如 Seq=1000
作为请求和应答的序列号),而且这些请求和应答可能是并行的,就是客户端在给服务端发消息,服务端也在给客户端发消息。接收方就无法知道哪个序列号是请求的哪个是应答的。所以就要有序号和确认序号
六个标志位
为什么要存在六个标志位?
- TCP报文的种类多种多样,除了正常通信时发送的普通报文,还有建立连接时发送的请求建立连接的报文,以及断开连接时发送的断开连接的报文等等。
- 收到不同种类的报文时完美需要对应执行动作,比如正常通信的报文需要放到接收缓冲区当中等待上层应用进行读取,而建立和断开连接的报文本质不是交给用户处理的,而是需要让操作系统在TCP层执行对应的握手和挥手动作。
也就是说不同种类的报文对应的是不同的处理逻辑,所以我们要能够区分报文的种类。而TCP就是使用报头当中的六个标志字段来进行区分的,这六个标志位都只占用一个比特位,为0表示假,为1表示真。
标志位存在的意义
区分TCP报文的类型
比如说你在使用百度网盘,正在上传,突然想暂停上传,取消上传。说明你和后端的服务在正常的功能请求的时候,一方面会进行数据通信,一方面你还要控制服务端,告诉对方你要干什么
ACK
ACK的作用是确认序号是否有效
- 报文当中的ACK被设置为1,表明该报文可以对收到的报文进行确认。
- 一般除了第一个请求报文没有设置ACK以外,其余报文基本都会设置ACK,因为发送出去的数据本身就对对方发送过来的数据具有一定的确认能力,因此双方在进行数据通信时,可以顺便对对方上一次发送的数据进行响应。
表明自己是个应答报文,至于有没有数据,那就要看你写了数据没
SYN
SYN的作用是请求建立连接
- 报文当中的SYN被设置为1,表明该报文是一个连接建立的请求报文。
- 只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置。
FIN
FIN的作用是通知对方,本端要关闭了
- 报文当中的FIN被设置为1,表明该报文是一个连接断开的请求报文。
- 只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
当我们关闭close套接字的时候,就是关闭连接,其实就是告诉底层操作系统给对方发送一个含有FIN置1的报文
PSH
报文当中的PSH被设置为1,是在告诉对方尽快将你的接收缓冲区当中的数据交付给上层。或者提示接收端应用程序立刻从TCP缓冲区把数据读走
如果数据过多地被存储在接收缓冲区中,而没有及时交给应用层,这可能导致缓冲区的溢出或应用程序的处理延迟。
PSH标志位指示接收方尽快将数据交给应用层,这也发生在应用层有紧急处理需求时。例如,当发送数据量较小,但对端的应用需要及时响应时,可以使用PSH标志位。这样,即使接收缓冲区没有填满,数据也会尽快交给应用程序,而不是等到数据流的更多部分到达。
我们一般认为,当使用read/recv从缓冲区当中读取数据时
- 如果缓冲区当中有数据,read/recv函数就能够读到数据进行返回。
- 而如果缓冲区当中没有数据,那么此时read/recv函数就会阻塞住,直到当缓冲区当中有数据时才会读取到数据进行返回。
实际这种说法是不太准确的,其实接收缓冲区和发送缓冲区都有一个水位线的概念。
RST
- 报文当中的RST被设置为1,表示需要让对方重新建立连接。
- 在通信双方在连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的RST标志位就会被置1,表示要求对方重新建立连接。
- 在双方建立好连接进行正常通信时,如果通信中途发现之前建立好的连接出现了异常也会要求重新建立连接。
TCP要保证可靠性的,所以建立连接,断开连接这样的动作必须是成功的吗?
保证可靠性的意思,是在正常通信情况下,TCP提供足够完善的策略来保证通信的过程是可靠的,但即便有那么多策略,在面对一些异常的时候也不能百分比保证。比如说我已经跟你建立连接成功正在给你发消息呢,突然我直接把接收方的电源拔掉,我不跟你玩了
虽然保证可靠性,但并不能保证上述每次都能成功,能保证的是,只要三次握手了就能保证建立连接成功了
TCP虽然保证可靠性,但是TCP允许连接建立失败
作为一个服务器端一定会同时存在多个已经建立好的连接,要对连接进行管理。你是如何理解连接概念呢?如何管理呢?
先描述再组织 在我们看来,客户端和服务器双方建立连接的本质是 双方的操作系统内为了维护连接创建struct结构体,包含连接对应的属性
维护连接也是有成本的,你这个对象创建时,要花空间,时间等的成本
在我们大量客户端进行连接的时候,此时会出现一些连接建立异常的情况:客户端建立好了但是服务端没建立好等等,所以客户端给服务端发消息的时候,服务器在给客户端进行应答时会带上RST
要求对连接进行重新建立
三次握手
为什么这个发送的线是斜的,因为报文中间是要经过网络发送的,发出去的报文,和收到报文是有时间差的
在客户端发送最后一次ACK时,是客户端发出去ACK就表明连接已经建立好了,还是服务端接收到了ACK才表明连接建立好了。事实是最后一个ACK是没有应答的。也就是客户端认为只要发出去了连接就建立好了,TCP在进行三次握手的时候,本质就是在赌,赌第三次的ACK一定能被服务端收到
前两次报文丢了我们都不怕的,因为有应答。在最后一次发出的时候客户端就创建连接结构体,把客户端信息服务端信息都填好,准备开始通信了。
举个例子
如果说客户端发了第三次的ACK认为对方收到了,但是服务端实际没收到。接着客户端开始给服务端发消息,服务端就很纳闷,不是说好的先建立连接吗?你都没建立呢咋就发消息呢?这个时候服务端就可能会猜出来了,一定是客户端以为连接建立好了,所以就要赶紧告诉客户端,实际我们没连接呢。就给客户端进行应答,携带TCP报头当中把RST标志位进行设置。客户端收到了报文携带了RST,就会立马意识到,咱俩建立的连接是有问题的的,所以就会重置建立连接(重新发起三次握手),正常连接服务端突然断网也是这种情况
URG
- 当URG标志位被设置为1时,需要通过TCP报头当中的16位紧急指针来找到紧急数据,否则一般情况下不需要关注TCP报头当中的16位紧急指针。
- 16位紧急指针代表的就是紧急数据在报文中的偏移量。
- 因为紧急指针只有一个,它只能标识数据段中的一个位置,因此紧急数据只能发送一个字节,而至于这一个字节的具体含义这里就不展开讨论了。
双方在进行网络通信的时候,由于TCP是保证数据按序到达的。即便发送端将要发送的数据分成了若干个TCP报文进行发送,最终到达接收端时这些数据也都是有序的,因为TCP可以通过序号来对这些TCP报文进行顺序重排,最终就能保证数据到达对端接收缓冲区中时是有序的。
有些情况下,我们想让某些数据插队进行优先处理,但是TCP是按需到达的,也就是这种情况不会存在,但是我们就想让某些数据优先处理。那么此时就可以设置URG标记位为1
16位紧急指针
16位紧急指针代表的就是紧急数据在报文中的偏移量。比如说数据携带了1000,16位紧急指针写的是500,就代表500那个位置包含的是紧急数据,如果URG不设置那就是正常排队,如果URG设置为1,就说明有数据要插队。
在TCP当中,紧急数据默认只让你携带一个字节。
recv函数的第四个参数flags有一个叫做MSG_OOB的选项可供设置,其中OOB是带外数据(out-of-band)的简称,带外数据就是一些比较重要的数据,因此上层如果想读取紧急数据,就可以在使用recv函数进行读取,并设置MSG_OOB选项。
与之对应的send函数的第四个参数flags也提供了一个叫做MSG_OOB的选项,上层如果想发送紧急数据,就可以使用send函数进行写入,并设置MSG_OOB选项。
什么场景下需要用URG呢?
比如当用户在Telnet会话中需要中断当前操作时(比如按下Ctrl+C),Telnet协议会利用URG标志将中断信号作为紧急数据发送给服务器,从而让服务器优先处理该命令。
确认应答机制(ACK)
确认应答机制就是由TCP报头当中的,32位序号和32位确认序号来保证的。需要再次强调的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是通过收到对方的应答消息,来保证自己曾经发送给对方的某一条消息被对方可靠的收到了。
超时重传机制
双方在进行网络通信时,发送方发出去的数据在一个特定的事件间隔内如果得不到对方的应答,此时发送方就会进行数据重发,这就是TCP的超时重传机制。
其实当一个数据被发出去的时候,没有收到应答。其实主机A并不清楚这个报文是不是丢了,因为AB可能相隔千里之外,数据传送的速度出现震荡是很正常的。只有当主机A收到应答了,才知道这个报文主机B已经收到了,而对于是否丢了A不清楚。所以要定一个时间间隔,也就是在时间间隔内没有应答,就判断报文丢失了。
丢包的两种情况
丢包分为两种情况,一种是发送的数据报文丢失了,此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传
超时重传的等待时间如何设置呢
超时重传的时间不能设置的太长也不能设置的太短。
- 超时重传的时间设置的太长,会导致丢包后对方长时间收不到对应的数据,进而影响整体重传的效率。
- 超时重传的时间设置的太短,会导致对方收到大量的重复报文,可能对方发送的响应报文还在网络中传输而并没有丢包,但此时发送方就开始进行数据重传了,并且发送大量重复报文会也是对网络资源的浪费。
因此超时重传的时间一定要是合理的,最理想的情况就是找到一个最小的时间,保证“确认应答一定能在这个时间内返回”。但这个时间的长短是动态的,与网络环境有关的。网好的时候重传的时间可以设置的短一点,网卡的时候重传的时间可以设置的长一点,也就是说超时重传设置的等待时间一定是上下浮动的,因此这个时间不可能是固定的某个值。
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时 时间都是500ms的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
连接管理机制
三次握手
三次握手的状态变化
三次握手时的状态变化如下:
- 最开始时客户端和服务器都处于CLOSED状态。
- 服务器为了能够接收客户端发来的连接请求,需要由CLOSED状态变为LISTEN状态。
- 此时客户端就可以向服务器发起三次握手了,当客户端发起第一次握手后,状态变为SYN_SENT状态。
- 处于LISTEN状态的服务器收到客户端的连接请求后,将该连接放入内核等待队列中,并向客户端发起第二次握手,此时服务器的状态变为SYN_RCVD。
- 当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED。
- 而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
至此三次握手结束,通信双方可以开始进行数据交互了。
套接字和三次握手之间的关系
- 在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数。
- 当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数。
- 需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手。当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
- 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。
- 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
注意:连接建立成功和上层有没有accept没有关系
三次握手是双方操作系统自动完成的
Listen接口的第二个参数 - backlog
注意:从一个状态到下一个状态,必须是抵达了下一个状态才能发生改变。就好比当前服务器已经发送了SYN+ACK此时它的状态是SYN_RCVD而不是ESTABLISHED
这是由于Listen的第二个参数造成的,第二个参数(backlog+1)表示的底层已经建立好的连接队列的最大长度。此时我们设置的是1,也就是最大连接数只能是2个.当连接到第三个客户端的时候,服务端虽然也会进行三次握手,但是在最后一次握手的时候会把收到的带有ACK的报文给丢弃,也就是没有从SYN_RCVD状态变成ESTABLISHED状态。并且服务端不会长时间的维护SYN_RCVD状态,被建立连接的一方,处于SYN_RCVD,属于是半连接,不会长时间的维护
在抢课的例子中,可以把服务器的连接队列类比为课堂的座位。当大量学生(客户端)尝试进入课堂(服务器)时,如果课堂的座位数量(半连接队列 + 全连接队列)有限,某些学生可能无法进入课堂。即使服务器并没有崩溃(服务器正常工作),但由于连接队列已满,新的连接请求会被拒绝或丢弃。
listen的第二个参数为什么不能太长呢?
因为如果太长了,加入全连接队列的客户端就多了,那么全连接队列也就很长,可能会导致服务器上有些连接来不及被处理,下层仍然有半连接的客户端输送上来。但是依旧要在系统内长时间的维持。也就是当服务器非常忙的时候,会有大量的资源被占用,也不创造价值
什么是半连接和全连接,全连接队列和半连接队列?
1. 全连接
全连接通常指的是在网络中,两个设备之间存在一条稳定且双向的连接。具体到TCP协议中,全连接通常指的是:
- TCP连接建立后,客户端和服务器之间能够双向传输数据。
- 双方都处于连接状态,可以互相交换数据,连接是可靠的。
在网络设备中,全连接也常用于描述网络拓扑结构,其中每个节点都与所有其他节点直接连接。比如在一个全连接网络中,任意两个节点之间都有直接的通信路径。
2. 半连接
半连接一般是在TCP三次握手过程中用来描述连接的中间状态,特别是在建立连接时,指的是连接还没有完全建立的阶段。具体来说,半连接是指:
- 在TCP三次握手过程中,客户端发送SYN请求后,服务器接收到SYN报文并响应SYN-ACK报文,但连接尚未完全建立,等待客户端的最后确认。
- 这种状态下,连接还没有完全处于可用状态。通常,半连接数是指服务器端在连接建立过程中处于等待客户端确认状态的连接数。
3. 半连接队列
-
半连接队列:在TCP三次握手过程中,当客户端发送SYN请求并等待服务器的响应时,该连接处于半连接状态。此时,连接还没有完全建立(仍在等待客户端确认),因此该连接处于半连接队列中。服务器将这个连接放入半连接队列,等待客户端的最后确认。
4. 全连接队列
-
全连接队列:当TCP三次握手完成,连接正式建立后,连接就会从半连接队列转移到全连接队列。此时,客户端和服务器之间可以正式开始数据传输。
注意
如果半连接队列已满,服务器会丢弃那些无暇处理的连接请求。在这种情况下,虽然第三个客户端完成了三次握手,但服务端无法把这个连接放入半连接队列,导致这个连接没有从SYN_RCVD
状态转变为ESTABLISHED
状态,结果是客户端连接失败。
为什么要有半连接队列呢?
就好比开了一家餐厅,但是人很多,当进去的人满了之后,会有个服务员在门口看着,如果没有半连接队列,也就是再来新人,服务员就会拒绝。来一个拒绝一个。最后外面就没有新的顾客了。这时候刚好有几桌人离开,但是外面没有人,那么那几桌的资源就被浪费了。如果当人满了服务员让新顾客在外面等着,那么当有人离开的时候,新顾客就能补上
TCP建立连接时,为什么要三次握手?
TCP在建立连接的时候,真的是三次握手嘛?其实是四次。只不过是因为中间的两个报文(SYN+ACK)被捎带应答了,被压缩成了一次。为什么可以被压缩一次呢,因为对于客户端发来的请求,服务端要无条件答应的
在四次挥手的时候,可不可以FIN和ACK也进行捎带应答了呢?也有可能。为啥四次挥手的时候不进行捎带应答呢?因为这里是有协商的。因为当客户端给服务器把消息发完了的时候,服务端还有消息要给客户端发。他俩要想压缩,要有巧合性
其实他俩的本质是一来一回的可靠性,相当于我给你发消息,你给我应答对于客户端和服务端都是如此
为什么要三次握手呢
无论是客户端还是服务端在进行双方通信之前,双方都至少做过一次发一次收
1.这样做的目的就是验证全双工通道是否通畅
一次或者两次握手行吗?
一次握手
仅发送一次SYN报文无法获得对方的确认。客户端发出SYN后不知道对方是否已经准备好接收数据,也无法同步初始序列号。这会使连接建立缺乏双方确认,风险较高。对于服务器来说很容易出现连接被打满的情况,也叫SYN洪水两次握手
假设采用两次握手:客户端发送SYN,服务器回复ACK。虽然服务器知道客户端的请求,但客户端没有收到服务器确认自己已准备好建立连接的信息,也无法获知服务器的初始序列号,从而导致双方信息不对称,可能出现数据混乱或重复传输的问题。而且也会优先让服务端做出建立连接的动作,如果客户端突然挂掉了,此时服务端就要一直挂着,如果此时流量特别大,就可能会出现问题,也就是说出了问题,服务端要进行兜底,但是客户端的数量是非常庞大的,这个风险不能让服务端去承担。
如果是三次握手,如果第一次第二次丢了,我们是不怕的,因为都有对应的应答。而且双方还尚未连接,损失的成本不是太高。如果是第三次丢了,对于客户端来说,一旦他第三次发出ACK之后,他就开始对连接做准备了。此时服务器没有收到认为连接建立不成功。连接建立失败的成本是由客户端来承担的,服务端损失成本很低,对服务器影响不是很大,保证了服务器的稳定性
2.奇数次握手,可以确保一般情况握手失败的连接成本是由客户端来承担的
为什么不是四次 五次握手呢?
三次握手是验证全双工的最少次数,验证了之后就不需要过多的验证了,反而会增加成本
四次挥手
断开连接的本质上,双方没有数据给对方发送了,因为发送数据是双方都可能发,所以必须断开2次
如果服务器此时还有数据要发,没问题,此时服务器仍然可以给客户端发的,等到发完再FIN。如果关闭了FIN之后,连接没有彻底地断开,任何一个关闭的一方其实是可以收对方的数据的
四次挥手时的状态变化
- 在挥手前客户端和服务器都处于连接建立后的ESTABLISHED状态。
- 客户端为了与服务器断开连接主动向服务器发起连接断开请求,此时客户端的状态变为FIN_WAIT_1。
- 服务器收到客户端发来的连接断开请求后对其进行响应,此时服务器的状态变为CLOSE_WAIT。
- 当服务器没有数据需要发送给客户端的时,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LASE_ACK。
- 客户端收到服务器发来的第三次挥手后,会向服务器发送最后一个响应报文,此时客户端进入TIME_WAIT状态。
- 当服务器收到客户端发来的最后一个响应报文时,服务器会彻底关闭连接,变为CLOSED状态。
- 而客户端则会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间)才会进入CLOSED状态。
至此四次挥手结束,通信双方成功断开连接。
套接字和四次挥手之间的关系
- 客户端发起断开连接请求,对应就是客户端主动调用close函数。
- 服务器发起断开连接请求,对应就是服务器主动调用close函数。
- 一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手。
CLOSE_WAIT
双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态。
但只有完成四次挥手后连接才算真正断开,此时双方才会释放对应的连接资源。如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
因此如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题。
因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符。
TIME_WAIT
TIME_WAIT 状态是 TCP 协议设计的一部分,主要目的是确保当一个连接关闭后,所有可能滞留在网络中的旧数据包都能被清除,避免影响将来的新连接。具体理解如下:
-
数据包清除
当一个连接关闭时,可能还存在延迟到达的数据包。如果立即重用相同的 IP 和端口,新连接可能会误收到这些旧数据包,从而导致混乱。TIME_WAIT 状态保持一定时间(通常是 2MSL,即两倍的最大报文生存时间),以确保所有旧数据包都被清除。 -
端口占用
在 TIME_WAIT 状态期间,该连接的四元组(源 IP、源端口、目的 IP、目的端口)依然被系统占用。这意味着系统认为这个连接还没有完全结束,所以你无法立即在同一端口上启动新的服务,因为端口被视为“正在使用”。 -
协议健壮性
通过让连接在 TIME_WAIT 状态下保持一段时间,TCP 协议可以更健壮地处理网络中的延迟和重复报文,防止因数据包乱序或重复而产生潜在的问题。 -
特殊选项
虽然系统默认不允许在 TIME_WAIT 状态下重用同一端口,但有些操作系统提供了诸如 SO_REUSEADDR 或 SO_REUSEPORT 的 socket 选项,可以允许在某些情况下重用处于 TIME_WAIT 状态的端口。不过,这需要谨慎使用,确保不会引入数据混淆或安全隐患。
下面我将详细介绍
主动断开连接的一方,在4次挥手完成之后,要进入time_wait状态,等待若干时长之后,自动释放
连接断开后,会维持一段时间的TIME_WAIT状态,在此期间, 不能重新在同样的端口启动服务,因为连接没有被彻底断开,ip和port正在被使用
一个服务器只能有10个连接,当第11个连接来的时候,服务器终于不堪负重,挂掉了,第11个连接自然请求失败,但是对前面10个连接来说,服务器就变成了主动断开连接的一方,服务器立马就会有10个连接处于TIME_WAIT状态,带来的后果就是服务器无法立即重启,重启时间取决于TIME_WAIT消失的时间,大约为30 -- 60秒,但是我们不允许服务器重启时间这么久,所以我们需要设置套接字属性,让服务器允许我们进行地址复
int opt = 1; setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法立即重启,TIME_WAIT状态时,立即重启,不要再等了
客户端为什么不会出现这个问题呢?
因为客户端每次启动用的都是随机端口,客户端挂了,再次启动就是其他的端口号了,不会受到这个的影响。但是服务器每次启动都必须绑定一个端口号,而且主动断开连接的一方大部分都是客户端
TIME_WAIT要等多少时间?为什么要等?
要等多少时间的问题
- TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime) 的时间后才能回到CLOSED状态.
- 我们使用Ctrl-C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听 同样的server端口;
- MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;
- 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值;
为什么要等的问题
我们在进行断开连接的时候,有可能我自己历史上发了很多数据,有些是正常的数据,有些是ACK,突然我们的客户端/服务器关闭了,最终会导致服务器或者客户端进入断开连接,可是是历史会有双方残留的数据在网络里依旧存在。1.所以等待的TIME_WAIT时间里,让历史的双方数据,在网络通信的信道里进行消散。2.我们让主动断开的一方维持一个timewait,即便我们双方连接已经彻底断开了,比如说四次握手最后一次ACK丢了,客户端处于TIME_WAIT状态,还可以重新收到服务端补发的FIN,重新再发送次ACK,确保四次挥手正确挥手完成。
1.让通信双方历史数据得以消散
2.让我们断开连接,4次挥手,具有较好的容错性
我们不是有超时重传吗?比如说报文在某个路由器超时很长时间了,我早就不发了。为什么还要等这个数据消散了呢?
原因很简单,历史数据消散的这个意义并不是让服务端把这个数据收到,而是让服务端或者客户端把这个数据丢弃,不让本轮的通信对后续产生影响。
TIME_WAIT的等待时长设置为两个MSL的原因:
-
确保所有旧数据包失效
MSL 表示一个 TCP 报文在网络中可能存在的最长时间。等待 2MSL 确保在关闭连接后,任何延迟到达的旧数据包都有足够时间在网络中消失,从而防止这些数据包被误认为是新连接的数据。 -
保障连接的可靠终止
如果最后的 ACK 丢失,对方可能会重传 FIN 报文。TIME_WAIT 状态允许发送方在等待期间重传 ACK,确保连接能完全终止,并且双方对连接关闭达成一致。 -
防止连接混淆
在同一(IP、端口)对的连接复用时,等待 2MSL 可避免新连接接收到旧连接的延迟数据包,防止因数据包混淆导致协议错误或数据混乱。
滑动窗口
服务器对每一个客户端发送的数据段,都要一一给予ACK确认应答,收到ACK后再发送下一个数据段。这样做效率非常低下,尤其是数据往返的时间较长的时候,因此TCP肯定有一次性发送多条数据的方法(多个段的等待时间重叠)我们知道,已经发出去,但是暂时没有收到应答的报文(存在多个这样的报文),要被TCP暂时保存起来。
所以发送方就会存在着多个已经发出去但是暂时还没收到应答的报文。那么这些已经发出去但是还没收到应答的报文会被保存到哪里呢?根本不需要保存,因为报文就是在缓冲区里的,所以我们只需要“把缓冲区做一个简单的区域划分”即可。
把缓冲区当作数组来看的话,只需要有一个数组的下标就可以标记区域了,所以缓冲区可以分为三部分:已发送已确认,已发送未确认,待发送,三个部分
- 对于已发送已确认的部分,这部分可被覆盖,用简单的话说就是这个部分里面的数据可以从缓冲区移除或者设置成无效
- 对于已发送未确认,理解为:可以发/已经发,但是就是没有收到应答,我们就把这部分区域叫做“滑动窗口”,是发送缓冲区的一部分。数据在这里保存着。当我们收到应答了,我们无非就是把应答的数据纳入到前一个区域,这个报文就相当于被清掉了。
滑动窗口在哪里?
是我们发送缓冲区的一部分
滑动窗口可以是多大呢?
滑动窗口的范围大小,是对方的接收窗口 凡是在滑动区域的窗口,是可以直接推送给我们的接收方的。因为有滑动窗口的存在,所以我们才可以一次向对方发送大量的TCP报文
滑动窗口的范围大小
作为发送和接收方,假设我们的接收方剩余空间,会更新给对方。更新之后发送方只要合适的把窗口调至为合适的大小,可以把数据一次性的发送给接收方。也就是说流量控制是和滑动窗口有关联的
如何做到的区域划分?
其实非常好理解, 由于区域划分可以用数组来搞,所以区域大小的变化就可以用“双指针”来完成,只需要在双方的TCP协议里面维护上两个整数就好了,一个整数是start 一个整数是end此时这个窗口就出现了。指针之前的叫做已发送,已确认区域,指针之后的叫做未发送区域。要是对方的接收能力变大了。那我们就把end向待发送区域扩展。滑动窗口本质上就是指针右移
滑动窗口描述的就是发送方不用等待ACK,一次能发送的数据最大量
既然有滑动窗口,那么为什么不把报文一次性全部发出去,而是一次发多个报文呢?(设置成一块一块的),每一次发送本质就是一次拷贝和IO,那么不是说IO次数多的话效率会降低的吗?
滑动窗口越大,意味着网络吞吐量越高,同时往对方发送的数据量越大。但是,滑动窗口的大小,都是根据对方接收缓冲区的剩余大小来定的,不能超过对方的接收能力即应答报文的窗口大小
滑动窗口机制的设计是为了平衡带宽利用、可靠性、错误恢复和系统资源的管理。虽然多次IO操作会引入一定的开销,但通过滑动窗口可以提高网络传输的效率,并确保更可靠的数据传输。
滑动窗口如何处理异常问题,如果丢包了,该怎么办
当发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
情况一 数据包已经抵达,ACK丢包。
在发送端连续发送多个报文数据时,部分ACK丢包并不要紧,此时可以通过后续的ACK进行确认。
我们对于确认序号的定义:确认序号是X,则表示X之前的报文我们全部收到了!即允许少量的ACK丢失
比如图中2001-3000和4001-5000的数据包对应的ACK丢失了,但只要发送端收到了最后5001-6000数据包的响应,此时发送端也就知道2001-3000和4001-5000的数据包实际上被接收端收到了的,因为如果接收方没有收到2001-3000和4001-5000的数据包是设置确认序号为6001的,确认序号为6001的含义就是序号为1-6000的字节数据我都收到了,你下一次应该从序号为6001的字节数据开始发送。
那如果是最后的5001-6000丢失了呢?那么5000之前的应答是不是收到了呢,我们就把窗口更新到5000就行了,然后静等着超时补发就好了。如果全丢失了呢?那窗口就不更新
情况二: 数据包丢了。
当1001-2000的数据包丢失后,确认序号并不是要接着往下填2001,3001...根据我们对确认序号的定义,表示的是确认序号之前的我们全部收到了。如果我们接着往下填,那就跟定义冲突了。
主机A窗口在向右滑动的时候,是不会出现跃过丢包的情况的,直接就只能把窗口移动到1001。确认序号的定义保证了滑动窗口线性的连续的向后更新,不会出现跳跃的情况
如果最后是5001-6000的报文丢了呢?那么确认序号只能是5000。
因为主机A一次性发送大量的报文,此时就要进入超时等待时间,对报文做补发,该怎么去补发报文呢?
因为主机A一次性发送大量的报文,此时就要进入超时等待时间,当1001-2000的数据丢失了之后,主机B剩下的所有报文的应答都必须填的是1001。因为数据就在缓冲区里此时主机A就可以静等确认应答的那个ACK进行补发。可是超时就要等呀,你历史上发送了大量的报文,也就意味着你你会收到很多应答,只不过剩下的ACK全填写的是1001.主机A有个原则:收到三个同样的确认应答是则会重发
也就是说如果发送端连续收到三次确认序号为1001的响应报文,此时就会立即将1001-2000的数据包重新进行发送。这种策略叫做快重传
此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为7001的响应报文,因为2001-7000的数据接收端其实在之前就已经收到了。
已经有了快重传了,为什么还有超时重传呢?
主要是因为快重传是有条件的:收到三个相同序号的应答,才会快重传,可是当数据通信接近末期,没那么多数据可以发送,也没那么多数据可以应答了。那么此时快重传就不会被触发。快重传的本意是为了提高效率的,所以超时重传也不能丢弃,因为超时重传是兜底的,是底线。
就好比你收到的报文是最后一个,它只能给一个ACK确认信号,快重传就不会被触发。
滑动窗口一定会整体右移吗?能不能向左移动呢?移动的时候大小会变化吗?怎么变化?会为0吗?
不能向左移动,左侧是已经发送已经确认的,只会往右滑;大小根据对方的接收能力来定,所以窗口大小是会动态(要照顾主机B的接收能力),因为对于滑动窗口的定义是可以直接发,直接发的前提条件是保证流量控制 动态包括:变大 变小 不变,其中向右移动范围可以扩大也可以缩小
所以向右移动也有三种方式:
- 右指针不变,左指针一直移动,说明对方上层一直不取数据,然后就是窗口变小的情况
- 左指针移动,右指针也移动,说明对方接收能力提高,窗口会变大;
- 同样的,如果左指针移动比右指针慢,表示对方接受能力减小,所以右指针移速也会动态减少,窗口也会动态变小
对于滑动窗口的范围大小,在代码层面上可以理解为两个指针start和end
- int* start = 确认序号 根据确认序号设置
- int* end = 确认序号 + 应答报头里的窗口大小 更严谨的是min(win,有效数据。拥塞窗口)
流量控制是怎么实现的呢?
流量控制就是通过滑动窗口实现的:流量控制不仅仅是限制发送接收,如果主机的接收能力非常强,流量控制也可以把滑动窗口搞大,使其能发送更多数据,提高效率
如果窗口一直往右移动,会不会越界?
Tcp对于滑动窗口的时间采用了类似“环状算法”,说白了就是数组的环状缓冲区,物理上是线性结构,逻辑上是环状结构。也相当于模运算到了结尾后通过环形算法重新计算start和end的位置,从左边再次开始移动
再聊序号
双方开始通信时,为了维护连接,双方要维护结构体,序号并不是从0开始的,假设我断开链接了,我进入TIME_WAIT等待数据消散,但是如果我断开连接后后悔了,于是立马又重新建立连接,但是数据可能还没有消散,于是服务器对新链接可能会收到旧数据(概率很低但是也有)TCP也要处理这种问题。所以一般双方对应的序号都是随机的,三次握手时也会协商序号,一般协商是以随机序号的最小值作为起始序号。因为是随机序号,它和老的序号出现冲突的概率就又小了
下次发送数据在缓冲区的位置是 确认序号-随机序号。
延迟应答
客户端和服务器都有接收和发送缓冲区,双方的发送缓冲区里都有滑动窗口,客户端发消息给服务器,服务器要给客户端应答
发送方一次发送更多的数据,代表它发送的效率越高(也就是一次IO往网卡里塞更多数据,效率高)
但是发送方一次发送多少数据,取决于对方告诉我它能接收更多数据,
如果接收方,给发送方通告一个更大的窗口大小(TCP报头那个),发送方才能发更多数据
如何让接收方给发送方通告一个更大的窗口呢?
那么就在服务器收到报文的时候,我不立即发应答,我等一等(这个等不是上层等,是TCP协议层在等),在不超时的情况下,我收到第二个或者第三个报文时,再给客户端应答,而在我等的时候,上层就有较大的概率把数据取走,缓冲区的剩余空间就会变大。
我们把这种收到报文不着急应答的策略,叫做:延迟应答,是一种提高效率的方式,但是不是一定提高效率,如果上层一直不取数据,那么效率会降低,所以这种应答提高效率是有概率的。
- 假设接收端缓冲区为1M. 一次收到了500K的数据
- 如果立刻应答, 返回的窗口就是500K
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M
为什么要每次尽快的通过read,recv尽快的把数据从内核中拿上来呢?
只要我每次尽快的把数据取走,TCP就能尽快地给对方更新出更大的窗口。我尽量的读,那边就能尽量的写
需要注意的是,延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行ACK响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。
此外,不是所有的数据包都可以延迟应答。
- 数量限制:每个N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。
延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。
捎带应答
捎带应答其实是TCP通信时最常规的一种方式,就好比主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发生消息,此时这个ACK就可以搭顺风车,而不用单独发送一个ACK应答,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了(比如说三次握手的第二次握手就是捎带应答)。
此外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的ACK应答也被对方可靠的收到了。
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
虽然有重传这个策略,但是不能滥用,那些被正常丢弃的报文,已经侵占了很多资源了,耗费了很多电力,物力,人力,你最后无脑丢掉了,重传虽然可以解决问题,但是是一个低效的表现
第一次的时候,怎么保证发送的数据量是合理的?
不要理解三次握手就只是为了握手,双方在握手的时候也交换了报文,已经协商了双方的接受能力(前两次握手的时候就完成了)所以双方在发送数据时是不会出现缓冲区溢出的问题的。
第三次握手的时候,可以携带数据
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接收到这个窗口之后,就会减慢自己发送的速度。
- 如果接收端缓冲区满了,就会将窗口值设置为0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据。
- 等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。
- 主动询问。发送端每隔一段时间向接收端发送报文,该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了。
16为数字最大表示65535,那TCP窗口最大就是65535吗?
理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的。
拥塞控制
如果发送数据,出现问题,不仅仅是对方主机出问题了,也可能是网络出现了问题。两种情况:
1.如果通信的时候出现了少量的丢包?
2.如果通信的时候出现了大量的丢包?
场景:一个班有30个人,考C语言只有两个人挂科,后面考网络,挂了28个人,所以前面考C语言可能就是我的问题,后面考网络可以认为是校方问题,对应的也就是客户端-服务器的问题和网络的问题
而网络出现问题可能会有以下情况:
- 硬件设备出问题
- 网络中数据吞吐量太大引起阻塞。
如果通信双方出现大量数据丢包问题(大量的数据都超时了),Tcp就会判断网络出了问题。我们叫做“网络拥塞”
当识别到网络拥塞,发送方应该怎么办呢?
我们不能立即对报文进行超时重发!原因是如果是硬件问题引起的阻塞你再怎么超时重发都没意义。如果是数据量太大引起的阻塞,它的数据量已经太大了,你还进行超时重发。肯定会加重网络的阻塞。就好比堵塞的十字路口一样,比如说10辆车只能过1-2辆车,正确的做法是先不过去,你绕过去,如果绕不过去你就先不过去,如果此时你过去只会加重阻塞。
而且一个网络也不仅仅是一个CS在通信,会有很多个CS在通信,而且网络资源本身是共享的,都是用的TCP/IP协议
关键点不在于你少发,网络拥塞影响的不是你一个主机,影响的是大家,所以是大家都减少对数据的发送,使网络拥塞的数据快速消散,你好我好大家好 本质是“用TCP协议实现了多主机面对网络拥塞时的共识”
也不是所有的发送方都能识别到网络拥塞了,比如我只发两个报文,那么也就丢两个报文,所以不是所有主机都能立马识别到网络拥塞,但是能保证一个主机一旦识别到网络拥塞就立即减少发送
- 网络这个东西是搏概率的,拥塞严重程度的不同也是自适应的。
- 识别到网络拥塞时,启动慢启动机制,发送少量数据,如果又丢了,进入超时重发;如果得到了应答,第二次发送多一点点数据,然后重复前面的操作(每次重发时都发送上一次发送的两倍报文)
拥塞控制的策略
每台识别主机拥塞的机器,都要做!
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍 然可能引发问题. 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.
识别到网络拥塞时,启动慢启动机制,发送少量数据,如果又丢了,进入超时重发;如果得到了应答,第二次发送多一点点数据,然后重复前面的操作(每次重发时都发送上一次发送的两倍报文)
所以我们现在就要推出一个概念:拥塞窗口
每次开始的时候,定义拥塞窗口大小为1,每次收到ACK应答后,拥塞窗口大小 *= 2,
拥塞窗口大小是主机判断网络健康程度的指标,超过拥塞窗口,会引发网络拥塞,否则不会。因为网络是动态的,所以拥塞窗口本身也是动态的
所以滑动窗口大小 = min(16位窗口大小 ,有效数据, 拥塞窗口);16位窗口大小是对方主机的接受能力,但是不能只考虑接收能力,还要考虑网络状况。拥塞窗口就是考虑的是动态的网络的接收能力
通过调整拥塞窗口的大小来动态控制滑动窗口的大小,让它的发送数据量按照我们的要求指数级增长,叫做“慢启动”
指数级增长为什么叫做慢启动呢?
他的增长是很快的,但是不代表前期增长就很快的。
就好比之前农民欠地主钱,但是农民还不起。这个时候地主说了,你不用还我钱了,你今天给我一粒米,明天给我二粒米,第三天给我四粒米。就这样指数型的给我米。这样来看的话,前期还的米就是非常的少
这个慢是指初始的时候慢,网络出拥塞的时候,发送少量的报文,如果都OK,那么表示网络已经趋于健康了,应该尽快恢复正常通信,而且为了不让后期拥塞窗口过大,所以也不是单纯地让拥塞窗口加倍增长
TCP拥塞控制这样的过程, 就好像 谈恋爱的感觉
实际机器发送的数据量,会一直增长吗?
慢启动具有阈值,当拥塞窗口大小超过阈值时,就以线性增长不再指数增长了。而这个阈值这个数字,代表最近一次发生网络拥塞时拥塞窗口的大小/2(实验得来的) 作为慢启动“阈值”,并且更新一个新的阈值,然后将拥塞窗口直接干到1,从1重新开始增长
网络一定会拥塞吗?
不一定,可能所有主机的发送量到达不了阈值。
滑动窗口 + 接收窗口 + 拥塞窗口,三个窗口相互配合,就可以在Tcp传输的时候既考虑接收问题,又考虑网络问题
TCP总结
可以看到Tcp比起Udp复杂了不止一点点,因为Tcp既要保证可靠性,又能尽可能提高效率
可靠性保证:
- 检验和
- 序列号
- 确认应答(核心)
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
需要注意的是,TCP的这些机制有些能够通过TCP报头体现出来的,但还有一些是通过代码逻辑体现出来的。
学到了这里,我们现在知道了,三次握手的目的有:1.建立连接 2.协商起始序号 3.协商双方的接收缓冲区大小
Tcp定时器
- 重传定时器:为了控制丢失的报文和丢弃的报文,也就是对报文段确认的等待时间
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔
- TIME_WAIT定时器:双方在四次挥手之后,主动断开连接的一方需要等待的时长
拓展
面向字节流
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
- 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的。
- 如果发送的字节数太长,TCP会将其拆分成多个数据包发出。如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用read函数来读取接收缓冲区当中的数据。
- 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,这就是面向字节流 例如:
- 写100个字节数据时,可以调用一次write写100字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
面向字节流 VS 面向数据报
- 写数据和读数据互不相关。数据发多少,读多少,没有那种我发多少你就必须收多少的那种匹配机制。UDP就是你发几次我就必须收几次,所以UDP就是面向数据报
- 发送本质就是把数据从我的发送缓冲区“拷贝”到对方的接收缓冲区,但是对方数据被上层取走多少由用户决定,但是发送方发送多少数据由操作系统和TCP协议决定,
- TCP也不管你上层要发送的数据是什么,在我缓冲区里它就是二进制,就是字节数据,TCP的任务就是保证这个数据能成功被对面接收到
- 同时对方的TCP也只认字节数据,分离报头等等工作由用户层自己做 --> TCP只管发送,对发送的数据不做任何处理,全由上层自己做 --> 所以TCP只有字节的概念
- UDP报头里面是有数据长度的,但是TCP没有,因为TCP的序号能保证数据段本身的按序性,TCP也不区分什么报头和载荷,那是上层的事情,TCP只有字节流的概念,你的缓冲区里什么我的缓冲区里就有什么
- 就像家里的自来水管,自来水公司只负责把水送到你家,你怎么用这个水自来水公司不管。字节流也是类似的概念
- 用户对报文进行处理必须一个一个处理,需要将字节流变成一个一个完成的请求,那就是应用层的事了,与Tcp无关了,这样也很好地进行了功能解耦
粘包问题
什么是粘包
我们在做包子的时候,当它刚蒸好出锅的时候,我们用手去拿的时候,一下子可能会拿起来很多个,它们的边界是连在一起的,所以我们在包子刚出锅的时候,是先要把它们一个一个分开的。
- Tcp没有粘包问题,它是上层的问题。要解决粘包问题就是定协议(自定义协议的时候搞过)粘包问题是相对于用户层的
- 粘包问题中的 “包” ,是指的应用层的数据包
- 在TCP的协议头中,没有如同UDP一样的 “报文长度” 这样的字段,但是有一个序号这样的字段
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中
- 站在应用层的角度,看到的只是一串连续的字节数据
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包
如何解决粘包问题
要解决粘包问题,本质就是要在应用层通过协议,明确报文和报文之间的边界。我们之前讲的Encode和Decode就是解决粘包问题的
- 采用定长报头
- 定特殊字符作为报文的边界
- 使用自描述字段(Content-Length)+定长报头
- 使用自描述字段+特殊字符
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。
- 对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
UDP是否存在粘包问题?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层的,有很明确的数据边界。
- 站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现“半个”的情况。
因此UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
TCP异常情况
进程终止
当客户端正常访问服务器时,如果客户端进程突然崩溃了,此时建立好的连接会怎么样?
当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭,文件的生命周期是随进程的。因此当客户端进程退出时,相当于自动调用了close函数关闭了对应的文件描述符,在操作系统层面,关掉一个进程和杀掉一个进程没有任何区别。此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。也就是说,进程终止时会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别。
机器重启
当客户端正常访问服务器时,如果将客户端主机重启,此时建立好的连接会怎么样?
当我们选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
机器掉电/网线断开
当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
- 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到ACK应答,此时服务器就会关闭这条连接。
- 此外,客户端也可能会定期向服务器“报平安”,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。
其中服务器定期询问客户端的存在状态的做法,叫做基于保活定时器的一种心跳机制,是由TCP实现的。此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的HTTP,也会定期检测对方的存在状态。
UDP实现可靠传输
我们这个应用场景是什么?因为UDP要实现的可靠性非常多。如果需要的话,如果场景非常的多,对可靠性的要求非常的高。我就不用UDP了
-
引入序列号(Sequence Number)
- 作用:为每个数据包分配唯一且递增的序列号。
- 目的:保证数据按序到达,帮助接收方检测乱序、丢包以及重复包。
-
引入确认应答(ACK)
- 作用:接收方在成功处理数据后,向发送方发送 ACK 确认。
- 目的:让发送方明确知道对端已经收到了哪些数据包,避免重复发送或数据丢失。
-
引入超时重传(Retransmission on Timeout)
- 作用:如果发送方在一定时间内未收到 ACK,则认为数据包可能丢失或延迟,触发重传。
- 目的:提高传输可靠性,避免数据包永远丢失。
-
滑动窗口(Sliding Window)
- 作用:在发送方和接收方之间维持一个窗口,表示当前允许未确认的数据包范围。
- 目的:控制并行发送的数据量,提高带宽利用率,同时便于处理乱序数据和确认响应。
-
流量控制(Flow Control)
- 作用:接收方会告知发送方其接收能力(例如可用的接收缓冲大小)。
- 目的:防止发送方发送过多数据导致接收方处理不过来或发生缓冲溢出。
-
拥塞控制(Congestion Control)
- 作用:检测网络拥塞状况(如超时、丢包),并动态调整发送速率。
- 目的:防止网络拥塞恶化,提高整体网络吞吐量和公平性。
-
数据校验(Checksum/CRC)
- 作用:在每个数据包或数据段中增加校验和/CRC,用于检测数据在传输过程中是否损坏。
- 目的:保证数据完整性,如果校验失败,要求发送方重发数据。
-
自适应超时机制(Adaptive Retransmission Timer)
- 作用:根据往返时延(RTT)的动态变化,调整超时重传的时长。
- 目的:在网络良好时加快重传检测,网络拥堵时适当延长等待时间,减少不必要的重传。
-
异常处理与状态管理
- 作用:在应用层设计状态机(如“等待 ACK”、“重传计数超过阈值”等),根据当前状态执行相应操作。
- 目的:使协议更健壮,能处理各种异常场景(连接断开、对方崩溃、网络波动等)。
在 UDP 上实现可靠传输,就是在其无连接、无保障的基础上构建一套完善的机制,包括数据包序号、ACK确认、超时重传、滑动窗口、数据校验、拥塞和流量控制等。这种设计可以弥补 UDP 的不足,使其在某些场景下既能享受低延迟优势,又能达到数据可靠传输的目的。