Skip to main content
  1. internet/

理解TCP握手和挥手

·3548 words·8 mins·

TCP三次握手和四次挥手是面试的经典问题,网上这方面的资料繁多但往往局限在表面,只有全面了解TCP协议才能做到知其然且知其所以然。

TCP协议位于传输层,介于应用层和网络层中间,负责提供可靠全双工的连接服务。

TCP协议的可靠性体现在多个方面,如ACK机制、强制维持校验和、重传机制等等。今天我们从可靠性的角度来看握手和挥手的过程。

ACK机制 #

ACK机制往往用来确保数据被正常读取或者消费,比如说Kafka、Rabbitmq提供了ACK机制确保数据被正常消费。

在TCP协议中也是用ACK机制来确保数据被正常接收,也就是当接收数据的一方获得数据后,会向发送方发送一条ACK消息表示自己接收到了这条消息。于是我们需要一个消息标识,并且需要这个消息标识在整个通信过程中是唯一的。

唯一标识 #

在存储服务中使用的唯一标识主要有两类:自增整数、随机字符串。TCP协议使用一个自增整数来作为消息的唯一标识,这样有以下几个优点:

  1. 自增整数要比随机字符串更节省空间。
  2. 能够进行批量ACK,即对一段连续的消息回复最大的自增值即可表示这批消息都被正常接收。
  3. 能够检查是否有数据包遗漏。

自增规则 #

自增值并不是从0开始,而是在开始连接时初始化的一个随机值,这是因为如果将自增值初始化为一个固定值,那么通信过程容易被预测并攻击。

自增值也不是每条新消息都会加1,而是加上消息体(排除掉TCP头部的数据)的字节大小,这样做是因为每条消息可能很大,也可能很小,使用消息体的字节大小能够更好的表示当前通信的累计大小,也能更好的控制发送频率。

对于通信中的双方,发送方需要提供当前发送消息的序列号(这个序列号就是自增值),接收方需要将这个序列号加上消息体的字节数作为ACK号(也是发送方下一个消息使用的序列号)返回给发送方表示自己已经收到了这么多的数据。

ACK消息的可靠性 #

TCP协议通过ACK机制实现了通信确认的可靠性,但是ACK消息本身没有确认机制——发送消息的一方在接收到ACK消息后不会再次发送ACK消息来告知接收方自己收到了这条ACK消息,否则就会导致死循环。

但是我们也不需要保证ACK消息能被正常接收,因为ACK消息存在的意义就是保证用户的消息能够被接收方接收,因此如果发送方没有收到这条消息的ACK,那么就重新发送就好了。这就涉及到了TCP协议中的超时重传机制

全双工 #

TCP协议是全双工的,即能够进行两个方向的数据发送,并且两个方向的数据发送是彼此独立的。

握手 #

作用 #

握手是通信前的一个准备过程,主要有以下几方面的作用:

  1. 在双发通信前需要确认双方能够正常通信。
  2. 传递通信所需的初始化信息,如序列号、窗口大小、MSS等

三次握手 #

第一次握手,客户端需要向服务端发起连接,主要用来告知对方以下消息:

  1. 客户端想要连接的端口号,即服务端的端口号
  2. 客户端监听的端口号:如果服务端要联系客户端就要指定为这个端口
  3. 客户端初始化的序列号、窗口大小等

在这个TCP消息中,需要在头部标记SYN标识表示这条消息为第一次握手。

第二次握手,出于ACK机制的考虑,服务端需要回复客户端,但是既然服务端也要发送自己的初始化信息,那么在ACK消息中也会携带这些信息。

在ACK机制中规定了ACK消息需要返回ACK号,而ACK号的值是序列号加上消息体的字节数的结果,但是此时消息中消息体的字节数为0,但是在第二次握手时,ACK号的值为序列号加1的结果(思考为什么一定要加1?序列号加1表示这是一个需要“可靠”传输的报文段,不加1的ACK报文则不需要“可靠”传输;同时,在握手时将序列号加1,那么在往后的存在用户数据的报文段中,假设ack号为N,表示接收方已经接受了前N个(不包含N)字节的数据,并且期待接收序列号为N的报文段)。

在这个TCP消息中,需要在头部标记SYN和ACK标识。

第三次握手,处于ACK机制的考虑,客户端需要回复服务端,这就是所谓的第三次握手。

由此可见,考虑到TCP协议全双工的特性和ACK机制,双方本来需要四次握手——两次发送初始化信息+两次ACK。但是TCP进行了优化,将中间的两次握手合并,最终形成了三次握手

四次握手? #

既然三次握手是四次握手优化后的结果,那么有没有可能出现四次握手呢?

