文章笔记主要引用:
一、基本认识
1.1 基本认识
TCP头部(头部报文字段)?
-
「源端口号」和「目的端口号」。源端口号就是指本地端口,目的端口就是远程端口。
-
「序列号」(32bit)。用于 TCP 通信过程中某一传输方向上字节流的每个字节的编号,为了确保数据通信的有序性,避免网络中乱序的问题。
-
「确认序列号」(32bit)。确认序列号是接收确认端所期望收到的下一序列号。确认序号应当是上次已成功收到数据字节序号加1。
-
首部长(4bit):标识首部有多少个4字节 * 首部长,最大为15,即60字节。
-
标志位(6bit):
-
URG:标志紧急指针是否有效。
-
ACK:标志确认号是否有效(确认报文段)。用于解决丢包问题。
-
PSH:提示接收端立即从缓冲读走数据。
-
RST:表示要求对方重新建立连接(复位报文段)。
-
SYN:表示请求建立一个连接(连接报文段)。
-
FIN:表示关闭连接(断开报文段)。
-
-
窗口(16bit):接收窗口。用于告知对方(发送方)本方的缓冲还能接收多少字节数据。用于解决流控。
-
校验和(16bit):接收端用CRC检验整个报文段有无损坏。
TCP是什么?
TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP连接
用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。
-
Socket:由 IP 地址和端口号组成
-
序列号:用来解决乱序问题等
-
窗口大小:用来做流量控制
TCP 四元组可以唯一的确定一个连接,四元组包括如下:
-
源地址
-
源端口
-
目的地址
-
目的端口
1.2 建立连接
TCP三次握手
- 一开始,客户端和服务端都处于
CLOSE
状态。先是服务端主动监听某个端口,处于LISTEN
状态 - 客户端会随机初始化序号(
client_isn
),将其置于 TCP 首部的「序号」字段中,同时把SYN
标志位置为1
。接着把第一个 SYN 报文发送给服务端处于SYN-SENT
状态。 - 服务端收到
SYN
报文后,首先服务端也随机初始化自己的序号(server_isn
),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入client_isn + 1
, 接着把SYN
和ACK
标志位置为1
。最后把该报文发给客户端,之后服务端处于SYN-RCVD
状态。 -
客户端收到服务端报文后,向服务端回应,首先该应答报文 TCP 首部
ACK
标志位置为1
,其次「确认应答号」字段填入server_isn + 1
,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于ESTABLISHED
状态。 -
服务端收到客户端的ACK后,也进入
ESTABLISHED
状态。此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
三次握手过程中可以携带数据吗
第一次握手不可以放数据,其中一个简单的原因就是会让服务器更加容易受到攻击(攻击者可能会大量发送数据导致服务器瘫痪)。而对于第三次的话,此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据。
为什么要三次握手?
- 三次握手才可以阻止重复历史连接的初始化(主要原因)在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接。 这次是历史连接,客户端收到ACK,发现不对,那么就会回 RST 报文来断开连接,而服务端此时已经建立连接,收到 RST 报文后,断开连接,造成资源浪费。在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费。
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费:客户端发送的
SYN
报文在网络中阻塞了,重复发送多次SYN
报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
ISN(Initial Sequence Number)是固定的吗?
起始 ISN
是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。
ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。
-
M
是一个计时器,这个计时器每隔 4 微秒加 1。 -
F
是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个Hash 随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。
三次握手的其中一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。
1.3 断开连接
TCP四次挥手
-
客户端打算关闭连接,此时会发送一个 TCP 首部
FIN
标志位被置为1
的报文,也即FIN
报文,之后客户端进入FIN_WAIT_1
状态。 -
服务端收到该报文后,就向客户端发送
ACK
应答报文,接着服务端进入CLOSE_WAIT
状态。 -
客户端收到服务端的
ACK
应答报文后,之后进入FIN_WAIT_2
状态。 -
等待服务端处理完数据后,也向客户端发送
FIN
报文,之后服务端进入LAST_ACK
状态。 -
客户端收到服务端的
FIN
报文后,回一个ACK
应答报文,之后进入TIME_WAIT
状态 -
服务端收到了
ACK
应答报文后,就进入了CLOSE
状态,至此服务端已经完成连接的关闭。 -
客户端在经过
2MSL
一段时间后,自动进入CLOSE
状态,至此客户端也完成连接的关闭。
服务器出现大量close_wait的连接的原因是什么?有什么解决方法?
当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明被动关闭方的程序没有调用 close 函数关闭连接。
那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。
我们先来分析一个普通的 TCP 服务端的流程:
-
创建服务端 socket,bind 绑定端口、listen 监听端口
-
将服务端 socket 注册到 epoll
-
epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
-
将已连接的 socket 注册到 epoll
-
epoll_wait 等待事件发生
-
对方连接关闭时,我方调用 close
可能导致服务端没有调用 close 函数的原因,如下。
第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。
不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。
第二个原因: 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。
发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。
第三个原因:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。
发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。
第四个原因:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。
可以发现,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close。
需要 TIME-WAIT 状态:
-
防止历史连接中的数据,被后面相同四元组的连接错误的接收;状态会持续
2MSL
(报文段最大生存时间)时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。 -
保证「被动关闭连接」的一方,能被正确的关闭,防止最后的ACK在网络中丢失,丢失了就是触发服务器重传FIN;
为什么要四次挥手?
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序:
- 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
- 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,
关闭的连接的函数有两种函数
- close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1,并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文。
- shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响。
1.4 Socket
建立TCP服务器的各个系统调用过程是怎样的?
1、服务器创建socket并bind()绑定socket和端口号,并listen()主动监听端口号,调用accept接收用户请求。
2、客户端创建socket,调用connect连接指定计算机,write向socket写入信息。
3、服务器read从socket中读取字符。
4、客户端close关闭socket,服务器接到关闭通知后调用close关闭socket。
二、可靠性
TCP 是怎么实现可靠传输的?
-
序列号与确认应答:TCP将每个发送的数据包进行编号(序列号),接收方通过发送确认应答(ACK)来告知发送方已成功接收到数据。如果发送方在一定时间内未收到确认应答,会进行超时重传。
-
数据校验:TCP使用校验和来验证数据的完整性。接收方会计算接收到的数据的校验和,并与发送方发送的校验和进行比较,以检测数据是否在传输过程中发生了错误。
-
窗口控制:TCP使用滑动窗口机制来控制发送方和接收方之间的数据流量。发送方根据接收方的处理能力和网络状况来调整发送的数据量,接收方则通过窗口大小来告知发送方可以接收的数据量。
-
重传机制:如果发送方未收到确认应答或接收方检测到数据错误,TCP会进行重传。发送方会根据超时时间或接收方的冗余确认来触发重传,以确保数据的可靠传输。
-
拥塞控制:TCP使用拥塞控制算法来避免网络拥塞。通过动态调整发送速率和窗口大小,TCP可以根据网络的拥塞程度来进行适当的调整,以提高网络的利用率和稳定性。
-
数据合理分片和排序(了解):tcp会按最大传输单元(MTU)合理分片,接收方会缓存未按序到达的数据,重新排序后交给应用层。而UDP:IP数据报大于1500字节,大于MTU。这个时候发送方的IP层就需要分片,把数据报分成若干片,是的每一片都小于MTU。而接收方IP层则需要进行数据报的重组。由于UDP的特性,某一片数据丢失时,接收方便无法重组数据报,导致丢弃整个UDP数据报。导致整个数据报的重发。
2.1 重传
可以解释一下RTO,RTT和超时重传分别是什么吗?
-
超时重传:发送端发送报文后若长时间未收到确认的报文则需要重发该报文。可能有以下几种情况:
-
发送的数据没能到达接收端,所以对方没有响应。
-
接收端接收到数据,但是ACK报文在返回过程中丢失。
-
接收端拒绝或丢弃数据。
-
-
RTO:从上一次发送数据,因为长期没有收到ACK响应,到下一次重发之间的时间。就是重传间隔。
-
通常每次重传RTO是前一次重传间隔的两倍,计量单位通常是略大于RTT。例:1RTO,2RTO,4RTO,8RTO......
-
重传次数到达上限之后停止重传。
-
-
RTT:数据从发送到接收到对方响应之间的时间间隔,即数据报在网络中一个往返用时。大小不稳定。
为何快速重传是选择3次ACK?
主要的考虑还是要区分包的丢失是由于链路故障还是乱序等其他因素引发。
两次duplicated ACK时很可能是乱序造成的!三次duplicated ACK时很可能是丢包造成的!四次duplicated ACK更更更可能是丢包造成的,但是这样的响应策略太慢。丢包肯定会造成三次duplicated ACK!综上是选择收到三个重复确认时窗口减半效果最好,这是实践经验。
包的丢失原因
1)包checksum 出错
2)网络拥塞
3)网络断。
于是有了fast retransmit 算法,基于在反向还可以接收到ACK,可以认为网络并没有断,否则也接收不到ACK,如果在时间内没有接收到> 2 的duplicated ACK,则概率大事件为乱序,乱序无需重传,接收方会进行排序工作;
而如果接收到三个或三个以上的duplicated ACK,则大概率是丢包,可以逻辑推理,发送方可以接收ACK,则网络是通的,可能是1、2造成的,先不降速,重传一次,如果接收到正确的ACK,则一切OK,流速依然(包出错被丢)。
而如果依然接收到duplicated ACK,则认为是网络拥塞造成的,此时降速则比较合理。
2.2 滑动窗口
2.3 流量控制
你了解流量控制原理吗?
-
目的是接收方通过TCP头窗口字段告知发送方本方可接收的最大数据量,用以解决发送速率过快导致接收方不能接收的问题。所以流量控制是点对点控制。
-
TCP是双工协议,双方可以同时通信,所以发送方接收方各自维护一个发送窗和接收窗。
-
发送窗:用来限制发送方可以发送的数据大小,其中发送窗口的大小由接收端返回的TCP报文段中窗口字段来控制,接收方通过此字段告知发送方自己的缓冲(受系统、硬件等限制)大小。
-
接收窗:用来标记可以接收的数据大小。
-
-
TCP是流数据,发送出去的数据流可以被分为以下四部分:已发送且被确认部分 | 已发送未被确认部分 | 未发送但可发送部分 | 不可发送部分,其中发送窗 = 已发送未确认部分 + 未发但可发送部分。接收到的数据流可分为:已接收 | 未接收但准备接收 | 未接收不准备接收。接收窗 = 未接收但准备接收部分。
-
发送窗内数据只有当接收到接收端某段发送数据的ACK响应时才滑动,左边缘紧贴刚被确认的数据。接收窗也只有接收到数据且最左侧连续时才移动接收窗口。
TCP 利用滑动窗口实现流量控制的机制?
目的是接收方通过TCP头窗口字段告知发送方本方可接收的最大数据量,用以解决发送速率过快导致接收方不能接收的问题。所以流量控制是点对点控制。
TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0 时,发送方一般不能再发送数据报。
但有两种情况除外,一种情况是可以发送紧急数据。例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个 1 字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。
2.4 拥塞控制
如何区分流量控制和拥塞控制?
-
流量控制属于通信双方协商;拥塞控制涉及通信链路全局。
-
流量控制需要通信双方各维护一个发送窗、一个接收窗,对任意一方,接收窗大小由自身决定,发送窗大小由接收方响应的TCP报文段中窗口值确定;拥塞控制的拥塞窗口大小变化由试探性发送一定数据量数据探查网络状况后而自适应调整。
-
实际最终发送窗口 = min{流控发送窗口,拥塞窗口}。
为什么要有拥塞控制,不是有流量控制了吗
流量控制是避免「发送方」的数据填满「接收方」的缓存。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,进入恶性循环被不断地放大....
当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。拥塞控制目的就是避免「发送方」的数据填满整个网络。
拥塞窗口 cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口 swnd
和接收窗口 rwnd
是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
-
只要网络中没有出现拥塞,
cwnd
就会增大; -
但网络中出现了拥塞,
cwnd
就减少;
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
TCP四大拥塞控制算法总结?(极其重要)
四大算法
1)慢启动,2)拥塞避免,3)拥塞发生,4)快速恢复。
1)慢启动:
慢启动的意思就是一点一点的提高发送数据包的数量。
当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1 。
发包的个数是指数性的增长。
2)拥塞避免:
慢启动门限 ssthresh
,超过了使用「拥塞避免算法」。
每当收到一个 ACK 时,cwnd 增加 1/cwnd。
变成了线性增长。
3)拥塞发生:
慢慢进入了拥塞,于是就会出现丢包现象,触发了重传机制,进入「拥塞发生算法」。
重传机制主要有两种:超时重传;快速重传。
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
-
ssthresh
设为cwnd/2
, -
cwnd
重置为1
(是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)会造成网络卡顿。
快速重传:当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh
和 cwnd
变化如下:
-
cwnd = cwnd/2
,也就是设置为原来的一半; -
ssthresh = cwnd
; -
进入快速恢复算法
4)快速恢复:
快速重传和快速恢复算法一般同时使用
快速恢复算法如下:
-
拥塞窗口
cwnd = ssthresh + 3
( 3 的意思是确认有 3 个数据包被收到了); -
重传丢失的数据包;
-
如果再收到重复的 ACK,那么 cwnd 增加 1;
-
如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
三、TCP 实战抓包
四、TCP 半连接队列和全连接队列
什么是半连接队列?
服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。
当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。
SYN攻击
SYN 攻击方式(一直发SYN)最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
避免 SYN 攻击方式,可以有以下四种方法:
-
调大 netdev_max_backlog;网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。调大此队列大小
-
增大 TCP 半连接队列;
-
开启 tcp_syncookies;「 SYN 队列」满之后,后续服务端收到 SYN 包,根据算法,计算出一个
cookie
值,发回给客户端,再次请求时可以直接带这个cookie,合法就会被加入Accept 队列。 -
减少 SYN+ACK 重传次数。服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传。如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。减少重传次数就可以快速断开连接。
DDos 攻击( TCP 半连接队列溢出)了解吗?
客户端向服务端发送请求链接数据包,服务端向客户端发送确认数据包,客户端不向服务端发送确认数据包,服务器一直等待来自客户端的确认 没有彻底根治的办法,除非不使用TCP
DDos 预防: 1)限制同时打开SYN半链接的数目 2)缩短SYN半链接的Time out 时间 3)关闭不必要的服务
五、如何优化
六、字节流
怎么理解TCP的流的概念?
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。
至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。
因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。
要解决这个问题,要交给应用程序。
TCP粘包,怎么解决?
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式:
-
固定长度的消息;
-
特殊字符作为边界;
-
自定义消息结构。
固定长度的消息
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。
但是这种方式灵活性不高,实际中很少用。
特殊字符作为边界
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
HTTP 是一个非常好的例子。
图片
HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。
有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。
struct {
u_int32_t message_length;
char message_data[];
} message;
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
封包和拆包你听说过吗?它是基于TCP还是UDP的?
封包和拆包都是基于TCP的概念。因为TCP是无边界的流传输,所以需要对TCP进行封包和拆包,确保发送和接收的数据不粘连。
- 封包:封包就是在发送数据报的时候为每个TCP数据包加上一个包头,将数据报分为包头和包体两个部分。包头是一个固定长度的结构体,里面包含该数据包的总长度。
- 拆包:接收方在接收到报文后提取包头中的长度信息进行截取。
常见TCP的连接状态有哪些?
- CLOSED:初始状态。
- LISTEN:服务器处于监听状态。
- SYN_SEND:客户端socket执行CONNECT连接,发送SYN包,进入此状态。
- SYN_RECV:服务端收到SYN包并发送服务端SYN包,进入此状态。
- ESTABLISH:表示连接建立。客户端发送了最后一个ACK包后进入此状态,服务端接收到ACK包后进入此状态。
- FIN_WAIT_1:终止连接的一方(通常是客户机)发送了FIN报文后进入。等待对方FIN。
- CLOSE_WAIT:(假设服务器)接收到客户机FIN包之后等待关闭的阶段。在接收到对方的FIN包之后,自然是需要立即回复ACK包的,表示已经知道断开请求。但是本方是否立即断开连接(发送FIN包)取决于是否还有数据需要发送给客户端,若有,则在发送FIN包之前均为此状态。
- FIN_WAIT_2:此时是半连接状态,即有一方要求关闭连接,等待另一方关闭。客户端接收到服务器的ACK包,但并没有立即接收到服务端的FIN包,进入FIN_WAIT_2状态。
- LAST_ACK:服务端发动最后的FIN包,等待最后的客户端ACK响应,进入此状态。
- TIME_WAIT:客户端收到服务端的FIN包,并立即发出ACK包做最后的确认,在此之后的2MSL时间称为TIME_WAIT状态。
什么是TCP粘包/拆包?发生的原因?
一个完整的业务可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这个就是TCP的拆包和粘包问题。
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。主要原因就是进行了消息的分片。
1、应用程序写入数据的字节大小大于套接字发送缓冲区的大小.
2、进行MSS大小的TCP分段。( MSS=TCP报文段长度-TCP首部长度)
3、以太网的payload大于MTU进行IP分片。( MTU指:一种通信协议的某一层上面所能通过的最大数据包大小。)
一般有三种方式分包的方式:
-
固定长度的消息;
-
特殊字符作为边界;
-
自定义消息结构。
固定长度的消息
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。
但是这种方式灵活性不高,实际中很少用。
特殊字符作为边界
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
HTTP 是一个非常好的例子。
图片
HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。
有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
定义消息结构
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。
struct {
u_int32_t message_length;
char message_data[];
} message;
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
七、UDP
7.1 基本认识与区别
UDP是什么?
提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性)。
TCP和UDP的区别
1、TCP面向连接(如打电话要先拨号建立连接);UDP是无连接的,即发送数据之前不需要建立连接
2、TCP提供可靠的服务;UDP尽最大努力交付,即不保证可靠交付
3、TCP面向字节流,实际上是TCP把数据看成一连串无结构的字节流;UDP是面向报文的。
UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
4、每一条TCP连接只能是点到点的;UDP支持一对一,一对多,多对一和多对多的交互通信
5、TCP首部开销20字节;UDP的首部开销小,只有8个字节
6、TCP的逻辑通信信道是全双工的可靠信道,UDP则是不可靠信道
7、UDP是面向报文的,发送方的UDP对应用层交下来的报文,不合并,不拆分,只是在其上面加上首部后就交给了下面的网络层,论应用层交给UDP多长的报文,它统统发送,一次发送一个。而对接收方,接到后直接去除首部,交给上面的应用层就完成任务了。因此,它需要应用层控制报文的大小
TCP是面向字节流的,它把上面应用层交下来的数据看成无结构的字节流会发送,可以想象成流水形式的,发送方TCP会将数据放入“蓄水池”(缓存区),等到可以发送的时候就发送,不能发送就等着TCP会根据当前网络的拥塞状态来确定每个报文段的大小。
八、UDP的可靠性
UDP在什么情况下会丢包
-
网络拥塞:当网络中的数据量超过网络带宽的容量时,就会发生拥塞。UDP在发送数据时不会等待确认,因此在网络拥塞的情况下,UDP数据包可能会被丢弃以减轻网络负载。
-
数据包丢失:在网络传输过程中,UDP数据包可能因为网络故障、传输错误或路由问题等原因丢失。
-
接收端缓冲区溢出:如果接收端的UDP缓冲区已满,新的数据包可能会被丢弃。
-
数据包重排:由于UDP不保证数据包的顺序,如果数据包在传输过程中被重排,接收方可能会丢弃一些数据包。
-
发送速率过高:如果发送端以过快的速率发送UDP数据包,接收端可能无法及时处理所有数据包,导致一些数据包被丢弃。
-
防火墙或路由器设置:防火墙或路由器可能会对UDP数据包进行筛选或丢弃,导致数据包丢失。
-
带宽不足:如果网络的带宽不足以支持UDP数据的传输,数据包可能会被丢弃。
如何基于 UDP 协议实现可靠传输
基于 UDP 协议实现的可靠传输协议的成熟方案了,那就是 QUIC 协议,已经应用在了 HTTP/3。基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,要设计好协议的头部字段。
8.1 头部字段
在 UDP 报文头部与 HTTP 消息之间的三层头部
1、Packet Header:首次建立连接时和日常传输数据时使用的 Header 是不同的。分为:
- Long Packet Header 用于首次建立连接。
- Short Packet Header 用于日常传输数据。
2、QUIC Frame Header:一个 Packet 报文中可以存放多个 QUIC Frame。每一个 Frame 的类型不同。
3、HTTP3 Frame Header:
Long Packet Header 与 Short Packet Header 的区别
QUIC 也是需要三次握手来建立连接的,主要目的是为了协商连接 ID。协商出连接 ID 后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。所以,你可以看到日常传输数据的 Short Packet Header 不需要在传输 Source Connection ID 字段了,只需要传输 Destination Connection ID。
Packet Number
设计严格递增的原因
其是每个报文独一无二的编号,它是严格递增的,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。
TCP 在响应报文中对于重传报文的序列号和原始报文的序列号是一样的,也正是由于这个特性,引入了 TCP 重传的歧义问题。若在发生1、超时重传的时候接收到了原始报文,2、只接受到了重传报文,客户端分不清「原始报文的响应」还是「重传报文的响应」,在计算 RTT(往返时间) 时应该选择从发送原始报文开始计算,还是重传原始报文开始计算,产生混淆。
严格递增的好处:可以分清「原始报文的响应」还是「重传报文的响应」。ACK 的 Packet Number 是 N+M,就根据重传报文计算采样 RTT。如果 ACK 的 Pakcet Number 是 N,就根据原始报文的时间计算采样 RTT,没有歧义性的问题。
QUIC Frame Header:Stream 类型的 Frame 格式
- Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;
- Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性;
- Length 作用:指明了 Frame 数据的长度。
重传数据包的 Packet N+M 与丢失数据包的 Packet N 编号并不一致,我们怎么确定这两个数据包的内容一样呢?
通过 Stream ID + Offset 字段信息实现数据的有序性,通过比较两个数据包的 Stream ID 与 Stream Offset ,如果都是一致,就说明这两个数据包的内容一致。
QUIC 通过单向递增的 Packet Number,配合 Stream ID 与 Offset 字段信息,可以支持乱序确认而不影响数据包的正确组装,摆脱了TCP 必须按顺序确认应答 ACK 的限制,解决了 TCP 因某个数据包重传而阻塞后续所有待发送数据包的问题。
8.2 对头堵塞
TCP 队头阻塞问题?
当接收窗口收到的数据不是有序的,比如收到第 33~40 字节的数据,由于第 32 字节数据没有收到, 接收窗口无法向前滑动,那么即使先收到第 33~40 字节的数据,这些数据也无法被应用层读取的。只有当发送方重传了第 32 字节数据并且被接收方收到后,接收窗口才会往前滑动,然后应用层才能从内核读取第 32~40 字节的数据。
导致接收窗口的队头阻塞问题,是因为 TCP 必须按序处理数据,也就是 TCP 层为了保证数据的有序性,只有在处理完有序的数据后,滑动窗口才能往前滑动,否则就停留,停留「接收窗口」会使得应用层无法读取新的数据。
HTTP/2 的队头阻塞?
不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream ),因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同一 Stream 内部的帧必须是严格有序的。
HTTP/2 多个 Stream 请求都是在一条 TCP 连接上传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,这样也会发生TCP队头阻塞的问题。
没有队头阻塞的 QUIC
QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。
8.3 流量控制
实现方式
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别:
- Stream 级别的流量控制:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。
- Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。
Stream 级别的流量控制
可以看到,接收窗口的左边界取决于接收到的最大偏移字节数,此时的接收窗口 = 最大窗口数 - 接收到的最大偏移数
。
这里就可以看出 QUIC 的流量控制和 TCP 有点区别了:
- TCP 的接收窗口只有在前面所有的 Segment 都接收的情况下才会移动左边界,当在前面还有字节未接收但收到后面字节的情况下,窗口也不会移动。
- QUIC 的接收窗口的左边界滑动条件取决于接收到的最大偏移字节数。
绿色部分数据超过最大接收窗口的一半后,最大接收窗口向右移动,接收窗口的右边界也向右扩展,同时给对端发送「窗口更新帧」,当发送方收到接收方的窗口更新帧后,发送窗口的右边界也会往右扩展,以此达到窗口滑动的效果。
协商完毕后最大绝对字节偏移量右移,发送方的缓存区变大,同时发送方发现数据包33超时发送方将超时数据包重新编号42 继续发送。
Connection 流量控制
而对于 Connection 级别的流量窗口,其接收窗口大小就是各个 Stream 接收窗口大小之和。
8.3 拥塞控制
QUIC 是如何改进 TCP 的拥塞控制算法的呢?
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。不需要操作系统,不需要内核支持。传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。
TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。但是因为 QUIC 处于应用层,所以就可以针对不同的应用设置不同的拥塞控制算法,这样灵活性就很高了。
8.4 QUIC 更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话服用,也需要至少 2 个 RTT。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
8.5 QUIC 是如何迁移连接的?
那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址(四元组)变化了,那么就必须要断开连接,然后重新建立 TCP 连接。
而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。