@TryLoveCatch
2022-04-20T15:49:27.000000Z
字数 15429
阅读 1042
Java知识体系
35 张图解:被问千百遍的 TCP 三次握手和四次挥手面试题
关于TCP/IP,必知必会的十个问题
我们先来看看 TCP 头的格式,标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述。
理解TCP序列号(Sequence Number)和确认号(Acknowledgment Number)
TCP会话的每一端都包含一个32位(bit)的序列号,该序列号被用来跟踪该端发送的数据量。每一个包中都包含序列号,在接收端则通过确认号用来通知发送端数据成功接收
当某个主机开启一个TCP会话时,他的初始序列号是随机的,可能是0和4,294,967,295之间的任意值,然而,像Wireshark这种工具,通常显示的都是相对序列号/确认号,而不是实际序列号/确认号,相对序列号/确认号是和TCP会话的初始序列号相关联的。这是很方便的,因为比起真实序列号/确认号,跟踪更小的相对序列号/确认号会相对容易一些
比如,在“包1”中,最初的相对序列号的值是0,但是最下方面板中的ASCII码显示真实序列号的值是0xf61c6cbe,转化为10进制为4129057982
需要注意的是,接下来都是使用相对序列号/确认号
我们可以利用这个流图更好的理解序列号和确认号是如何工作的:
TCP会话的每一端的序列号都从0开始,同样的,确认号也从0开始,因为此时通话还未开始,没有通话的另一端需要确认(我使用的Wireshark版本和原作者不同,Wireshark1.10.2中,包1不显示确认号
服务端响应客户端的请求,响应中附带序列号0(由于这是服务端在该次TCP会话中发送的第一个包,所以序列号为0)和相对确认号1(表明服务端收到了客户端发送的包1中的SYN)
需要注意的是,尽管客户端没有发送任何有效数据,确认号还是被加1,这是因为接收的包中包含SYN或FIN标志位(并不会对有效数据的计数产生影响,因为含有SYN或FIN标志位的包并不携带有效数据)
和包2中一样,客户端使用确认号1响应服务端的序列号0,同时响应中也包含了客户端自己的序列号(由于服务端发送的包中确认收到了客户端发送的SYN,故客户端的序列号由0变为1)
此时,通信的两端的序列号都为1,通信两端的序列号增1发生在所有TCP会话的建立过程中
这是流中第一个携带有效数据的包(确切的说,是客户端发送的HTTP请求),序列号依然为1,因为到上个包为止,还没有发送任何数据,确认号也保持1不变,因为客户端没有从服务端接收到任何数据
需要注意的是,包中有效数据的长度为725字节
当上层处理HTTP请求时,服务端发送该包来确认客户端在包4中发来的数据,需要注意的是,确认号的值增加了725(725是包4中有效数据长度),变为726,简单来说,服务端以此来告知客户端端,目前为止,我总共收到了726字节的数据,服务端的序列号保持为1不变
这个包标志着服务端返回HTTP响应的开始,序列号依然为1,因为服务端在该包之前返回的包中都不带有有效数据,该包带有1448字节的有效数据
由于上个数据包的发送,TCP客户端的序列号增长至726,从服务端接收了1448字节的数据,客户端的确认号由1增长至1449
在抓包文件的主体部分,我们可以看到上述过程的不断的重复,客户端的序列号一直是726,因为客户端除了最初的725字节数据没有再向服务端发送数据,服务端的序列号则与此相反,由于服务端不断的发送HTTP响应,故其序列号一直在增长
序列号为当前端成功发送的数据位数,确认号为当前端成功接收的数据位数,SYN标志位和FIN标志位也要占1位
在确认了服务端发送过来的最后一个数据段之后,客户端将处理整个HTTP响应并决定不需要进一步通信了。此时客户端发送设置了FIN标志位的包38,其确认号和之前的包37一样
服务端通过将确认号加1的方式回应客户端期望关闭连接的请求(这里和包2中确认SYN标志位时所作的操作是一样的),同时设置当前包的FIN标志位
客户端发送最终序列号727,通过将确认号加1的方式确认服务端的FIN包
此时,通信双方都终结了会话并且可以释放用于维持会话所占用的资源
IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。
因为 TCP 是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
我们来看看 RFC 793 是如何定义「连接」的:
Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.
简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。
TCP 四元组可以唯一的确定一个连接,四元组包括如下:
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。
因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:
最大TCP连接数 = 客户端IP数 X 客户端端口数
对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。
当然,服务端最大并发 TCP 连接数远不能达到理论上限。
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。
UDP 协议真的非常简,头部只有 8 个字节( 64 位),UDP 的头部格式如下:
连接
服务对象
可靠性
拥塞控制、流量控制
首部开销
传输方式
分片不同
由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
由于 UDP 面向无连接,它可以随时发送数据,再加上UDP本身的处理既简单又高效,因此经常用于:
原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度则是不会变化的,无需多一个字段去记录 UDP 的首部长度。
先说说 TCP 是如何计算负载数据长度:
TCP数据长度 = IP总长度 - IP首部长度 - TCP首部长度
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
大家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”
这么一问,确实感觉 UDP 「包长度」是冗余的。
因为为了网络设备硬件设计和处理方便,首部长度需要是 4字节的整数倍。
如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以小林觉得这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。
从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
相信大家比较常回答的是:“因为三次握手才能保证双方具有接收和发送的能力。”
这回答是没问题,但这回答是片面的,并没有说出主要的原因。
在前面我们知道了什么是 TCP 连接:
所以,重要的是为什么三次握手才可以初始化Socket、序列号和窗口大小并建立 TCP 连接。
接下来以三个方面分析三次握手的原因:
我们来看看 RFC 793 指出的 TCP 连接使用三次握手的首要原因:
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.
简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
网络环境是错综复杂的,往往并不是如我们期望的一样,先发送的数据包,就先到达目标主机,反而它很骚,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?
客户端连续发送多次 SYN 建立连接的报文,在网络拥堵情况下:
如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接:
所以,TCP 使用三次握手建立连接的最主要原因是防止历史连接初始化了连接。
TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了「三次握手」。
而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
TCP 建立连接时,通过三次握手能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号。序列号能够保证数据包不重复、不丢弃和按序传输。
不使用「两次握手」和「四次握手」的原因:
如果一个已经失效的连接被重用了,但是该旧连接的历史报文还残留在网络中,如果序列号相同,那么就无法分辨出该报文是不是历史报文,如果历史报文被新的连接接收了,则会产生数据错乱。
所以,每次建立连接前重新初始化一个序列号主要是为了通信双方能够根据序号将不属于本连接的报文段丢弃。
另一方面是为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收。
起始 ISN 是基于时钟的,每 4 毫秒 + 1,转一圈要 4.55 个小时。
RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
我们先来认识下 MTU 和 MSS:
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,可以发现 MSS 的值,取决与 发送端和接收端两者较小的 MSS 的值。,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:
net.core.netdev_max_backlog
SYN_RCVD 状态连接的最大个数:
net.ipv4.tcp_max_syn_backlog
超出处理能时,对新的 SYN 直接回报 RST,丢弃连接:
net.ipv4.tcp_abort_on_overflow
我们先来看下 Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的?
正常流程:
应用程序过慢:
受到 SYN 攻击:
tcp_syncookies 的方式可以应对 SYN 攻击的方法:
net.ipv4.tcp_syncookies = 1
天下没有不散的宴席,对于 TCP 连接也是这样, TCP 断开连接是通过四次挥手方式。
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。
你可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
比如如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 Fin 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。
2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么** 2MSL 时间将重新计时。**
在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT state, about 60 seconds */
如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
需要 TIME-WAIT 状态,主要是两个原因:
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:
TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
假设 TIME-WAIT 没有等待时间或时间过短,断开连接会造成什么问题呢?
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
所以客户端在 TIME-WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。
如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。
过多的 TIME-WAIT 状态主要的危害有两种:
第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定
net.ipv4.ip_local_port_range
如果发起连接一方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。
如果开启了 TCP 保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
Linux内核中会维护两个队列:
int listen (int socketfd, int backlog)
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
我们先看看客户端连接服务端时,发送了什么?
从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
我们看看客户端主动调用了 close,会发生什么?