在极端情况下是可以出现的:通信双方同时发起SYN消息,这样就就没办法将一方的SYN消息和ACK消息合并,因此就会出现四次握手的情况。但是一方面很少有双方主动连接对方的场景,另一方面,这需要双方同时发起SYN消息,所以出现这种情况还是很难的。

挥手 #

作为可靠的传输协议,使用TCP协议连接的双方不能简单粗暴的直接关闭连接(当然有这种场景,等下再吐槽),否则可能会导致数据丢失。

四次挥手 #

假设是客户端发起的断开连接请求

第一次挥手,客户端要告知服务端自己需要关闭连接了。此时在TCP头部标记为FIN(实际为FIN&ACK,因为除了第一次握手外,其他时候的通信都要有ACK标记)。

第二次挥手,即服务端对客户端单纯的ACK回复。

第三次挥手,服务端等待对客户端的数据发送完后发起关闭请求,内容同“第一次挥手”。

第四次挥手,客户端对服务端单纯的ACK回复。

由此可见,四次挥手就是处于全双工特性加上ACK机制的两次关闭请求+两次ACK。

既然中间的两次挥手都是服务端向客户端发送消息,那能不能合二为一?

三次挥手? #

中间的两次挥手都是服务端向客户端发送消息,并且是有可能合并为一个消息的。如果服务端本来就要发送FIN包,这时候收到了客户端发来的FIN包,那么就可能会将FIN包和ACK包合并在一起。

经典问题 #

客户端在最后一次挥手后,为什么要等待2MSL #

正常情况下,客户端在最后一次挥手后是不会再接收到服务端的消息的,因此也就不能确定服务端是否正常接收了最后一个ACK消息。

假设客户端在最后一次挥手后没有等待2MSL,并且服务端没有接收到最后一个ACK消息:

第一种情况:客户端使用这个端口重新向这个服务端发起了连接请求,此时对于服务端来说仍处于LAST_ACK状态(第三次挥手后),因此会对新的客户端发送RST包中断连接。

第二种情况:客户端复用这个端口向其他服务端建立了连接, 由于超时重试机制,服务端就会再次向客户端发送FIN包(第三次挥手),那么新的客户端接收到这个FIN包后,就可能会造成数据冲突。

因此客户端需要等待服务端超时重试的包送达的最大时间后才能关闭连接,这个时间就是2MSL。

如果在2MSL期间端口不可用,那么如果是服务端主动关闭连接(比如重启),为什么就可以重用端口 #

服务端在启动时,往往会激活SO_REUSEADDR,即允许端口重用。

握手次数能不能减少到两次 #

三次握手前两次肯定要存在,那么就考虑能不能省略第三次握手。

第三次握手本质上就是由于ACK机制引起的消息确认,从这个角度思考,问题就变成了能不能去掉ACK机制,那么答案也就很明了了:不能,因为ACK机制是TCP协议可靠性的重要保障。

ACK机制的作用是为了确保接收端收到了发送端发送的消息,如果没有第三次握手,就没有办法保证被动连接方能够正常发送消息到主动连接方。

backlog #

如果有大量连接同时发起,应用层不能及时处理或者操作系统正在处理其他进程,那么这些连接如何处理?

TCP会将已完成握手的连接放入一个FIFO队列(全连接队列)中,应用层会从这个队列中获取已完成的连接进行处理。

同样,未完成的连接也有一个FIFO队列(半连接队列)。

两个队列的大小是固定值,如果应用层不能及时消费全连接队列导致队列已满,那么完成握手的新连接服务进入全连接队列,最终导致半连接队列被填满。半连接队列已满后将不再理会SYN报文,表现为客户端的连接状态一直为TCP_SENT,服务端的连接状态为TCP_RECV,最终客户端的连接将超时.

吐槽时间 #

有次公司服务器要从UCLOUD迁移至华为云,迁移后kingshrad服务(一个开源的数据库代理中间件)一直报客户端连接异常,本来以为是数据库迁移有问题或者新的环境服务间的通信有问题,结果测试了下都没问题。

后来通过抓包,发现客户端在进行三次握手后就发送了RST包(重置报文),而kingshard不同于普通服务直接使用http协议,kingshard实现的是数据库协议并进行了一些改写,因此当遇到RST包的时候,就会打印错误日志。

再经过分析发现是华为云服务器的健康检查搞的鬼,将健康检查关闭后报错消失。

华为云服务器的健康检查没有进行四次挥手而是直接发送RST包,是为了减少通信次数从而减少服务器压力(从三次握手+四次挥手变为了三次握手+RST包,节省了3/7的通信!),所以也不失为一种好的设计方式,只是需要服务正常处理RST包。

状态图 #

熟知TCP握手和挥手中的各个状态,能够更快的解决问题。

img