传输层相关协议
前言
今天梳理一下传输层的一些知识点,特别是关于TCP和UDP的,篇幅较长,还请各位看官,慢慢看
运输层
运输层:定义了一些传输数据的协议和端口号,如:
- TCP(传输控制协议,传输效率低,可靠性强,用于传输可靠性要求高,数据量大的数据)
- UDP(用户数据报协议,与TCP特性恰恰相反,用于传输可靠性要求不高,数据量小的数据,例如QQ聊天数据就是通过这种方式传输的)。
主要是将从下层接受的数据进行分段和传输,达到目的地址后再进行重组。常常把这一层数据叫做段
我们最主要了解的就是TCP协议和UDP协议,其中TCP协议特别重要!
TCP协议
什么是TCP
TCP协议又叫做传输控制协议,是一种可靠,面向连接,基于字节流的协议,是在运输层的,正是因为它的可靠,HTTP/HTTPS协议都是依靠TCP协议的,也就是说,只要支持HTTP/HTTPS协议的就一定支持TCP协议
可靠
无论的网络链路中出现了怎样的链路变化,TCP都可以保证一个报文一定能够到达接收端
面向连接
一定是一对一才能连接,不能像UDP协议 可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的
基于字节流
消息是没有边界的,所以无论我们消息有多大都可以进行传输
并且消息是有序的,当前一个消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对重复的报文会自动丢弃
TCP协议头部
TCP头部字段
16位源端口号
发送方的端口号
16位目的端口号
接收方的端口号
序列号
在建立连接时由计算机生成的随机数作为其初始值,通过SYN包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小用来解决网络包乱序问题
确认应答号
指下一次期望收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收用来解决不丢包的问题
4位首部长度
标识该TCP头部有多少个32bit字(4字节)因为4位最大能标识15,所以TCP头部最长是60字节,选项最多为40字节,固定头部为20字节,所以最长为60字节
6位保留
占6位,保留为以后使用,目前应置为0
6位标志位
- 紧急URG:此位置1,表明紧急指针字段有效,他告诉系统此报文中有紧急数据,应该尽快传送
- 确认ACK:TCP规定,在建立连接后,所有的传达的报文段都必须把ACK置1
- 推送PSH:提示接收端应用程序应该立即从TCP接收缓冲区中读走数据,为接收后续数据腾出空间(如果应用程序不将接收到的数据读走,它们就会一直停留在TCP接收缓冲区中),当我们希望一个请求能在发出后立即就收到对方的响应的时候,就可以将发送报文的PSH置1,这样,接收方在接收到该报文后,不需要等到TCP缓存满了才交付给上层进行处理,而是直接交付到上层
- 复位RST:用于复位相应的TCP连接,表示要求对方重新建立连接
- 同步SYN:表示请求建立一个连接,置1表示这是一个连接请求或者连接接收报文
- 终止FIN:表示通知对方本端要关闭连接了
16位窗口大小
是TCP流量控制的一个手段
这里说的窗口,指的是接收通告窗口(Receiver Window,RWND)
它告诉对方本端的TCP接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度
16位检验和
由发送端填充,接收端对TCP报文段执行CRC算法以检验TCP报文段在传输过程中是否损坏。注意,这个校验不仅包括TCP头部,也包括数据部分这也是TCP可靠传输的一个重要保障
16位紧急指针
仅在URG=1的时候有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置注意:即使窗口位0时也可以发送紧急数据
为什么需要TCP协议
因为IP层(网络层)是不可靠的,它不能保证网络包的交付、网络包的按序交付、网络包中的数据完整性,那么就只能依靠上层来保证了,而TCP就因此而设计出来,所以TCP是一个可靠的协议,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的
TCP连接
什么是TCP连接
用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接
所以我们可以知道,建立一个TCP连接是需要客户端与服务器端达成上述三个信息的共识
- Socket:由IP地址和端口号组成
- 序列号:用来解决乱序问题
- 窗口大小:用于流量控制
TCP四元组
- 源端口
- 目的端口
- 源IP地址
- 目的IP地址
TCP四元组可以唯一确定一个连接
源地址和目的地址的字段(32位)是在IP头部中,作用是通过IP协议发送报文给对方主机
源端口和目的端口的字段(16位)是在TCP头部中,作用是告诉TCP协议应该把报文发给哪个进程
所以一个服务器一个端口可以理论上可以监听2^32(客户端IP地址)×2^16(客户端端口)
的连接,当然了,这是理论上,而且是IPV4的情况下,原因:
- 首先主要是文件描述符限制,Socket都是文件,所以首先要通过ulimit配置文件描述符的数目
- 另一个是内存限制,每个TCP连接都要占用一定内存,操作系统是有限的
TCP是面向连接的协议,所以使用TCP前必须先建立连接,而建立连接是通过三次握手而进行的
三次握手
一开始服务端和客户端都是关闭的(CLOSE状态),三次握手只能由客户端发起,服务端没办法主动发起连接,服务端需要先进入LISTEN状态,在SOcket中可以利用listen()去主动监听某个端口
三次握手详解
- 第一次握手:由客户端率先发起,客户端发送一个请求连接报文给客户端
该请求报文中,需要将SYN标志位置为1,代表请求连接,Seq Num是该报文的序号,我们假设为X,是计算机随机生成的,客户端发送完请求连接报文后,进入SYN_SENT状态这一次握手的请求报文不能包含数据
- 第二次握手:服务接收到客户端的请求连接报文后,会进行第二次握手,此次握手由服务端发送确认报文
该确认报文中,需要将SYN标志位置为1,代表连接请求,ACK位置为1,表示服务端收到了客户端的请求连接,确认应答号Ack Num=X+1,代表希望下次收到数据包的序列号,同时这个报文也会随机生产一个序列号,我们假设为Y,服务端接收到请求报文后,就会从LISTEN状态转变为SYN_RCVD状态,一直持续到第三次握手结束这一次的确认报文也不会携带任何数据
- 第三次握手:客户端接收到服务端发过来的确认报文后,会进入ESTABLISHED状态,然后也会发送一个确认报文给服务端
确认报文中,需要将ACK置为1,这里就不需要将SYN置为1了,同时,Ack Num=Y+1
服务端接收到客户端发送的确认报文后,状态也从SYN_RCVD转变成ESTABLISHED这次握手,客户端可以在报文后面附带其他数据,如果有的话
- 到此为止,服务端就可以与客户端进行通信啦!
为什么是三次握手?不能是二次或者四次嘛?
首先我们知道了什么是TCP连接,那么我们就得知道为什么三次握手才可以初始化Socket、序列号和窗口大小并建立 TCP 连接
原因一:避免历史连接
首先我们需要知道,网络是错综复杂的,我们没法保证报文的每次传输都能成功被接受到,那么就会出现一个问题,如图
- 假设此时网络拥堵
- 当我们先发送了一个序列号为90的请求报文,因为网络拥堵,导致客户端迟迟没有接受到服务端的请求报文,那么此时客户端就会认为请求报文丢失,就会重新发送一个序列号为100的请求报文(这里的序列号都是我们假设的)
- 此时序列号为90的旧请求报文就会比序列号为100的新请求报文先到达服务端,此时服务端就会接收到旧请求报文,然后就会返回一个确认序号为90+1的确认报文
- 客户端接收到服务端发送的确认报文后,由于客户端最后发送的是序列号为100的新请求报文,所以客户端希望收到的是确认序列号为100+1的确认报文,当客户端收到服务端发来的确认序列号为90+1的确认报文后,就会发现这是一个历史连接(序列号过期或超时),那么客户端就会发送RST置为1的报文,表示终止这次连接
如果是两次握手,就不能判断当前连接是否是历史连接(因为如果是两次握手,那么第二次握手后会直接进入发送数据状态),三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接
所以,TCP三次握手建立连接的最主要原因就是防止历史连接初始化了连接
原因二:同步双方初始序列号
TCP 协议的通信双方,都必须维护一个序列号,序列号是可靠传输的一个关键因素,它的作用:
- 接收方可以去除重复的数据
- 接收方可以根据数据包的序列号按序接收
- 可以标识发送出去的数据包中,哪些是已经被对方收到的
序列号在TCP连接中占据着非常重要的作用,所以当客户端发送携带初始序列号的SYN报文的时候,需要服务端回一个ACK应答报文,表示客户端的SYN报文已被服务端成功接收,那当服务端发送初始序列号给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步这样看来,是需要四次握手(双方发送初始序列号后都需要得到对方的确认)才可以成功连接的,但是我们可以将第二次握手和第三次握手合成一次握手,所以只需要四次握手
原因三:避免资源浪费
如果只有两次握手,当客户端的SYN请求连接在网络中阻塞,客户端没有接收到ACK报文,就会重新发送 SYN由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接
这样就会出现这样一种情况:如果客户端的SYN阻塞了,重复发送多次SYN报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费
TCP三次握手中报文丢失会如何处理?
第一次握手报文丢失
当第一次握手的报文丢失时,客户端会一直没办法收到服务端的确认请求报文,那么此时客户端就会认为请求报文丢失,那么就会重新组织一个请求报文并发送,同样的,当第一次握手报文阻塞的时候,也会如此
具体重传几次,要看tcp_syn_retries
内核参数,一般默认是5次要注意的是,重传的请求连接报文的seq序列号字段还是之前的seq,不会重新生成
客户端发送完请求报文后会有一个定时器,定时器结束后没收到确认报文就会认为请求报文丢失了捏,定时器时间大多为3秒,6秒,12秒,这也算TCP协议保证可靠的原因之一:超时重传机制
第二次握手报文丢失
第二次握手报文丢失的话,客户端也没办法收到服务端传来的确认报文,那么客户端也还是会认为是自己的请求报文丢失,所以就像第一次握手报文丢失一样,会进行重传连接请求
但是因为服务端发送的不光是确认报文,要知道,这个报文里面SYN也置为1了,所以这个报文也表示要建立连接的请求报文,所以当第二次握手报文走丢后,服务端等不到自己请求的回应,所以也会重传报文
第三次握手报文丢失
当客户端接收到第二次握手报文后,客户端就会进入ESTABLISHED状态,服务器迟迟得不到ACK报文,但是ACK报文丢失,ACK报文是不会有重传的(当 ACK 丢失了,就由对方重传对应的报文)
所以当到达服务器的超时重传时间后,服务器会认为是第二次握手报文丢失,所以会超时重传第二次报文,当达到最大超时重传次数还没得到ACK报文,服务器就会断开连接
每次超时重传的时间是上一次超时重传时间的两倍
是不是只要三次握手成功后,客户端就能和服务端成功连接并通信呢?
并不是这样的,在我们服务端接收到最后一次握手报文的时候,还需要进行一系列的判断来确认是否可以与客户端进行连接
在此之前,我们先需要知道,在进行TCP三次握手的时候,Linux会为其维护两个队列:全连接队列(accept队列)和半连接队列(syn队列)
在客户端发起第一次连接的时候,服务端会将其加入半连接队列,并且响应客户端的SYN+ACK报文
半连接队列有着最大长度的限制,当超出限制的时候,内核就会丢弃这个连接,并且返回RST包
1 | netstat -natp | grep SYN_RECV | wc -l //查看半连接队列长度 |
syn攻击
因为半连接队列只要没有收到对应的第三次握手,那么该连接就不会从半连接队列中取出,如果有大量的连接没被取出,半连接队列很容易达到最大长度
假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务
解决办法
方法一:通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理
- 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包
控制该队列的最大值如下参数:net.core.netdev_max_backlog - SYN_RCVD 状态连接的最大个数:net.ipv4.tcp_max_syn_backlog
- 超出处理能时的处理
- net.ipv4.tcp_abort_on_overflow=0:如果全连接队列满了,那么服务端丢弃ack报文
- net.ipv4.tcp_abort_on_overflow=1:如果全连接队列满了,那么服务端会向客户端发送RST报文,终止这个握手连接
方法二:启动cookie
当应用程序处理速度过慢的时候,会导致全连接队列达到最大值,当遭受SYN攻击的时候会导致半连接队列达到最大值,此时可以用过设置net.ipv4.tcp_syncookies = 1
的方法来开启cookie,开启cookie的流程如图:
- 当SYN 队列满之后,后续服务器收到 SYN 包,不进入SYN 队列
- 计算出一个cookie值,再以SYN + ACK中的序列号返回客户端,服务端接收到客户端的应答报文,服务器会检查这个ACK包的合法性。如果合法,直接放入到Accept 队列
- 最后应用通过调用accpet()socket接口,从Accept 队列取出的连接
方法三:减少第二次握手报文重传次数
因为我们在收到syn攻击时,服务端会重传syn+ack报文到最大次数,才会断开连接。针对syn攻击的场景,我们可以减少ack+syn报文的重传次数,使处于syn_recv状态的它们更快断开连接修改重传次数:/proc/sys/net/ipv4/tcp_synack_retries
等到客户端返回对第二次握手的确认报文时,服务端将该连接从半连接队列中取出,并新建一个新的连接,加入到全连接队列中,等待进程调用accept()函数的时候,将该连接从**全连接队列取出
1 | ss //判断全连接队列的情况 |
所以一个连接进行三次握手后服务端也不一定能和客户端进行网络通信
当全连接队列已满的时候,若服务端成功接收到第三次握手的ack报文,判断tcp_abort_on_overflow
的值
- 若tcp_abort_on_overflow=0,服务端就会扔掉客户端发送的ack报文,之后一段时间服务端会重新发送第二次握手的报文,如果客户端连接一直排队不上等待超时则会报超时异常
- 若tcp_abort_on_overflow=1,服务端会发送一个reset包给客户端,表示废除这个握手过程和这个连接
1 | netstat -napt //Linux下查看TCP状态 |
四次挥手
当客户端和服务端发送完消息后,需要断开连接,当然有时候会根据HTTP头部来判断是否需要断开连接还是继续保持连接(HTTP那篇文章有讲哦)
注意,四次挥手和三次握手不一样的是,四次挥手无论是服务端还是客户端都可以主动发起,而三次握手只能由客户端主动发起!!!
四次挥手详解
这里我们假设由客户端主动发起断开连接请求
- 第一次握手,客户端组织一个断开连接请求报文,发给服务端,然后客户端进入FIN_WAIT_1状态,一直到接收到第二次挥手报文为止
该请求报文中,需要将FIN置为1,假设该报文的序号seq为X此时挥手的报文是可以附带数据的,所以可能该报文的确认序号不为0,ACK位也不一定为0
- 第二次握手,服务端接收到断开连接请求报文,组织一个确认报文发给客户端,然后进入CLOSED_WAIT状态
该确认报文中,ACK置为1,确认序列号为X+1,假设该报文的序号seq为Y因为第一次挥手的报文也可能会携带一些请求数据要求,所以该报文也可能会携带回应数据
注意,该确认报文中FIN位并不为1,至于为什么后面会说
- 客户端接收到服务端发来的确认报文后,进入FIN_WAIT_2状态
- 第三次握手,
当服务端将剩余的数据发送完毕后
,会发送一个请求断开连接报文,然后进入LAST_ACK状态
该报文位请求断开连接报文,该报文中,FIN位置为1,ACK为0,序列号假设位Z - 第四次挥手,客户端接收到服务端发送的请求断开连接报文,会发送回去一个确认报文,并进入TIME_WAIT状态,并等待2MSL时间后,进入CLOSE状态,服务端接收到确认报文后也会进入CLOSE状态
为什么是四次挥手而不是三次挥手?
当我们第二次挥手后,有人会问为什么握手阶段,可以第二次握手可以发送请求连接,而挥手阶段不能发送请求断开连接呢?
其实,断开连接请求可以在任意时刻发送,如果我们请求断开连接的时候,还有一些数据没有发送完毕呢?所以我们必须等最后一个请求被响应(也就是说服务器将所有请求处理了),才能关闭服务器
所以挥手的第二次和第三次是不可以合成一次的,而且两次挥手间会有一定的时间间隔当然了,如果第一次挥手后,对方没有任何数据需要传递的话,我们是可以认为第二次挥手报文可以和第三次挥手合成一次
为什么要等待2MSL时间才关闭呢?
我们先来了解一下什么是MSL
MSL:Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个TTL字段,是IP数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减1,当此值为 0 则数据报将被丢弃,同时发送ICMP报文通知源主机
MSL与TTL的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡
那么为要等2MSL时间呢?
这是因为,第四次挥手报文可能会丢失,如果被动关闭方没有收到断开连接的最后的ACK报文(第四次挥手报文),就会触发超时重发 Fin 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL在Linux下,一个MSL大概为30s,所以2MSL位1min,也就是说Linux停留在TIME_WAIT的时间为固定的60s
为什么要有TIME_WAIT状态?TIME_WAIT状态过短会怎么样?
主要有两个原因
如图
- 如上图黄色框框服务端在关闭连接之前发送的SEQ = 301报文,被网络延迟了
- 这时有相同端口的TCP 连接被复用后,被延迟的SEQ = 301抵达了客户端(图上画错哩),那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题
所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的
- 当客户端发起建立连接的SYN请求报文后,服务端会发送RST报文给客户端,连接建立的过程就会被终止
- 服务端没有收到四次挥手的最后一个ACK 报文时,则会重发FIN关闭连接报文并等待新的ACK报文
TIME_WAIT状态过多的危害
如果服务器有处于TIME_WAIT状态的TCP,则说明是由服务器方主动发起的断开请求
TIME_WAIT过多的危害:
- 对内存资源占用
- 对端口资源的占用,一个TCP连接至少消耗一个本地端口,这个危害就很大了,因为端口资源有限,
当服务端TIME_WAIT过多而导致占用了所有的端口资源,则会导致无法创建新的连接
CLOSE_WAIT状态过多的原因以及解决办法
CLOSE_WAIT状态是在TCP四次挥手的时候服务器收到FIN但是没有发送自己的FIN时出现的
服务器出现大量CLOSE_WAIT状态的原因有两种
- 服务器内部业务处理占用了过多时间,都没能处理完业务,或者还有数据需要发送
- 服务器的业务逻辑有问题,没有执行close()方法
服务器的父进程派生出子进程,子进程继承了socket,收到FIN的时候子进程处理但父进程没有处理该信号,导致socket的引用不为0无法回收
这里有必要说一下调用close()关闭连接的一些注意点:
- 如果有多个进程共享一个socket,close每被调用一次,计数-1,直到所有计数为0的时候,也就是所有进程都调用了colse,socket才会被释放
- 在多进程中如果一个进程调用了shutdown()后,其他进程就无法进行通信
解决办法:
- 停止应用程序
- 修改代码BUG
TCP如何保证可靠
我们知道,TCP协议是可靠,面向连接,字节流的协议,最主要的是可靠,面向连接上面已经说了,现在来说一下为什么说TCP是可靠的,TCP又是如何保证可靠的呢?
校验和
校验和是实现数据在传输过程中出错而实现的一种计算方法
计算方法:在数据传输的过程中,将发送的数据段都当做一个16位的整数。将这些整数加起来。并且前面的进位不能丢弃,补在后面,最后取反,得到校验和
发送方:在发送数据之前计算检验和,并进行校验和的填充
接收放:收到数据后,对数据以同样的方式进行计算,求出校验和,与发送方的进行比对
如果不一致,则说明数据在传输过程中出现错误,那么数据就不一定能传输成功
确认应答和序列号
看了上面的连接过程,我们直到三次握手为什么要三次的原因之一就是需要确认双方的初始序列号,而不光是三握手和挥手阶段,TCP传输数据的时候,都需要有确认应答机制来确保请求成功被接受
但是网络情况错综复杂,我们没办法保证每个请求都能被接受到,那么当一个请求没办法被服务端接收到的时候,我们就需要重新发送这个请求,这也是TCP实现可靠的方法之一
超时重传和快速重传
其实这个概念我们在上面讲TCP三次握手和四次挥手的时候就有提及过,当握手/挥手过程中一个报文丢失了就会重新发送一个新的报文
超时重传有两种情况:
- 数据包丢失
- 确认应答包丢失在上图我们可以看到,无论是**数据包丢失**或者是**确认应答包**丢失,都是当客户端接收不到想要的确认应答包才进行超时重传的
超时重传设置的时间
在此之前,我们来了解一下什么叫RRT
RRT:又叫做往返时间(Round-Trip Time),从下图可以知道,RRT就是一个包的往返时间我们设置超时重传的时间一般叫做RTO(Retransmission Timeout 超时重传时间),我们的RTO不能设置的太长或者太短
- 当我们的RTO设置地太长(比RRT还长),当我们发送的包或者确认应答包丢失了,我们等待的时间比正常一个包来往的时间还长,也就是如果这个包丢了,我们要等好久才重发,这明显是不利于网络传输的性能的
- 当我们的RTO设置地太短(比RRT还短),当我们发的包无论有没有丢失都会重发,因为客户端在RTO时间内是没办法收到一个来往需要RRT的数据包的,所以就会进行重传,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发
所以我们需要合理地设置RTO
从上面可知,我们的RTO需要略大于RRT但是不能过大
当然了,因为网络是波动的,所以每个报文地RRT不是固定的,所以我们设置的RTO也不是固定的,而是动态变化的
快速重传和超时重传有点不太一样,这是因为超时重传会带来一点微妙的问题,比如:
- 当一个报文段丢失时,会等待一定的RTO然后才重传,增加了端到端的时延
- 当一个报文段丢失时,在其等待超时的过程中,可能会出现这种情况:
其后的报文段已经被接收端接收但却迟迟得不到确认(这是因为TCP采用的是累计确认机制),发送端会认为也丢失了,从而引起不必要的重传,既浪费资源也浪费时间
TCP累计确认机制
也就是当接收端接收到比期望序列号大的报文时,便会重复发送最近一次确认的报文段的确认信号,我们称之为冗余ACK(duplicate ACK)
举个例子:
- 当我们发送序列号为1的报文时,接收端如果成功接收便会返回确认报文,确认报文中的确认序列号(ack)为2,也就是期待下次收到的序列号
- 当客户端发送序列号为2的报文丢失了,然后发送了序列号为3的报文,此时接收方收到了序列号为3的报文,但是由于该报文的序列号不是自己期待收到的序列号为2的报文,于是又会发送一个ack为2的报文
这就是冗余ACK
超时重传是在RTO时间内没有收到期望的确认应答报文而触发的机制,而快速重传是客户端连续收到三个相同确认应答包(其实是4个,不过第一个是正常的ACK确认报文,后面连续三个都是冗余ACK报文)而触发的机制
在上图,发送方发出了 1,2,3,4,5 份数据
- 第一份Seq1先送到了,于是就Ack回2
- 结果Seq2因为某些原因没收到,Seq3到达了,于是还是Ack回2
- 后面的Seq4和Seq5都到了,但还是Ack回2,因为Seq2还是没有收到
- 发送端收到了三个Ack = 2的确认,知道了Seq2还没有收到,就会在定时器过期之前,重传丢失的Seq2
- 最后,接收到收到了Seq2,此时因为Seq3,Seq4,Seq5都收到了,于是Ack回6
所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题
比如对于上面的例子,是重传Seq2呢?还是重传Seq2、Seq3、Seq4、Seq5呢?因为发送端并不清楚这连续的三个 Ack 2是谁传回来的
根据 TCP 不同的实现,以上两种情况都是有可能的可见,这是一把双刃剑
SACK
SACK,又叫做(Selective Acknowledgment 选择性确认),也是一种重传方式
这种方式需要在 TCP 头部选项字段里加一个SACK的东西,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据
当发送方200~299的报文丢失后,会收到连续四个ack为200的确认应答报文,第一个是正常报文,接下来连续三个是冗余报文,其中SACK都是从300开始,那么发送发就知道是200~299的报文丢失了而导致没被接受到如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)
D-SACK
D-SACK是利用SACK来告诉发送方有哪些数据被重复接收了
例如:
ACK确认应答丢失
1. **接收方**发给**发送方**的两个**ACK确认应答**都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499) 2. 于是**接收方**发现数据是重复收到的,于是回了一个**SACK = 3000~3500**,告诉**发送方**3000~3500的数据早已被接收了,**因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK** 3. 这样**发送方**就知道了,数据没有丢,是**接收方**的 ACK 确认报文丢了网络延时
1. 数据包(1000~1499) 被网络延迟了,导致**发送方**没有收到**ACK1500**的确认报文。 2. 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了**接收方** 3. 所以**接收方**回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个**SACK 是 D-SACK**,表示收到了重复的包 4. 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了D-SACK的好处
- 发送方可以知道是哪部分数据丢失,可以只重传这一部分数据
- 发送方可以知道重传的原因,是因为自己的发送报文丢失还是因为请求报文丢失
- 可以知道是不是发送方的数据包被网络延迟了
- 可以知道网络中是不是把发送方的数据包给复制了
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)
流量控制
我们知道,当接收方接收到数据后,需要对数据进行处理,接收方处理请求的是需要一定时间的,虽然单个请求处理时间会很短,但是当请求数量数以百万计的时候,所耗费的时间是很大的,如果此时发送方还继续发送数据的话,接收方是处理不过来的(不要啊,”被大大的数据塞满”这种事情,我不接受啊!(艾伦坐)),这样显然是不好的,所以我们需要控制发送方的发送速度,而依据就是接收方处理速度,这就是TCP可靠的原因之一,流量控制
流量控制的机制:滑动窗口
TCP用来实现流量控制的方法就是依靠滑动窗口,在此之前,我们还是一样需要知道什么是窗口,为什么要有窗口,窗口的出现是解决了什么问题知其然,我们还需要知其所以然
什么是窗口
窗口实际上是操作系统开辟的一块缓冲区, 发送方发送数据就会将数据存放在缓冲区里面,如果发送方收到接收方回应的确认应答,这个数据就会从缓冲区里面删除,如果没收到就需要保留在缓冲区,TCP头部字段中有一个16位的窗口大小
为什么引入窗口,解决了什么问题
我们都知道,TCP是运用应答机制来确保数据的传达从而实现TCP的可靠,每次请求都需要收到确认才会进行下一次的请求就好像你和喜欢的人聊天一样,她不回你你就不敢继续往下聊了,因为你喜欢她,你害怕你多说一句她就会觉得你烦,只有等她回你"嗯","哈哈","对"的时候,你才能继续往下聊,真惨啊~
但是!你为什么要这样卑微?自卑让你弯下身段,派大星要你站起来!
我们要先爱自己,她不理老子,老子照样给你发消息,至于发多少那要看哥对你喜欢的程度以及你能接受多少的程度了
TCP也是这样认为的,所以它开辟了一个叫做窗口的东西,窗口的大小就是无需等待确认应答,而可以继续发送数据的最大值
假设窗口大小为3,也就是说发送方可以连续发送3个TCP段而不需要等待回应,并且中途若有 ACK 丢失,可以通过下一个确认应答进行确认
知道了窗口的概念,现在我们来看看TCP是如何利用窗口来达到流量控制的目的
TCP头部有一个16位的窗口大小字段这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来
所以,通常窗口的大小是由接收方的决定的,发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据
发送方的滑动窗口
如上图所示(实际上不一定是这样的)
当我们将可用窗口的数据(也就是46~51)全部发送出去后,可用窗口的大小就会变为0,表明在没有收到ACK确认前是没办法继续发送数据了
当发送方收到之前发送的数据(32~36)的ACK时,如果发送窗口大小没有变化的话,整个发送窗口往右移5个字节,也就是已经确认收到的字节,那么此时,52-56这5个字节也就又变成了可用窗口
在程序中,TCP是这样表示这四个部分的
- SND.WND:表示发送窗口的大小(大小是由接收方指定的)
- SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是发送窗口的第一个字节
- SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是可用窗口的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了
那么可用窗口大小的计算就可以是:可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方的滑动窗口
- RCV.WND:表示接收窗口的大小,它会通告给发送方
- RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节
- 指向 #4 的第一个字节是个相对指针,它需要RCV.NXT指针加上RCV.WND大小的偏移量,就可以指向 #4 的第一个字节了
接收方的滑动窗口大小和发送方的滑动窗口并不是相等的,而是约等的,是因为传输过程存在延迟
我们举个栗子来讲一下TCP利用滑动窗口来做到流量控制的过程
举个栗子
我们依旧假设客户端为发送方,服务器为接收方(因为TCP连接也可以是服务端与服务端的连接,所以这边只是假设)
- 双方通过三次握手建立连接,在三次握手的过程中,客户端会通过报文中的窗口大小字段告知服务端,服务端也会将自己的窗口大小设置成一样的,这边假设为400个字节吧
- 我们假设客户端有1000字节的代发数据,每个TCP包为100个字节
- 因为客户端的窗口为400字节,代表可以连续发送400个字节的数据,在这里就是可以连续发送4个TCP包而无需等待应答,此时客户端的SND.WND=400,SND.NEXT=0,SND.UNA=0
- 因为此时客户端的可用窗口为400,所以可以发送400个字节,每发送一个TCP包,SND.NEXT都要+100,所以发送完序列号1`100的TCP包后,SND.NEXT指向101字节,SND.UNA是不变的,因为还没有接收到确认报文
- 就这样,当发送方的可用窗口变为0的时候,也就是SND.NEXT指向401的时候,发送方就不能继续发送数据了
- 在服务端的角度来看的话(服务端也有发送窗口,这里我们就先关注接收窗口),RCV.WND=400,RCV.NEXT=0,当服务端收到第一个报文,也就是序列号为1~100的报文时,RCV.NEXT=101(下一个期望收到的序列,也就是应答报文的ack),整个接收窗口向后移动100个字节
- 发送方接收到客户端发送来的应答报文后,根据ack来确认报文送达,SND.UNA往右移动100个字节,此时可用窗口大小就=400-(401-101)=100,相当于发送窗口右移100个字节,这也是为什么说是滑动窗口
这个栗子里,我们假设接收窗口和发送窗口是不变的,在是我们知道窗口实际上是一块操作系统内存缓冲区,而大小是会被操作系统调整的
操作系统如何影响接收窗口和发送窗口?
还是看看下面两个例子(客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为360)
- 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为360
- 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据
- 客户端发送140字节数据后,可用窗口变为220(360 - 140)
- 服务端收到140字节数据,但是服务端非常繁忙,应用进程只读取了40个字节,还有100字节占用着缓冲区,于是接收窗口收缩到了260(360 - 100),最后发送确认信息时,将窗口大小发送给客户端
- 客户端收到确认和窗口通告报文后,发送窗口减少为260
- 客户端发送 180 字节数据,此时可用窗口减少到80
- 服务端收到 180 字节数据,但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了80(260 - 180),并在发送确认信息时,通过窗口大小给客户端
- 客户端收到确认和窗口通告报文后,发送窗口减少为 80
- 客户端发送 80 字节数据后,可用窗口耗尽
- 服务端收到 80 字节数据,但是应用程序依然没有读取任何数据,这80字节留在了缓冲区,于是接收窗口收缩到了0,并在发送确认信息时,通过窗口大小给客户端
- 客户端收到确认和窗口通告报文后,发送窗口减少为0
可见最后窗口都收缩为 0 了,也就是发生了窗口关闭窗口关闭
窗口关闭指的是:如果窗口大小为0时,就会阻止发送方给接收方传递数据,直到窗口变为非0为止
窗口关闭的潜在危害:死锁
我们假设这样一个情况- 接收端因为来不及处理数据,所以数据填满了接收窗口(也就是缓冲区),发送确认报文的时候,报文里的窗口大小就为0,发送端接收到消息后,将自己的发送窗口也变为0,此时发送端将无法发送消息
- 等接收端处理完数据后,会向对方发送一个窗口非0的ACK报文
- 这个时候,这个ACK报文在网络中走丢了,那么发送方就永远接受不到接收窗口非0的这个消息,所以发送方的发送窗口就一直为0,一直没办法发送数据了
解决办法:
TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器,如果持续计时器超时,就会发送窗口探测(Window probe)报文(这个即使接收窗口为0也可以接收到),而对方在确认这个探测报文时,给出自己现在的接收窗口大小:- 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器
- 如果接收窗口不是 0,那么死锁的局面就可以被打破了
窗口探查探测的次数一般为 3 此次,每次次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发RST报文来中断连接
当服务端系统资源非常紧张的时候,操心系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象
- 客户端发送 140 字节的数据,于是可用窗口减少到了220
- 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了100字节,又因为应用程序没有读取任何数据,所以140字节留在了缓冲区中,于是接收窗口大小从360收缩成了120,最后发送确认信息时,通告窗口大小给对方
- 假设此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了120,客户端只会看自己的可用窗口还有220,所以客户端就发送了180字节数据,于是可用窗口减少到40
- 服务端收到了180字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了
- 此时客户端收到第2步服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值
所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象
为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间在减少缓存,这样就可以避免了丢包情况
拥塞控制
拥塞控制也是TCP实现可靠的重要手段之一,和流量控制不同的是,拥塞避免是争对网络传输而做出的一系列举措
网络错综复杂,当网络特别拥挤阻塞的时候,我们会经常出现数据包丢失、时延等问题,这样就会要用超时重传来解决,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大,这显然是不利于网络传输的
所以TCP只好牺牲自我,来降低发送量,啊!TCP可真是无私啊~
那么,为了在发送方调节所发送的数据量,我们需要通过拥塞窗口来控制
拥塞窗口是什么?和发送窗口有什么关系?
拥塞窗口cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的
我们在前面提到过发送窗口swnd和接收窗口rwnd是约等于的关系,那么由于引入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd)
拥塞窗口(cwnd)的变化规则:
- 只要网络中没有出现拥塞,cwnd就会增大
- 一旦网络中出现拥塞,cwnd就会减小
如何知道网络出现拥塞呢?
其实只要发送方没有在规定时间内接收到ACK应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞
拥塞控制算法
- 慢开始(慢启动):当我们的TCP在三次握手建立连接后,首先就是慢启动过程,顾名思义,就是一点点慢慢地增加拥塞窗口的大小,
TCP规定,只要发送方收到一个ACK确认报文,cwnd就加一
慢启动
我们假设cwnd和swnd一样,一开始初始为1
- 发送一个MSS大小的数据,等接收到一个ACK后,cwnd+1(cwnd=2),于是下一次就可以发送两个MSS大小的数据了
- 发送两个MSS大小的数据,会接收到两个ACK,cwnd+2(cwnd=4),下一次就可以发送四个了
- 发送四个MSS大小的数据,会接收到四个ACK,cwnd+4(cwnd=8),下一次就可以发送八个了
…
会发现,这一阶段的cwnd是以2为次方的指数增长,学过数学的都知道指数爆炸吧,也就是越到后面变化的越夸张,所以我们也不能一直这样下去,需要设定一个阈值,当cwnd小于阈值的时候,使用慢启动算法,等大于阈值的时候,就需要用到拥塞避免算法
2. 拥塞避免:该算法就是为了防止指数爆炸的情况,其实也是一个cwnd增加的算法,不过该算法是线性增加的
就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传当触发了重传机制的时候,就会进入拥塞发生算法
3. 拥塞发生:
当发送方经过一定时间(RTO)也没有收到ACK报文时,也就是超时了,就会使用拥塞发生算法,我们需要将原本慢启动的阈值变成cwnd的一半(cwnd/2),然后重新从慢启动开始,如下所示:
使用超时重传的话,需要重新从慢启动开始,这很让人不甘心,在上面我们说过,如果在RTO时间内,接收到连续四个相同的ACK的话,就可以使用快速重传,同样的,当我们收到四个相同的ACK时候,我们会选择另一种拥塞发生算法:快速回复
TCP认为这种情况不严重,因为大部分没丢,只丢了一小部分,则阈值和cwnd变化如下:
- cwnd = cwnd/2 ,也就是设置为原来的一半
- 阈值 = cwnd
进入快速恢复算法
算法如下:
- 拥塞窗口 cwnd = 阈值 + 3(3 的意思是确认有 3 个数据包被收到了)
- 重传丢失的数据包
- 如果再收到重复的 ACK,那么 cwnd 增加 1
- 如果收到新数据的 ACK 后,设置 cwnd 为 阈值,接着就进入了拥塞避免算法
整个过程如下:
TCP的拆包和粘包问题
粘包:因为我们知道TCP是基于数据流的协议,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(Nagle 算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包
这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界的
假设客户端发送了两个数据包D1和D2,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:
- 服务端分两次读取到了两个独立的数据包,分别是 D1 和 D2,没有粘包和拆包
- 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为TCP粘包
- 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为TCP拆包
- 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容 D1_2 和完整的 D2 包
导致出现粘包和拆包的原因
- 发送端等待缓冲区满才进行发送,造成粘包
- 接收方来不及接收缓冲区内的数据,会将一个数据包拆除几个小的数据包,造成拆包
- 由于TCP协议在发送较小的数据包的时候,会将几个包合成一个包后发送
解决粘包和拆包的办法
- 发送定长包:如果每个消息的大小都是一样的,如果不够长就通过补充空格的方式来使其与其他数据包大小一致,那么在接收对等方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息
- 包头加上包体长度:包头是定长的 4 个字节,说明了包体的长度。接收方先接收包头长度,依据包头长度来接收包体
- 在数据包之间设置边界,如添加特殊符号 \r\n 标记。FTP 协议正是这么做的。但问题在于如果数据正文中也含有 \r\n,则会误判为消息的边界
- 使用更加复杂的应用层协议
UDP协议
什么是UDP
UDP协议与TCP协议不同在,UDP协议是无连接,尽最大可能交付,面向报文的不可靠协议
无连接
即发送数据之前不需要建立连接(当然,发送数据结束时也没有连接可释放),因此减少了开销和发送数据之前的时延
尽最大可能交付
即不保证可靠交付,因此主机不需要维护复杂的连接状态表
面向报文
发送方的 UDP 对应用程序交下来的报文,在添加首部后就向下交付 IP 层。UDP 对应用层交下来的报文,既不合并,也不分拆,而是保留这些报文的边界。这就是说,应用层交给 UDP 多长的报文,UDP 就照样发送,即一次发送一个报文
UDP头部
UDP头部字段
- 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程
- 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和
- 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计
TCP和UDP的区别
连接
- TCP 是面向连接的传输层协议,传输数据前先要建立连接
- UDP 是不需要连接,即刻传输数据
服务对象
- TCP 是一对一的两点服务,即一条连接只有两个端点
- UDP 支持一对一、一对多、多对多的交互通信
可靠性
- TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达
- UDP 是尽最大努力交付,不保证可靠交付数据
拥塞控制、流量控制
- TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
- UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
首部开销
- TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。
- UDP 首部只有 8 个字节,并且是固定不变的,开销较小
这也是为什么TCP头部有一个首部长度字段而UDP没有的原因,而UDP有包长度字段而TCP没有的原因也很简单:
- TCP数据长度=IP总长度–IP首部长度-TCP首部长度(TCP有该字段)
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的
- 当然,UDP也可以通过该公式计算,但是!为了网络设备硬件设计和处理方便,首部长度需要是 4字节的整数倍,如果去掉了包长度,那首部长度就不是4字节的整数倍了
TCP和UDP的应用场景
- 由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
- FTP 文件传输
- HTTP / HTTPS
- 由于 UDP 面向无连接,它可以随时发送数据,再加上UDP本身的处理既简单又高效,因此经常用于:
- 包总量较少的通信,如 DNS 、SNMP 等
- 视频、音频等多媒体通信
- 广播通信
结语
这篇文章花了我3天的时间来写,你别说,TCP要记的东西真的好多,不光是因为这个,也因为我最近写笔试写麻了,要不容易熬到周六了,周末还有一个腾讯的海笔,下周周五晚上直接两个笔试,我也只能选一个,大概率选阿里吧,大厂虐我千百遍,我待大厂如初恋捏,即使他们都是海笔,好像赶紧找一个实习啊,一点也不想去学校安排的那个破实习
明天周六啦!新海诚的电影上线啦!我明天要一个人去看!一个人怎么了,也没什么不好的嘛哈哈~
晚安,垃圾世界~
- 标题: 传输层相关协议
- 作者: 这题超纲了
- 创建于: 2023-03-22 17:01:46
- 更新于: 2023-06-23 14:47:18
- 链接: https://qx-gg.github.io/2023/03/22/blog14/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。