秋招复习笔记——八股文部分:网络TCP
TCP 三次握手和四次挥手
TCP 基本认识
序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:
- ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
- RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
- SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
- FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。为了能够可靠传递就需要上层(传输层)的 TCP 协议负责。TCP 是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。面向连接,就是指一定是「一对一」的连接,不像 UDP 可以一对多;可靠的就是指 TCP 能保证一个报文到达接收端;字节流是指 TCP 报文有序,即使后面的报文先到达也不会给应用层而是等待,同时重复报文会自动丢弃。
用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。Socket 就是由 IP 地址和端口号组成;序列号之前已经学习,就是解决乱序问题;窗口大小就是用来流量控制。
那么确认连接,依靠的就是 TCP 的四元组,包含了:源地址和目的地址,32 位在 IP 头部中;源端口和目的端口,16 位,在 TCP 头部。
上述是 TCP 理论上服务端的最大连接数,对 IPv4 来说,客户端 IP 最多 2^32,客户端端口最多 2^16,也就是说理论上最大连接数为 2^48。当然,还会有以下的影响因素:
- 文件描述符限制,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
- 系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看;
- 用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看;
- 进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看;
- 内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。
那么,为什么有 UDP 还有 TCP 呢?因为 UDP 非常简单,但不能提供复杂机制,头部只有 8 个字节:
两者的区别如下:
- 连接
TCP 是面向连接的传输层协议,传输数据前先要建立连接。
UDP 是不需要连接,即刻传输数据。
- 服务对象
TCP 是一对一的两点服务,即一条连接只有两个端点。
UDP 支持一对一、一对多、多对多的交互通信。
- 可靠性
TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。
UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议。
- 拥塞控制、流量控制
TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
- 首部开销
TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。
UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
- 传输方式
TCP 是流式传输,没有边界,但保证顺序和可靠。
UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。
- 分片不同
TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。
UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层。
所以,TCP 是保证数据的可靠性的,多用于 FTP 文件传输以及 HTTP/HTTPS;而 UDP 处理简单,常用于包总量较少的通信 DNS、SNMP 等,还有视频和广播通信等。
TCP 有「首部长度」字段,因为 TCP 是可变长的,UDP 不会变化,所以没有这个字段。
TCP 的数据长度,IP 相关的内容在 IP 首部格式中都已知,而 TCP 首部长度也可以通过头部格式中的信息获取,所以 TCP 数据长度是可以计算的,不需要「包长度」字段;UDP 则因为各种问题,是有「包长度」字段的,靠谱的说法是首部长度需要是 4 的倍数,加上这个字段就正好。
**这里注意,TCP 和 UDP 是可以使用同一个端口的。**数据链路层,MAC 地址来寻找局域网中主机;网络层通过 IP 地址寻找主机;传输层通过端口来寻址找到同一计算机中通信的不同应用程序。端口号就是为了区分同一主机上不同的应用程序的数据包。所以说,TCP、UDP 端口号相互独立,不冲突。
TCP 连接建立
三次握手
建立连接是通过三次握手来进行的。三次握手的过程如下图:
- 最开始,客户端和服务端都是 CLOSE 状态。服务端主动监听某个端口,处于 LISTEN 状态。
- 客户端随机初始化序列号(client_isn),然后 SYN 位置 1,表示 SYN 报文。然后发送 SYN 报文,这里不包含应用层数据,之后客户端处于 SYN-SENT 状态。
- 服务端收到 SYN 报文,也随机初始化自己序号(server_isn),然后把 TCP 头部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后发回给客户端,也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
- 客户端收到后,需要回应应答报文。把 ACK 标志位置 1,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
- 服务端收到后,也进入 ESTABLISHED 状态。
记住:第三次握手是可以携带数据的,前两次握手是不可以携带数据的!
在 Linux 可以通过 netstat -napt 命令查看 TCP 连接状态。
三次握手的原因
三次握手的原因(为什么一定是三次):
- 阻止重复历史连接的初始化(主要原因)
首要原因是为了防止旧的重复连接初始化造成混乱。如果客户端发了旧的 SYN 报文然后网络拥堵或者宕机,然后恢复后发了新的 SYN 报文,此时服务端回发的 SYN + ACK 报文就会对不上客户端期望的确认应答号,就会回发 RST 报文,服务端收到后就会释放连接;在之后新的 SYN 报文就到了,会重新三次握手。
两次握手,就无法阻止历史连接,因为此时服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费。要解决这种现象,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手。
- 同步双方初始序列号
TCP 协议的通信双方, 都必须维护一个「序列号」,因为依靠序列号接收方可以去除重复数据;接收方可以以此按序接收;通过序列号来标识已经被接收到的报文(通过 ACK 报文序列号)。
因此,客户端发送带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收;同理服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。通过四次握手,也就是两次互相发 SYN 和 ACK 信号,就能完成序列号同步。那么可以将服务端回复 SYN 和发送 ACK 合并到一起,就变成了同时发 SYN + ACK,也就变成了三次握手。
- 避免浪费资源
如之前所说,只有两次握手,那么就会有历史连接,因为服务端不确定客户端是否收到 ACK 报文,就只能每收到一个 SYN 报文都先主动建立一个连接。这样就会造成资源浪费。
初始序列号不同的原因
每次建立 TCP 连接,初始化的序列号不一样,是因为:
- 防止历史报文被下一个相同四元组连接接收(主要);
- 安全性,防止相同序列号 TCP 报文被接受。
针对第一个方面,例如双方已经都 ESTABLISHED 之后,客户端传资源超时,服务端断电重启,那么久会在重启的时候,建立失效进而导致服务端回发 RST 让客户端进入 CLOSED。然后如果再次建立连接,此时序列号是一样的,而上一次连接发送的那个报文正好抵达服务端,那么现在的序列号没变就会导致数据传递是有效的,那么服务端就会正常接收导致数据产生混乱。如果序列号不一样,那么第二次连接生效的序列号就发生了改变,也就不会导致接收成功了,会把该报文直接丢弃。
初始序列号随机产生
初始序列号 ISN 是基于时钟,每 4 微秒 + 1,转一圈要 4.55 个小时。随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。所以基本不会有一样的初始化序列号 ISN。
TCP 的 MSS存在意义
IP 层会分片,那为什么还要在 TCP 层设置 MSS?
IP 层的分片就是把数据(TCP 头部 + TCP 数据)发送出去,如果超过 MTU大小,就进行分片重组再交给 TCP 传输层。如果 IP 分片有一个丢失了,那么整个 IP 报文所有分片都得重传。IP 本身没有超时重传机制,那么就会造成传输层 TCP 来负责超时和重传。这样就会导致,整个 TCP 的报文都得重发。所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。这样,如果一个 TCP 分片丢失,重发也是以 MSS 为单位,不用重传所有分片,大大提升效率。
第一次握手丢失
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。
每次重传判断超时的时间是写在内核里的,最大重传次数由 tcp_syn_retries内核参数控制,这个参数是可以自定义的,默认值一般是 5。每次超时重传的时间是上一次的 2 倍。也就是说,会等到 tcp_syn_retries 次,客户端直接断开连接。
第二次握手丢失
当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD 状态。相当于发了针对第一次握手的确认 ACK 报文,同时发了服务端建立 TCP 连接的 SYN 报文。
所以,如果第二次丢失,对第一次握手的确认报文就没有了,客户端就会触发超时重传机制,重传 SYN 报文。而服务端发送的 SYN 报文也就没有回复的确认报文,服务端这边会触发超时重传机制,重传 SYN-ACK 报文。SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5。
第三次握手丢失
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。因为 ACK 报文是没有重传的,所以只能有对方重传相应的 SYN 报文。
SYN 攻击
攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的半连接队列,使得服务端不能为正常用户服务。
半连接队列,就是 SYN 队列;全连接队列,就是 accept 队列。
SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
解决方法:调大 netdev_max_backlog;增大 TCP 半连接队列;开启 tcp_syncookies(echo 把 1 写入);减少 SYN+ACK 重传次数。
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 状态,至此客户端也完成连接的关闭。
每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
挥手四次的原因
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
所以,服务端通常需要等待完成数据的发送处理,所以 ACK 和 FIN 包是分开发送的。当然特殊情况下可以变成三次挥手,之后会学习。
第一次挥手丢失
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。
如果第一次丢失,那相当于客户端一直等不到服务端 ACK,就会触发超时重传,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。每一次的等待时间都是上一次的 2 倍。如果等到 tcp_orphan_retries + 1 次,就会进入 close 状态。
第二次挥手丢失
当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT 状态。
因为 ACK 不会重传,所以第二次挥手丢失,那就会等到客户端触发超时重传,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。
当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。对于 close 函数关闭的连接,由于无法再发送和接收数据,所以 FIN_WAIT2 状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭。
但是注意,如果主动关闭方使用 shutdown 函数关闭连接,指定了只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。此时,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 FIN_WAIT2 状态(tcp_fin_timeout 无法控制 shutdown 关闭的连接)。
第三次挥手丢失
当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。
服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。如果没有 ACK,服务端会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。
第四次挥手
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。
如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
TIME_WAIT 2MSL 原因
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
2MSL时长 这其实是相当于至少允许报文丢失一次。而起始计算的时间是从客户端接收到 FIN 后发送 ACK 开始计时,如果在 TIME-WAIT 时间内客户端接收到重发的 FIN 报文,2MSL 会重新计时。
Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
为什么要 TIME_WAIT 状态
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
主要是两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭。
序列号是 TCP 一个头部字段,标识了 TCP 发送端到接收端的数据流的一个字节,是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。初始序列号,是客户端和服务端会各自生成的一个随机数,可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。两者并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。
如果服务端关闭连接前发送了 SEQ 的报文,然后服务端以相同四元组重新打开新连接,则之前的 SEQ 报文就会抵达客户端,该序列号又刚好落在客户端接收窗口内,就会使得客户端正常接收,导致数据错乱。
因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
TIME-WAIT 还有一个作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。此时就发生了异常情况,这对于可靠协议而言还是有问题的。
TIME_WAIT 过多的危害
- 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
- 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range 参数指定范围。
如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但已经被使用的端口还是可以继续对另一个服务端发起连接的。(四元组来定位,客户端端口一样并不影响连接)
如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。
优化 TIME_WAIT
打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;复用处于 TIME_WAIT 的 socket 为新的连接所用。tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。(这个值置 1),还有一个前提,需要打开对 TCP 时间戳的支持(默认开启)。在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
net.ipv4.tcp_max_tw_buckets,这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。
可以通过设置 socket 选项,来设置调用 close 关闭连接行为。如下所示,l_onoff 非 0,l_linger 为 0,那么 close 后会直接发送 RST 给对面,跳过四次挥手。这个并不提倡。
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。
服务器出现大量 TIME_WAIT
首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,也就是说现在是服务器主动断开很多 TCP 连接。
会有如下三个场景:
- HTTP 没有使用长连接
从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive;如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息,也就是说,只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制。
虽然 RFC 文档中,请求和响应的双方都可以主动关闭 TCP 连接。不过,根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive,服务端是主动关闭方。HTTP 是请求-响应模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是为客户端后续的请求重用连接,如果我们在某次 HTTP 请求-响应模型中,请求的 header 定义了 connection:close 信息,那不再重用这个连接的时机就只有在服务端了,所以我们在 HTTP 请求-响应这个周期的「末端」关闭连接是合理的。
当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,这时服务端在发完 HTTP 响应后,服务端也会主动关闭连接。在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,剩下的工作由内核 TCP 栈直接进行了处理,整个过程只有一次 syscall;如果是要求 客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 readable 队列,调用 select / epoll 去等待事件;然后调用一次 read() 才能知道连接已经被关闭,这其中是两次 syscall,多一次用户态程序被激活执行,而且 socket 保持时间也会更长。
**当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive。**任意一方没开都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。
解决办法也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制。
- HTTP 长连接超时
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。
解决方法:排查网络问题。
- HTTP 长连接请求数量达到上限
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接。
对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态。
解决方法:调大对应参数。
服务器出现大量 CLOSE_WAIT 状态
CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。
当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接。
一个普通 TCP 连接的流程:
- 创建服务端 socket,bind 绑定端口、listen 监听端口
- 将服务端 socket 注册到 epoll
- epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
- 将已连接的 socket 注册到 epoll
- epoll_wait 等待事件发生
- 对方连接关闭时,我方调用 close
一般可能有 4 个原因:
- 第 2 步没有做,socket 没有注册到 epoll,那么新连接到来服务端没法感知,也就无法获取 socket,无法调用 close;
- 第 3 步没做,新连接来没有 accept 获取socket,导致客户端主动断开连接,所以服务端没机会调用 close;
- 第 4 步没做,accept 获取后没有注册到 epoll,后续收到 FIN 报文没法感知,同理无法调用 close;
- 第 6 步没做,客户端关闭连接,服务端没有执行 close,可能是代码没有执行,例如发生死锁等。
当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close。
建立连接后客户端故障
避免这种情况,TCP 搞了个保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。
如果使用,就要考虑三个情况:
- 对端程序正常工作,那么需要在正常发送探测报文后重置 TCP 保活时间;
- 对端主机宕机并重启,此时对端可以响应,但是连接已经失效了只会返回 RST;
- 对端主机宕机(不是进程崩溃,进程崩溃操作系统会回收并发送 FIN 报文),此时进入真正的保活机制,没有响应那么 TCP 会报告 TCP 连接死亡。
但这个时间是很长的,所以可以自己定义一个时间来实现这个功能:web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。
已经建立连接服务端进程崩溃
服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。
Socket 编程
- 服务端和客户端初始化 socket,得到文件描述符;
- 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口;
- 服务端调用 listen,进行监听;
- 服务端调用 accept,等待客户端连接;
- 客户端调用 connect,向服务端的地址和端口发起连接请求;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;服务端调用 read 读取数据;
- 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
listen 时的 backlog
一共维护两个队列,一个是半连接队列(SYN 队列),一个是全连接队列(Accept 队列),之前有讲过。
int listen (int socketfd, int backlog)
参数一就是文件描述符,参数二在 Linux 内核 2.2 之后就是 accept 队列,也就是已完成连接建立的队列长度。上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
accept 发生位置
客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
客户端调用 close 的断开流程
服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态。
没有 accept 可以建立 TCP 连接
accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,用户层通过 accpet 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了。
没有 listen 可以建立 TCP 连接
客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接。
TCP 重传、滑动窗口、流量控制、拥塞控制
TCP 是通过序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现可靠性传输的。
重传机制
TCP 针对数据包丢失的情况,会用重传机制解决。
超时重传
在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。
触发这种机制一般会有两种情况:数据包丢失、确认应答丢失。
RTT 指的是数据发送时刻到接收到确认的时刻的差值,也就是包的往返时间。超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示。根据这个定义可以发现,RTO 过长或过短会分别导致效率低和网络拥塞两种情况。
所以,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
不过由于 RTT 是经常变化的,所以 RTO 也需要动态变化。
SRTT 是计算平滑的 RTT,DevRTT 是平滑的 RTT 与现在 RTT 的差距。在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。
如果已经重发,再度出发超时重发,这时候超时的时间就会加倍。两次超时,就说明网络环境差,不宜频繁反复发送。
快速重传
不以时间为驱动,而是以数据驱动重传。
这个可以直接看图,非常直观:
快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
但是,虽然解决了超时时间的问题,还是存在着重传时是重传一个还是重传所有的问题。为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
SACK 方法
SACK( Selective Acknowledgment), 选择性确认。
在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
同样的,三次 ACK 想通,就会触发重传机制,此时就通过 SACK 的头部信息接收到并缓存下来的接收缓冲区查看丢失的数据,然后只重发丢失数据即可。
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
Duplicate SACK
Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
主要就是用来解决应答报文的丢失,以及网络延时的问题。
ACK 丢失,就是在接收方收到重复报文的时候,返回一个 SACK 告诉发送方已经收到过了;网络延时,如果有数据没收到,就会一直回复 ACK + SACK,三次 ACK 相同会触发快速重传,如果之后收到了延时的包此时就会再回一个这一时刻收到的那个重复的包(SACK),通知发送方这个是网络延时问题。
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。
滑动窗口
TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
这样的话,即使有 ACK 丢失,也可以通过下一个 ACK 来确认应答判断是否收到数据。这个模式就叫累计确认或者累计应答。
TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,通常窗口大小是由接收方决定的。
只有在窗口还有剩余的情况下,发送方才可以继续发送;如果收到了应答,那么就会滑动窗口,也就是又有可以发送的数据了,这时就会继续发送,直到窗口再度耗尽。
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
由图可看出,可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)。
接收方就两个指针,一个是窗口大小一个是期望发来的序列号。
同时注意,接收窗口的大小是约等于发送窗口的大小的。滑动窗口并不是一成不变的。
流量控制
TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
一个具体的例子可以直接看这张图:
操作系统缓冲区与滑动窗口
实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。
这种调整可以看下面两个图的例子来感受一下,就不配说明性的文字了,图已经比较清晰的展示出来了:
当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会进一步学习。
上图的情况,就会出现最后发送端的窗口出现负值。为了规避这种情况,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
窗口关闭
如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
接收方是通过 ACK 报文来告知发送方窗口大小的,如果窗口变为 0 然后发送 ACK 报文;之后恢复了窗口在通过 ACK 告知但是报文丢失,就会造成死锁,两边都在等对方的通知。
为了解决死锁,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
该过程流程图如下:
糊涂窗口综合征
如果接收方来不及取走窗口数据,那么就会导致窗口的减小;如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
也就是说,在接收方来不及取出数据的时候,就会告知窗口大小,然后每次循环就会导致最后窗口越来越小,每次只能发送一点点数据。那么为了解决这个问题,一般就是同时解决两个方面:接收方不通告小窗口;发送方避免发送小数据。
接收方的通常策略:
当**「窗口大小」小于 min( MSS,缓存空间/2 )** ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。当窗口大小 >= MSS,或者接收方缓存空间超过一半可用,再打开窗口让发送方发送数据。
发送方策略:
使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据:
- 条件一:要等到窗口大小 >= MSS 并且 数据大小 >= MSS;
- 条件二:收到之前发送数据的 ack 回包;
也就是说,接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。一般 Nagle 算法是默认开启的,除非就是一些小数据交换的应用场景才会关闭 Nagle 算法。
拥塞控制
流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
但是,其他主机通信也可能导致网络拥堵,不只是当前的一对 TCP 连接的问题。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大…
拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
所以,为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。拥塞窗口 cwnd 是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 的变化规则如下:
- 网络中没有出现拥塞,cwnd 会增大;
- 网络出现拥塞,cwnd 会减小。
网络拥塞的判断条件:只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
慢启动
慢启动的算法记住一个规则就行:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。也就是说,慢启动的发包个数是指数增长的,如下图所示:
当然,慢启动到了一定情况就会停止,有一个慢启动门限 ssthresh (slow start threshold)状态变量。一般来说 ssthresh 的大小是 65535 字节。
- 当 cwnd < ssthresh 时,使用慢启动算法。
- 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。
拥塞避免算法
进入拥塞避免算法后,慢启动门限 ssthresh 的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。也就是说,进入拥塞避免算法,发包的个数变成了线性增长。
拥塞发生
拥塞发生后,就会触发重传机制,一般而言有两个,一个是超时重传,一个是快速重传。两者的拥塞发送算法不同。
- 超时重传
ssthresh 设为 cwnd/2;cwnd 重置为 1 (是恢复为 cwnd 初始化值,这里假定 cwnd 初始化值 1)可以用 ss -nli 命令查看每一个 TCP 连接的 cwnd 初始化值。这时的拥塞发生算法如下所示:
于是,该算法触发后,就会再次回到慢启动。该算法较为激进。
- 快速重传
当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。这时相当于只丢了一小部分的包,所以并不严重。
cwnd = cwnd/2 ,也就是设置为原来的一半; ssthresh = cwnd; 进入快速恢复算法。
快速恢复
- 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
TCP 实战抓包
如何抓包
分析网络的利器:tcpdump 和 Wireshark。
- tcpdump 仅支持命令行格式使用,常用在 Linux 服务器中抓取和分析网络包。
- Wireshark 除了可以抓包外,还提供了可视化分析网络包的图形页面。
一般两者可搭配使用,tcpdump 在 Linux 服务器抓包,然后文件放到 Windows 中,用 Wireshark 可视化分析。
如果是抓取 PING 命令包,就需要先知道 PING 命令是 icmp 协议,那么就可以只抓 icmp 协议的数据包。抓包的输出格式就是 时间戳+协议+源地址.源端口 > 目的地址.目的端口 网络包信息 这么一个格式,如下所示:
一般 tcpdump 的常用选项以及过滤表达式如下两张表格所示:
抓包完成后,把 tcpdump 抓取的数据包保存成 pcap 后缀的文件,接着用 Wireshark 工具进行数据包分析。
保存之后,直接用 Wireshark 打开,就可以直观的来分析数据。点开每一个数据包,还可以看到各个协议栈各层的信息,如下:
抓包分析 TCP 三次握手和四次挥手
这里具体的可以看这篇,这里我就自己记一下笔记:解密 TCP
Wireshark 可以用时序图直接看数据包的交互,同时序列号 Seq 或默认用相对值来显示。
这里注意,会出现三次挥手的原因:当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
三次握手异常
这里也是看的图解,具体见链接:三次握手异常的实验
第一次握手 SYN 丢包
模拟方法就是拔掉网线来模拟。
实验可知,超时重传 SYN 数据包,每次超时重传的 RTO 是翻倍上涨的,直到 SYN 包的重传次数到达 tcp_syn_retries 值后,客户端不再发送 SYN 包。
第二次握手 SYN + ACK 丢包
在客户端加上防火墙限制,直接粗暴的把来自服务端的数据都丢弃。
当第二次握手的 SYN、ACK 丢包时,客户端会超时重发 SYN 包,服务端也会超时重传 SYN、ACK 包。
客户端 SYN 包超时重传的最大次数,是由 tcp_syn_retries 决定的,默认值是 5 次;服务端 SYN、ACK 包时重传的最大次数,是由 tcp_synack_retries 决定的,默认值是 5 次。
第三次握手 ACK 包丢失
模拟方法是在服务端配置防火墙,屏蔽客户端 TCP 报文中标志位是 ACK 的包。
在建立 TCP 连接时,如果第三次握手的 ACK,服务端无法收到,则服务端就会短暂处于 SYN_RECV 状态,而客户端会处于 ESTABLISHED 状态。
由于服务端一直收不到 TCP 第三次握手的 ACK,则会一直重传 SYN、ACK 包,直到重传次数超过 tcp_synack_retries 值(默认值 5 次)后,服务端就会断开 TCP 连接。
客户端则会有两种情况:
- 如果客户端没发送数据包,一直处于 ESTABLISHED 状态,然后经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接,于是客户端连接就会断开连接。
- 如果客户端发送了数据包,一直没有收到服务端对该数据包的确认报文,则会一直重传该数据包,直到重传次数超过 tcp_retries2 值(默认值 15 次)后,客户端就会断开 TCP 连接。
TCP 快速建立连接
在 Linux 3.7 内核版本中,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。
- 在第一次建立连接的时候,服务端在第二次握手产生一个 Cookie (已加密)并通过 SYN、ACK 包一起发给客户端,于是客户端就会缓存这个 Cookie,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延;
- 在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程,因为 Cookie 中维护了一些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延;
通过设置 net.ipv4.tcp_fastopen 内核参数,来打开 Fast Open 功能。1 就是客户端打开;2 是服务端打开;3 是都打开。
重复确认和快速重传
当发送方收到 3 个重复 ACK 时,就会触发快速重传,立刻重发丢失数据包。
流量控制
服务器会出现繁忙的情况,当应用程序读取速度慢,那么缓存空间会慢慢被占满,于是为了保证发送方发送的数据不会超过缓冲区大小,服务器则会调整窗口大小的值,接着通过 ACK 报文通知给对方,告知现在的接收窗口大小,从而控制发送方发送的数据大小。
如果窗口收缩至 0,发送方会定时发送窗口大小探测报文,以便及时知道接收方窗口大小的变化。超时时间会翻倍递增。
TCP 延迟确认和 Nagle 算法
Nagle 算法一定会有一个小报文,也就是在最开始的时候。
解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
这两者如果一起使用,就会造成额外的延时,所以要么发送方关闭 Nagle,要么接收方关闭 TCP 延迟确认。
TCP 半连接队列和全连接队列
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
两者都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
全连接队列溢出
首先,可以通过ss命令来查看 TCP 全连接队列的情况。获取到的 Recv-Q/Send-Q 在「LISTEN 状态」和「非 LISTEN 状态」所表达的含义是不同的。
「LISTEN 状态」:
- Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
- Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128;
「非 LISTEN 状态」:
- Recv-Q:已收到但未被应用进程读取的字节数;
- Send-Q:已发送但未收到确认的字节数;
可以使用 netstat -s 命令来查看 TCP 连接丢掉的个数:
当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。当然也可以修改,变成向客户端发送 RST 复位报文。修改 tcp_abort_on_overflow 为 1 即可(默认是 0)。
默认丢弃的原因:只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。也就是说,如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。
半连接队列溢出
可以抓住 TCP 半连接的特点,就是服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列。所以可以通过如下命令来查看当前 TCP 的半连接队列长度:
半连接队列最大值不是单单由 max_syn_backlog 决定,还跟 somaxconn 和 backlog 有关系。
- 当 max_syn_backlog > min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = min(somaxconn, backlog) * 2;
- 当 max_syn_backlog < min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = max_syn_backlog * 2;
当然以上只是理论值,实际的丢弃逻辑如下:
- 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
- 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
- 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;
如果当前半连接队列的长度 「没有超过」理论半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_RECV 状态的最大个数不会是理论值 max_qlen_log。
所以,服务端处于 SYN_RECV 状态的最大个数分为如下两种情况:
- 如果「当前半连接队列」没超过「理论半连接队列最大值」,但是超过 max_syn_backlog - (max_syn_backlog >> 2),那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog - (max_syn_backlog >> 2);
- 如果「当前半连接队列」超过「理论半连接队列最大值」,那么处于 SYN_RECV 状态的最大个数就是「理论半连接队列最大值」;
当然,Linux 内核版本不一样算法就会不一样。以上针对的是 Linux 2.6.32。
跟全连接队列一样,并不是说半连接队列满了就只能丢弃。开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,在前面我们源码分析也可以看到这点,当开启了 syncookies 功能就不会丢弃连接。
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
那么,如果要应对造成半连接队列溢出的 SYN 攻击,可以把 tcp_syncookies 的参数设置为 1。
半连接队列相关的,最容易问的就是 如何防御 SYN 攻击:
- 增大半连接队列;
- 开启 tcp_syncookies 功能
- 减少 SYN+ACK 重传次数
要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列。
优化 TCP
三次握手性能提升
客户端优化
三次握手建立连接的首要目的是「同步序列号」。序列号就是在 TCP 头部中的一个 32 位的数据。
客户端发了 SYN 报文之后,就会进入 SYN_SENT 状态;如果没收到服务端返回的 SYN+ACK 报文就会进入 SYN 包的重传,这里是有 tcp_syn_retries 参数控制,每次超时时间是上一次的两倍,默认重传 5 次,大致是 63 秒之后停止重传。
这里的优化,就是可以适当修改 tcp_syn_retries 参数,控制 SYN 包的重传次数。
服务端优化
服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方,并将自己的状态切换至 SYN_RECV,这时候内核就会构建「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。
可以通过该 netstat -s 命令给出的统计结果中, 可以得到由于半连接队列已满,引发的失败次数:
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。
增大半连接队列大小,不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大 accept 队列。否则,只单纯增大 tcp_max_syn_backlog 是无效的。
同时,半连接队列已满,只要开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接。把 tcp_syncookies 设置为 1 即可(默认就是开启的)。
服务端发送 SYN+ACK 报文后,客户端收到就会回复 ACK 报文,如果服务端没收到那就会重传 SYN+ACK 报文,同时一直处于 SYN_RECV 状态。修改重发次数的方法是,调整 tcp_synack_retries 参数。
直到服务端收到 ACK 报文建立成功,内核会将连接从半连接队列中移除,并添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP 连接被丢弃。
全连接队列也是同理,并不一定是丢弃,可以设置 tcp_abort_on_overflow 为 1,这样在溢出后就会返回客户端一个 RST 包。(一般默认是 0,直接丢弃,这样处理更高效)
accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。
绕过三次握手
如果是三次握手,那至少需要一个 RTT 后才能发送数据(在第三次握手的 ACK 包中可以携带数据)。
绕过三次握手这个之前也有讲过,就是在 Linux 3.7 之后,提供了 TCP Fast Open 功能。
第一次握手,就是正常的三次握手流程。但如果之后再次建立连接,这时客户端发送 SYN 报文,就会携带数据以及之前记录的 Cookie,然后服务器就会校验该 Cookie,有效就会在返回的 SYN+ACK 包中对 SYN 和数据确认,并将数据进一步给应用进程处理;无效则会丢弃,只发送正常的 SYN+ACK 包;之后,客户端会发送 ACK 确认服务器的 SYN 和数据。
相当于绕过了三次握手,减少了 1 个 RTT 的时间消耗。
开启了 TFO 功能,这个 cookie 是放在 TCP 头部中的:
设置 tcp_fastopn 内核参数为 3,来打开 Fast Open 功能(服务端和客户端均需打开)。
四次挥手性能提升
通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。
- 当主动方关闭连接时,会发送 FIN 报文,此时发送方的 TCP 连接将从 ESTABLISHED 变成 FIN_WAIT1。
- 当被动方收到 FIN 报文后,内核会自动回复 ACK 报文,连接状态将从 ESTABLISHED 变成 CLOSE_WAIT,表示被动方在等待进程调用 close 函数关闭连接。
- 当主动方收到这个 ACK 后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,也就是表示主动方的发送通道就关闭了。
- 当被动方进入 CLOSE_WAIT 时,被动方还会继续处理数据,等到进程的 read 函数返回 0 后,应用程序就会调用 close 函数,进而触发内核发送 FIN 报文,此时被动方的连接状态变为 LAST_ACK。
- 当主动方收到这个 FIN 报文后,内核会回复 ACK 报文给被动方,同时主动方的连接状态由 FIN_WAIT2 变为 TIME_WAIT,在 Linux 系统下大约等待 1 分钟后,TIME_WAIT 状态的连接才会彻底关闭。
- 当被动方收到最后的 ACK 报文后,被动方的连接就会关闭。
主动关闭连接的,才有 TIME_WAIT 状态。
主动方优化
关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。如果进程收到 RST 报文,就直接关闭连接了,不需要走四次挥手流程,是一个暴力关闭连接的方式。
四次挥手,由进程调用 close 和 shutdown 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。调用了 close 函数意味着完全断开连接,完全断开不仅指无法传输数据,而且也不能发送数据。 此时,调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p 命令,会发现连接对应的进程名为空。使用 close 函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的 shutdown 函数,它可以控制只关闭一个方向的连接。第一个参数就是 sock,第二个参数决定了关闭的方向,0 就是关闭连接的「读」这个方向;1 就是关闭连接的「写」这个方向;2 相当于关闭套接字的读和写两个方向。
主动方发送 FIN 报文后会等待 ACK 报文,连接就处于 FIN_WAIT1 状态;如果收不到就会重发 FIN 报文,重发次数由 tcp_orphan_retries 参数控制(默认为 0,实际就是重发 8 次)。如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。
如果遇到恶意攻击,FIN 报文无法发出,那就调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量。当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。
主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60,表示能在 FIN_WAIT2 持续 60s。(与 TIME_WAIT 状态持续的时间是相同的)
TIME_WAIT 是主动方四次挥手的最后一个状态,也是最常遇见的状态。当收到被动方发来的 FIN 报文后,主动方会立刻回复 ACK,表示确认对方的发送通道已经关闭,接着就处于 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 60 秒后才会进入关闭状态。
**TIME_WAIT 状态存在的必要之前已经学过了,可以往上翻到最前面刚学四次挥手的地方。**主要是两点:
- 防止历史连接中数据,被后面的四元组连接错误接受
- 保证「被动关闭连接」的一方,能被正确的关闭
Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets 参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟系统资源是有限的。
有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。同时,还有一个前提是双方都需要打开时间戳(tcp_timestamps 设置为 1)。时间戳一开,TIME_WAIT 的 2MSL 就会因为重复数据包的时间戳过期直接舍弃,还可以防止序列号的绕回。
还可以直接在 socket 选项中,设置调用 close 关闭连接行为。 l_onoff 为非 0, 且 l_linger 值为 0,那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。只推荐在客户端使用!
被动方优化
当被动方收到 FIN 报文时,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。当你用 netstat 命令发现大量 CLOSE_WAIT 状态,就需要排查你的应用程序,因为可能因为应用程序出现了 Bug,read 函数返回 0 时,没有调用 close 函数。
处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动方重发 FIN 报文的优化策略一致。
还有一种特殊情况,因为 TCP 是全双工连接,就有可能出现客户端和服务端同时关闭连接,两者都认为自己是主动方,发送 FIN 报文后进入 FIN_WAIT1 状态,重发同样是 tcp_orphan_retries 控制;之后双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态。
传输数据性能提升
TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区,内存过小就会无法充分利用网络带宽;内存过大会导致服务器资源耗尽无法建立新连接。
滑动窗口
TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文 ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。
TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。同时要注意,发送方的窗口和接收方的窗口都是动态变化的,如果不考虑拥塞控制,那么两者是约等于的关系。
这里的窗口是 2 个字节,也就是最大值为 65535 字节,64 KB。后续有了扩充窗口的方法:在 TCP 选项字段定义了窗口扩大因子,用于扩大 TCP 通告窗口,其值大小是 2^14,这样就使 TCP 的窗口大小从 16 位扩大为 30 位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB。
该功能需要配置 tcp_window_scaling 设为 1(默认打开)。同时,要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项。
但是,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。
确定最大传输速度
网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:
- 带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s;
- 缓冲区单位是字节,当网络速度乘以时间才能得到字节数;
带宽时延积,它决定网络中飞行报文的大小,由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。
发送缓冲区的大小最好是往带宽时延积靠近。
调整缓冲区大小
发送缓冲区,它的范围通过 tcp_wmem 参数配置。
- 第一个数值是动态范围的最小值,4096 byte = 4K;
- 第二个数值是初始默认值,16384 byte ≈ 16K;
- 第三个数值是动态范围的最大值,4194304 byte = 4096K(4M);
发送缓冲区是自行调节的。
接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的 tcp_rmem 参数。
- 第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte = 4K;
- 第二个数值是初始默认值,87380 byte ≈ 86K;
- 第三个数值是动态范围的最大值,6291456 byte = 6144K(6M);
接收缓冲区可以根据系统空闲内存的大小来调节接收窗口,但是不是自动开启的,需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能。
同时引入了新的问题,接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的。
- 当 TCP 内存小于第 1 个值时,不需要进行自动调节;
- 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;
- 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;
在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。
同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。
TCP是面向字节流协议
理解字节流
UDP 是面向报文的,TCP 是面向字节流的,两者的发送方机制不同。
- UDP
UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
对操作系统而言,收到 UDP 报文,会插入一个队列,队列的每个元素就是一个 UDP 报文,用户调用 recvfrom() 读取,就会从队列读取数据,然后从内核拷贝到用户缓冲区。
- TCP
TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
所以,接收方一定要知道发送方的消息长度,不然无法解析成一个完成的用户消息。
在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。
所以,不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。
解决粘包
一般有三种方式分包的方式:
- 固定长度的消息;
- 特殊字符作为边界;
- 自定义消息结构。
固定长度的消息
最简单的方法,直接规定每一个消息的长度是相通的。但是使用很不灵活,很少使用。
特殊字符作为边界
可以在两个用户消息之间插入特殊的字符,接收数据时读到这个字符相当于读完了一个完整消息。
HTTP 就是用了这种方法:
通过设置回车符、换行符作为 HTTP 报文协议的边界。
自定义消息结构
可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
TCP 建立,初始化序列号均不相同
主要目的:防止历史报文被像一个相同四元组的连接接收。
TIME_WAIT 会使得历史报文消失?
虽然 TCP 连接中 TIME_WAIT 持续 2 MSL 时长后,历史报文会自动消失,但前提是能正常四次挥手。例如如下的情况,就能很好的解释为什么可能有历史报文:
初始化序列号不同是否解决历史报文
当然,即使客户端和服务端的初始化序列号不一样,也会存在收到历史报文的可能。但是,历史报文是否接收,还要看序列号是否在接收窗口,只有在窗口才会接收。如果每次连接,客户端和服务端序列号均不相同,那么大概率历史报文序列号「不在」对方接收窗口,从而很大程度上避免了历史报文。
初始化序列号随机化
RFC793 采用了 ISN 随机生成算法,基本不会有一样的初始化序列号。
先来了解序列号(SEQ)和初始序列号(ISN)。
- 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
- 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
也就是说,序列号和初始化序列号均会发生回绕,无法根据序列号来直接判断新老数据。为了解决这个问题,就需要有 TCP 时间戳。
tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)。如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
当然,时间戳也可能回绕。Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。所以解决这个问题可以如下出发:
- 增加时间戳大小,从原先的 32 bit 扩大到 64 bit。会造成兼容问题。
- 将与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳增速不变。这样报文太多一样会导致时间戳失去意义。
SYN 报文被丢弃情况
可能的 SYN 报文被丢弃情况:
- 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃
- TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃
tcp_tw_recycle
TCP 四次挥手过程中,主动断开连接方会有一个 TIME_WAIT 的状态,这个状态会持续 2 MSL 后才会转变为 CLOSED 状态。默认 TIME_WAIT 在 Linux 中是 60 秒。
如果客户端(发起连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,就无法对「目的 IP+ 目的 PORT」都一样的服务器发起连接了。当然,TIME_WAIT 也是有作用的:
- 防止具有相同四元组的旧数据包被收到,也就是防止历史连接中的数据,被后面的连接接受,否则就会导致后面的连接收到一个无效的数据;
- 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:
- net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。所以该选项只适用于连接发起方。
- net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收。
要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。
tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!
对于服务器,如果同时开启了 recycle 和timestamps, 则会开启一种称之为「 per-host 的 PAWS 机制」。 PAWS 就是上一篇中的防回绕的机制。per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。
Per-host PAWS 机制利用TCP option里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。如果通过 NAT 网关建立 TCP 连接,都会得到相同的 IP 地址,那么如果有两个客户端,就存在可能 timestamps 的大小来触发 PAWS 进而导致第二个客户端的 SYN 包被丢掉。
tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。
accept 队列满
TCP 三次握手,内核会维护两个队列:
- 半连接队列, SYN 队列;
- 全连接队列,accept 队列。
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,就会造成 accpet 队列满了 ,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。
半连接队列满
例如服务器遭受 syn 攻击,就会导致半连接队列满了,之后的 SYN 包就会被丢弃。当然,开启 syncookies 功能,即使半连接队列满了也不会丢弃 SYN 包。
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。如果要开启,只需要设置 syncookies 参数为 1 就可以了。
以下有三种应对 SYN 攻击的方法,来解决 SYN 丢包:
- 增大半连接队列
要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列。
- 开启 tcp_syncookies 参数
- 减少 SYN+ACK 重传次数
已经建立 TCP 连接,收到 SYN
客户端中途宕机,服务端没有数据发送就一直处于 Established 状态,客户端恢复连接,服务端会如何呢?
此时,客户端 IP、服务端 IP、目的端口均无变化,所以主要看 SYN 包的源端口与上一次源端口是否相同。
- 不相同
那么就会三次握手建立新的连接;
旧连接如果服务端发数据,就会有 RST 报文然后服务端释放连接;如果一直没有数据,那么超过一定时间 TCP 保活机制出发就会释放。
- 相同
处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。
接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。
关闭 TCP 连接
直接杀掉进程,会造成不同影响:
- 客户端杀掉,会发送 FIN 报文断开客户端进程与服务端建立的所有 TCP 连接,其余客户端不影响;
- 服务端直接杀掉,所有 TCP 连接均关闭。
可以通过伪造一个 RST 报文来完成关闭,要注意的是,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。
这件事情可以通过 killcx 工具完成,直接发 SYN 报文,服务端会回复一个 Challenge ACK,他的报文确认号就是服务端下一次想要接收的序列号。用这个序列号作为 RST 的序列号,就会释放连接。
killcx 工具则是属于主动获取,它是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取正确的序列号,所以这种方式无论 TCP 连接是否活跃,都可以关闭。
tcpkill 工具也可以关闭连接,其在双方 TCP 通信中,拿到下一次期望的序列号,从而伪造 RST 报文。这是被动获取的方式,无法关闭非活跃 TCP 连接。
四次挥手收到乱序 FIN 包
在 FIN_WAIT_2 状态下,是如何处理收到的乱序到 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?
这里直接上结论:
在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,并不会进入到 TIME_WAIT 状态。
等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。
可以看下图:
TIME_WAIT 状态 TCP 连接,收到 SYN
这个状态就是下图这样:
这个问题,关键是要看 SYN 的「序列号和时间戳」是否合法,因为处于 TIME_WAIT 状态的连接收到 SYN 后,会判断 SYN 的「序列号和时间戳」是否合法,然后根据判断结果的不同做不同的处理。
什么是「合法」的 SYN?
- 合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大,并且 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要大。
- 非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小,或者 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要小。
如果没有时间戳,就可以简化成以下形式:
- 合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大。
- 非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小。
收到合法 SYN
会直接重用此四元组,跳过 2 MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接的过程。
这个过程如下所示:
在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval (21),用 ts_recent 变量保存。然后会计算下一次期望收到的序列号,本次例子下一次期望收到的序列号就是 301,用 rcv_nxt 变量保存。
之后接到 SYN,因为符合判断逻辑所以是一个「合法的 SYN」,于是就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。
收到非法 SYN
再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号(ack num),就回 RST 报文给服务端。
这个过程如下所示:
TIME_WAIT 状态收到 RST
收到 RST 之后是否会断开?
会不会断开,关键看 net.ipv4.tcp_rfc1337 这个内核参数(默认情况是为 0):
- 如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
- 如果这个参数设置为 1, 就会丢掉 RST 报文。
这里要注意,如果收到 RST (默认参数)就会直接释放连接,跳过了 2MSL 时间,这是有一定风险的。2MSL的主要目的是:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
TCP 连接,一端断电与进程崩溃
这是 TCP 异常断开连接的一个场景。
首先没有打开 TCP keepalive,也就是关闭了保活机制,不会发送探测报文。这个机制如下:
- 如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
- 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
主机崩溃
客户端主机崩溃了,服务端是无法感知到的,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。
进程崩溃
当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP四次挥手的过程。
有数据传输
客户端主机宕机,迅速重启
在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的响应,在一定时长后,服务端就会触发超时重传机制,重传未得到响应的报文。
客户端重启,就会收到这个重传报文:
- 如果客户端主机上没有进程绑定该 TCP 报文的目标端口号,那么客户端内核就会回复 RST 报文,重置该 TCP 连接;
- 如果客户端主机上有进程绑定该 TCP 报文的目标端口号,由于客户端主机重启后,之前的 TCP 连接的数据结构已经丢失了,客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会回复 RST 报文,重置该 TCP 连接。
总结:只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接。
客户端主机宕机,一直没有重启
服务端多次重传,达到阈值就会调用 Socket 接口,断开 TCP 连接。
重传次数:
由 Linux 的参数 tcp_retries2 决定,默认为 15 ,内核会根据 tcp_retries2 设置的值,计算出一个 timeout(如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms),如果重传间隔超过这个 timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接。并且重传的超时时间(RTO)是倍数增长的。而 RTO 是根据 RTT(一个包往返时间)计算。
在默认情况下,RTT 较小那么 RTO 就是大致为下限 200 ms,大致就是重传 15 次。
拔掉网线后 TCP 连接
直接说结论,不会影响,仍然处于 ESTABLISHED 状态。
拔掉后有数据传输
服务端传输给客户端无响应,那么一段时间后就会触发服务端的超时重传。
如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。
如果如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。
拔掉后没有数据传输
如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。
而如果开启了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:
- 如果对端是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
- 如果对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
TCP keepalive 机制之前已经介绍过就不赘述了。
tcp_tw_reuse 为什么默认关闭
这个参数打开,就可以快速复用处于 TIME_WAIT 状态的 TCP 连接,但是默认是关闭的。相当于在问:如果 TIME_WAIT 状态持续时间过短会怎么样。
TIME_WAIT 状态
四次挥手的过程简图如下:
可以看到,主动关闭连接的才有 TIME_WAIT 状态。且该状态会持续 2MSL 之后进入 CLOSED 状态。
MSL 指的是 TCP 协议中任何报文在网络上最大的生存时间,MSL 是由网络层的 IP 包中的 TTL 来保证的,TTL 是 IP 头部的一个字段,用于设置一个数据报可经过的路由器的数量上限, MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
TIME_WAIT 状态意义
老生常谈两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
针对第二个情况,比如被动关闭发送 FIN 包丢失,那么会重传 FIN,此时主动关闭端处于 TIME_WAIT 状态,就会返回 ACK 包,同时 TIME_WAIT 会重置计时器进入新的 TIME_WAIT 时间。
tcp_tw_reuse 是什么
如果客户端(主动关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务器发起连接了,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。(不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。)
Linux 是有两个参数可以快速回收 TIME_WAIT 状态的连接的,不过都是默认关闭:
- net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。所以该选项只适用于连接发起方。
- net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收,该参数在 NAT 的网络下是不安全的!
要使得上面这两个参数生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1)。
tcp_tw_reuse 默认关闭原因
有两个原因:
第一个问题
时间戳此时打开,就可以有效判断回绕的序列号。但是,对于 RST 报文的时间戳即使过期了,只要 RST 报文的序列号在对方的接收窗口内,也是能被接受的。
就有可能发生如下情况:
第二个问题
如果第四次挥手的 ACK 报文丢失,服务端就会重传 FIN 包,此时处于 SYN_SENT 状态的客户端收到第三次挥手报文,就会回 RST 报文。
过程如下图所示:
处于 last_ack 状态的服务端收到了 SYN 报文后,会回复确认号与服务端上一次发送 ACK 报文一样的 ACK 报文,这个 ACK 报文称为 Challenge ACK (opens new window),并不是确认收到 SYN 报文。
处于 syn_sent 状态的客户端收到服务端的 Challenge ACK (opens new window)后,发现不是自己期望收到的确认号,于是就会回复 RST 报文,服务端收到后,就会断开连接。
HTTPS 中 TLS 和 TCP 可以同时握手?
TLS 是 HTTPS 协议中的内容。
一般情况下,不管 TLS 握手次数如何,都得先经过 TCP 三次握手后才能进行。当然,也有情况是可以同时握手的,需要以下两个条件同时满足:
- 客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;
- 客户端和服务端已经完成过一次通信。
TCP Fast Open
TCP 的第一次和第二次握手是不能够携带数据的,而 TCP 的第三次握手是可以携带数据的,因为这时候客户端的 TCP 连接状态已经是 ESTABLISHED,表明客户端这一方已经完成了 TCP 连接建立。
TCP Fast Open 是为了绕过 TCP 三次握手发送数据,在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。要使用该功能,客户端和服务端需要同时支持该功能。
开启了 TCP Fast Open 功能,想要绕过 TCP 三次握手发送数据,得建立第二次以后的通信过程。
客户端首次建立连接:
具体介绍:
- 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;
- 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 报文中的 Fast Open 选项以发回客户端;
- 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。
后续通信,客户端第一次握手就可以携带数据,从而绕过三次握手发送数据:
具体情况如下:
- 客户端发送 SYN 报文,该报文可以携带「应用数据」以及此前记录的 Cookie;
- 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「应用数据」递送给对应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「应用数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;
- 如果服务器接受了 SYN 报文中的「应用数据」,服务器可在握手完成之前发送「响应数据」,这就减少了握手带来的 1 个 RTT 的时间消耗;
- 客户端将发送 ACK 确认服务器发回的 SYN 以及「应用数据」,但如果客户端在初始的 SYN 报文中发送的「应用数据」没有被确认,则客户端将重新发送「应用数据」;
- 此后的 TCP 连接的数据传输过程和非 TCP Fast Open 的正常情况一致。
TLSv1.3
过程如下:
TCP 连接的第三次握手是可以携带数据的,如果客户端在第三次握手发送了 TLSv1.3 第一次握手数据,是不是就表示「HTTPS 中的 TLS 握手过程可以同时进行三次握手」?。并没有,服务端只有收到客户端的 TCP 第三次握手后才能进行后续 TLS 握手。
TLSv1.3 还有个更厉害到地方在于会话恢复机制,在重连 TLvS1.3 只需要 0-RTT,用“pre_shared_key”和“early_data”扩展,在 TCP 连接后立即就建立安全连接发送加密消息,如下所示:
TCP Fast Open + TLSv1.3
如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。
如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。
TCP Keepalive 和 HTTP Keep-Alive
两者是有区别的,实现也不同:
- HTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接;
- TCP 的 Keepalive,是由 TCP 层(内核态) 实现的,称为 TCP 保活机制;
HTTP Keep-Alive
HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端发送 HTTP 请求,服务端收到后就返回响应,至此「请求-应答」的模式就完成了,随后就会释放 TCP 连接。整体流程如下所示:
如果每一次都重复如上过程,那就是短连接。如果第一次请求完先不断开 TCP,后续复用这个连接,那就是 Keep-Alive 实现的功能,可以使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 HTTP 长连接。
在 HTTP 1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的包头中添加对应字段,然后服务器收到请求同样也要添加这个响应字段。从 HTTP 1,1 开始就是默认开启了 Kepp-Alive。
HTTP 长连接不仅仅减少了 TCP 连接资源的开销,而且这给 HTTP 流水线技术提供了可实现的基础。所谓的 HTTP 流水线,是客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应,可以减少整体的响应时间。当然,服务器还是按照顺序响应。
当然,为了避免 TCP 长连接浪费资源,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。这就相当于给定时器计时,超时就会触发回调函数释放该连接。
TCP Keepalive
TCP 的 Keepalive 这东西其实就是 TCP 的保活机制。之前有讲过。直接看一个图示复习一下:
TCP 协议缺陷
主要有如下四个方面:
- 升级 TCP 的工作很困难;
- TCP 建立连接的延迟;
- TCP 存在队头阻塞问题;
- 网络迁移需要重新建立 TCP 连接;
升级 TCP 困难
TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。
TCP 连接延迟
现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS 四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。
整个上网流程如下所示:
TCP 三次握手可以通过 TCP Fast Open 解决,在「第二次建立连接」时减少 TCP 连接建立的时延。优化如下:
TCP Fast Open 这个特性是不错,但是它需要服务端和客户端的操作系统同时支持才能体验到,而 TCP Fast Open 是在 2013 年提出的,所以市面上依然有很多老式的操作系统不支持,而升级操作系统是很麻烦的事情,因此 TCP Fast Open 很难被普及开来。
并且,TCP 在内核实现,而 HTTPS 的 TLS 是在应用层握手,这两个握手无法结合;同时,TLS 无法对 TCP 头部加密,所以 TCP 序列号都是明文传输,有安全隐患。
TCP 存在队头阻塞
TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。如下所示:
这就是队头阻塞,如果丢包,那么整个 TCP 都要重传,那就会阻塞该 TCP 连接中的所有请求。
网络迁移需要重建 TCP
因为 TCP 协议是通过四元组(源 IP、源端口、目的 IP、目的端口),那么当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。
基于 UDP 实现可靠传输
现在市面上已经有基于 UDP 协议实现的可靠传输协议的成熟方案了,那就是 QUIC 协议,已经应用在了 HTTP/3。
如何实现可靠传输
相当于在应用层修改,也就是设计好协议头部字段。在 HTTP/3 中,头部如下:
整体结构如下:
Packet Header
Packet Header 首次建立连接时和日常传输数据时使用的 Header 是不同的。如下:
左侧的用于首次连接,右侧用于日常传输数据。
QUIC 也是需要三次握手来建立连接的,主要目的是为了协商连接 ID。协商出连接 ID 后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。
Short Packet Header 中的 Packet Number 是每个报文独一无二的编号,它是严格递增的 。这就解决了 TCP 重传时序列号不变而可能造成的 TCP 重传歧义。
QUIC 报文中,Packet Number 严格递增,即使重传报文也会递增,可以更加精确的计算报文的 RTT。
还有一个好处,QUIC 使用的 Packet Number 单调递增的设计,可以让数据包不再像 TCP 那样必须有序确认,QUIC 支持乱序确认,当数据包Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动。
QUIC Frame Header
一个 Packet 报文可以有多个 Frame:
每个 Frame 都有明确类型,类型不同功能不同,格式也会不同。例如 Stream 类型的 Frame,就可以认为是一条 HTTP 请求:
具体如下:
- Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;
- Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性;
- Length 作用:指明了 Frame 数据的长度。
引入 Frame Header 这一层,通过 Stream ID + Offset 字段信息实现数据的有序性;丢失的数据包和重传的数据包 Stream ID 与 Offset 都一致,说明这两个数据包的内容一致。
总结来说,QUIC 通过单向递增的 Packet Number,配合 Stream ID 与 Offset 字段信息,可以支持乱序确认而不影响数据包的正确组装。
QUIC 解决队头阻塞
之前讲过,TCP 是有队头阻塞的, TCP 必须按序处理数据,也就是 TCP 层为了保证数据的有序性,只有在处理完有序的数据后,滑动窗口才能往前滑动,否则就停留。
HTTP/2 的队头阻塞
HTTP/2 中抽象出 Stream,实现并发传输,一个 Stream 相当于一次请求和响应。不同 Stream 是可以乱序发送的,但是 HTTP/2 多个 Stream 请求都是在一条 TCP 连接上传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,那么当发生数据丢失,滑动窗口是无法往前移动的,此时就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。
没有队头阻塞的 QUIC
QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。
QUIC 流量控制
QUIC 实现流量控制的方式:
- 通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
- 通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
这里注意,QUIC 的滑动窗口滑动的条件跟 TCP 有一点差别,但是同一个 Stream 的数据也是要保证顺序的,不然无法实现可靠传输,因此同一个 Stream 的数据包丢失了,也会造成窗口无法滑动。
QUIC 的 每个 Stream 都有各自的滑动窗口,不同 Stream 互相独立,队头的 Stream A 被阻塞后,不妨碍 StreamB、C的读取。
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别:
- Stream 级别的流量控制:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。
- Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。
Stream 级别流量控制
比较复杂,直接上链接:
Stream 流量控制
Connection 流量控制
其接收窗口大小就是各个 Stream 接收窗口大小之和。如下所示:
QUIC 对拥塞控制改进
QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。可以针对不同应用场景设置不同的拥塞控制算法。
QUIC 更快的连接建立
HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
如下图所示:
QUIC 迁移连接
QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。
TCP 和 UDP 可以同时使用同一个端口?
可以同时绑定相同端口?
TCP 和 UDP 服务端网络相似的一个地方,就是会调用 bind 绑定端口。
TCP 会监听端口,但是 UDP 是没有这个操作的,分别如下方两张图所示:
两者是可以同时绑定相同端口的!
在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。收到数据包,根据 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。所以这两者共用端口,并不会造成冲突,过程如下:
多个 TCP 可以绑定同一端口?
如果两个 TCP 服务进程绑定的 IP 地址不同,而端口相同的话,也是可以绑定成功的。
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。
这里注意,重启 TCP 进程,也会有这个报错,这是因为重启的时候相当于发生了四次挥手,那么主动关闭方就会处于 TIME_WAIT 状态,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。
避免以上问题,可以在调用 bind 之前,对 socket 设置 SO_REUSEADDR 属性。并不会造成危害。
客户端端口可以重复使用?
整体 TCP 连接如下所示:
客户端选择端口是在 connect 函数,内核在选择端口的时候,会从 net.ipv4.ip_local_port_range 这个内核参数指定的范围来选取一个端口作为客户端端口。
不过这里端口是可以重复用的,因为 TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。
如果想自己指定端口,那就要在客户端调用 bind 函数绑定端口,那么再调用 connect 就会跳过端口选择。这里同样,只要绑定的 IP + PORT 是否都相同,如果都是相同的,那么在执行 bind() 时候就会出错,错误是“Address already in use”。
不过一般不会在客户端 bind。
客户端 TIME_WAIT 过多,会导致端口耗尽?
只要客户端连接的服务器不同,端口资源可以重复使用的。
客户端 TIME_WAIT 过多,无法与同一服务器建立 TCP
打开 net.ipv4.tcp_tw_reuse 这个内核参数。
开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。
总结
端口选择有一张总结的流程图,可以看一下:
服务端没有 listen,客户端建立连接
服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。
没有 listen,可以建立 TCP?
可以的,客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有listen,就能建立连接。
虽然没有 listen 就没有半连接队列和全连接队列,来存 IP + 端口,但是在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。
没有 accept,能建立 TCP?
这里可以看一下简单的服务端伪代码:
int main()
{
/*Step 1: 创建服务器端监听socket描述符listen_fd*/
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
/*Step 2: bind绑定服务器端的IP和端口,所有客户端都向这个IP和端口发送和请求数据*/
bind(listen_fd, xxx);
/*Step 3: 服务端开启监听*/
listen(listen_fd, 128);
/*Step 4: 服务器等待客户端的链接,返回值cfd为客户端的socket描述符*/
cfd = accept(listen_fd, xxx);
/*Step 5: 读取客户端发来的数据*/
n = read(cfd, buf, sizeof(buf));
}
也就是说,listen 之后会执行 accept;一般而言,启动服务器,最后程序会阻塞在 accept。
再看看客户端简化伪代码:
int main()
{
/*Step 1: 创建客户端端socket描述符cfd*/
cfd = socket(AF_INET, SOCK_STREAM, 0);
/*Step 2: connect方法,对服务器端的IP和端口号发起连接*/
ret = connect(cfd, xxxx);
/*Step 4: 向服务器端写数据*/
write(cfd, buf, strlen(buf));
}
客户端创建好 socket 之后就会直接 connect;这时,服务端阻塞的 accept 就会返回结果了。
实验抓包得到结果:**就算不执行accept()方法,三次握手照常进行,并顺利建立连接。**而且,在服务端执行accept()前,如果客户端发送消息给服务端,服务端是能够正常回复ack确认包的。
这里就回到了之前的半连接队列和全连接队列:全连接队列就是第三次握手之后,会从半连接队列中取出 sock 存到自己这里,这里的所有连接全部是 ESTABLISHED 状态,只等着服务端执行 accept 取出。可以看下面这张图:
半连接队列是哈希表
虽然叫队列,但其实半连接队列是哈希表,全连接队列是链表:
半连接队列却不太一样,因为队列里的都是不完整的连接,嗷嗷等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列里把相应IP端口的连接取出,如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是O(n)。
全连接队列满
如果队列满了,服务端还收到客户端的第三次握手ACK,默认当然会丢弃这个ACK。
但除了丢弃之外,还有一些附带行为,这会受 tcp_abort_on_overflow 参数的影响。
- tcp_abort_on_overflow设置为 0
会丢弃这个第三次握手ACK包,并且开启定时器,重传第二次握手的SYN+ACK,如果重传超过一定限制次数,还会把对应的半连接队列里的连接给删掉。
- tcp_abort_on_overflow设置为 1
全连接队列满了之后,就直接发RST给客户端,效果上看就是连接断了。
这里注意,服务端端口未监听时,客户端尝试去连接,服务端也会回一个 RST。这两个情况长一样,所以客户端这时候收到 RST 之后,其实无法区分到底是端口未监听,还是全连接队列满了。
半连接队列满
一般是丢弃,但这个行为可以通过 tcp_syncookies 参数去控制。
一般满了,之前提到过就是 SYN Flood 攻击。
遇到这种情况,可以将 tcp_syncookies 参数设置为 1,此时 SYN 发来,服务端不会将其放入半连接队列中,而是直接生成一个cookies,这个cookies会跟着第二次握手,发回客户端。客户端在发第三次握手的时候带上这个cookies,服务端验证到它就是当初发出去的那个,就会建立连接并放入到全连接队列中。可以看出整个过程不再需要半连接队列的参与。
注意,cookies并不会有一个专门的队列保存,它是通过通信双方的IP地址端口、时间戳、MSS等信息进行实时计算的,保存在TCP报头的seq里。
cookies 直接取代半连接队列?
服务端并不会保存连接信息,所以如果传输过程中数据包丢了,也不会重发第二次握手的信息。
编码解码 cookies,都是比较耗 CPU 的,利用这一点,如果此时攻击者构造大量的第三次握手包(ACK包),同时带上各种瞎编的cookies信息,就会消耗很多资源。
TCP 能保证数据不丢?
假设有两个用户,把中间的服务器简化掉,变成一个端到端的通信:
会首先三次握手,建立连接。
一个数据包,从聊天框里发出,消息会从聊天软件所在的用户空间拷贝到内核空间的发送缓冲区(send buffer),数据包就这样顺着传输层、网络层,进入到数据链路层,在这里数据包会经过流控(qdisc),再通过RingBuffer发到物理层的网卡。数据就这样顺着网卡发到了纷繁复杂的网络世界里。这里头数据会经过n多个路由器和交换机之间的跳转,最后到达目的机器的网卡处。
此时目的机器的网卡会通知DMA将数据包信息放到RingBuffer中,再触发一个硬中断给CPU,CPU触发软中断让ksoftirqd去RingBuffer收包,于是一个数据包就这样顺着物理层,数据链路层,网络层,传输层,最后从内核空间拷贝到用户空间里的聊天软件里。
建立连接丢包
半连接队列和全连接队列,如果参数没有设置,队列满了,就会丢包。
流量控制丢包
让数据按一定的规则排个队依次处理,也就是所谓的qdisc(Queueing Disciplines,排队规则),这也是我们常说的流量控制机制。
可以通过下面的ifconfig命令查看到,里面涉及到的txqueuelen后面的数字1000,其实就是流控队列的长度。
当发送数据过快,流控队列长度txqueuelen又不够大时,就容易出现丢包现象。
网卡丢包
RingBuffer 过小丢包
接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包。
一个网卡里是可以有多个RingBuffer的,所以上面的rx_queue_0_drops里的0代表的是第0个RingBuffer的丢包数,对于多队列的网卡,这个0还可以改成其他数字。
网卡性能不足
网卡作为硬件,传输速度是有上限的。当网络传输速度过大,达到网卡上限时,就会发生丢包。
接收缓冲区丢包
一般使用TCP socket进行网络编程的时候,内核都会分配一个发送缓冲区和一个接收缓冲区。
发送时,将数据拷贝到内核发送缓冲区就完事返回了,至于什么时候发数据,发多少数据,这个后续由内核自己做决定。
接收缓冲区作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。
# 查看接收缓冲区
# sysctl net.ipv4.tcp_rmem
net.ipv4.tcp_rmem = 4096 87380 6291456
# 查看发送缓冲区
# sysctl net.ipv4.tcp_wmem
net.ipv4.tcp_wmem = 4096 16384 4194304
不管是接收缓冲区还是发送缓冲区,都能看到三个数值,分别对应缓冲区的最小值,默认值和最大值 (min、default、max)。缓冲区会在min和max之间动态调整。
对于发送缓冲区,执行send的时候,如果是阻塞调用,那就会等,等到缓冲区有空位可以发数据。如果是非阻塞调用,就会立刻返回一个 EAGAIN 错误信息,意思是 Try again。让应用程序下次再重试。这种情况下一般不会发生丢包。
当接受缓冲区满了,事情就不一样了,它的TCP接收窗口会变为0,也就是所谓的零窗口,并且会通过数据包里的win=0,告诉发送端不要发消息了。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生丢包。
两端间网络丢包
只能通过一些命令来观察整个链路情况。
ping 查看
需要知道目的地的域名。想知道你的机器到服务器之间,有没有产生丢包行为。可以使用ping命令。
ping之后,查看 packet loss,就知道有没有丢包了。
mtr 命令
mtr命令可以查看到你的机器和目的机器之间的每个节点的丢包情况。
可以看到Host那一列,出现的都是链路中间每一跳的机器,Loss的那一列就是指这一跳对应的丢包率。
同时,因为mtr默认用的是ICMP包,有些节点限制了ICMP包,导致不能正常展示。可以加一个参数使用 udp 包来展示:
把ICMP包和UDP包的结果拼在一起看,就是比较完整的链路图了。有个小细节,Loss那一列,我们在icmp的场景下,关注最后一行,如果是0%,那不管前面loss是100%还是80%都无所谓,那些都是节点限制导致的虚报。
如何解决丢包
建立了TCP连接的两端,发送端在发出数据后会等待接收端回复ack包,ack包的目的是为了告诉对方自己确实收到了数据,但如果中间链路发生了丢包,那发送端会迟迟收不到确认ack,于是就会进行重传。以此来保证每个数据包都确确实实到达了接收端。
TCP 一定不会丢包?
TCP位于传输层,在它的上面还有各种应用层协议,比如常见的HTTP或者各类RPC协议。
TCP保证的可靠性,是传输层的可靠性。也就是说,TCP只保证数据从A机器的传输层可靠地发到B机器的传输层。
这时,就有可能在完成了传输层的正常传输之后,应用层需要从接收缓冲区取出数据,这时就可能导致丢包:
解决丢包
不再简化模型,重新加入服务器:
服务器可能记录了我们最近发过什么数据,假设每条消息都有个id,服务器和聊天软件每次都拿最新消息的id进行对比,就能知道两端消息是否一致,就像对账一样。
对于发送方,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。
如果接收方的聊天软件崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,同步上来就是了,所以也不存在上面提到的丢包情况。
可以看出,TCP只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。
两端通信的时候也能对账,为什么还要引入第三端服务器?
- 第一,如果是两端通信,你聊天软件里有1000个好友,你就得建立1000个连接。但如果引入服务端,你只需要跟服务器建立1个连接就够了,聊天软件消耗的资源越少,手机就越省电。
- 第二,就是安全问题,如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种鉴权校验。
- 第三,是软件版本问题。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,且两端的软件版本跨度太大,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本升级,否则不能使用软件。但对于大部分兼容性问题,给服务端加兼容逻辑就好了,不需要强制用户更新软件。
TCP 四次挥手可以变成三次?
在一些情况下, TCP 四次挥手是可以变成 TCP 三次挥手的。
TCP 四次挥手
具体过程:
- 客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;
- 服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
- 接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
- 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
- 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
- 客户端经过 2MSL 时间之后,也进入 CLOSE 状态;
四次挥手原因
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序:
- 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
- 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数;
如何关闭
关闭的连接的函数有两种函数:
- close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1,并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文。
- shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响。
如果调用 close 函数,这时如果挥手的时候客户端收到了服务端数据,因为客户端无法收发数据,就会直接回 RST 报文,然后内核释放连接。此时就不会经历完整四次挥手,是一种粗暴的关闭。当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:
- 如果是读操作,则会返回 RST 的报错,也就是我们常见的Connection reset by peer。
- 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。
shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以调用 shutdown 是优雅的关闭。
出现三次挥手?
当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见三次挥手的次数比四次挥手还多。TCP 延迟确认的策略:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
TCP 序列号和确认号的变化
万能公式
发送的 TCP 报文:
- 公式一:序列号 = 上一次发送的序列号 + len(数据长度)。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 上一次发送的序列号 + 1。
- 公式二:确认号 = 上一次收到的报文中的序列号 + len(数据长度)。特殊情况,如果收到的是 SYN 报文或者 FIN 报文,则改为上一次收到的报文中的序列号 + 1。
重点关注这三个字段的作用:
- 序列号:在建立连接时由内核生成的随机数作为其初始值,通过 SYN 报文传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
- 确认号:指下一次「期望」收到的数据的序列号,发送端收到接收方发来的 ACK 确认报文以后,就可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
- 控制位:用来标识 TCP 报文是什么类型的报文,比如是 SYN 报文、数据报文、ACK 报文,FIN 报文等。
三次握手
握手建立连接的过程就是上图所示,应该已经很熟悉了。
数据传输阶段
客户端发送 10 字节的数据,通常 TCP 数据报文的控制位是 [PSH, ACK],此时该 TCP 数据报文的序列号和确认号分别设置为:
- 序列号设置为 client_isn + 1。客户端上一次发送报文是 ACK 报文(第三次握手),该报文的 seq = client_isn + 1,由于是一个单纯的 ACK 报文,没有携带用户数据,所以 len = 0。根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 client_isn + 1 + 0,即 client_isn + 1。
- 确认号设置为 server_isn + 1。没错,还是和第三次握手的 ACK 报文的确认号一样,这是因为客户端三次握手之后,发送 TCP 数据报文 之前,如果没有收到服务端的 TCP 数据报文,确认号还是延用上一次的,其实根据公式 2 你也能得到这个结论。
接着,当服务端收到客户端 10 字节的 TCP 数据报文后,就需要回复一个 ACK 报文,此时该报文的序列号和确认号分别设置为:
- 序列号设置为 server_isn + 1。服务端上一次发送报文是 SYN-ACK 报文,序列号为 server_isn,根据公式 1(序列号 = 上一次发送的序列号 + len。特殊情况,如果上一次发送的报文是 SYN 报文或者 FIN 报文,则改为 + 1),所以当前的序列号为 server_isn + 1。
- 确认号设置为 client_isn + 11 。服务端上一次收到的报文是客户端发来的 10 字节 TCP 数据报文,该报文的 seq = client_isn + 1,len = 10。根据公式 2(确认号 = 上一次收到的报文中的序列号 + len),也就是将「收到的 TCP 数据报文中的序列号 client_isn + 1,再加上 10(len = 10) 」的值作为了确认号,表示自己收到了该 10 字节的数据报文。
这里注意特殊情况,如果第三次握手的 ACK 丢失,处于 SYN_RCVD 的服务端收到客户端第一个 TCP 数据,仍然是可以正常完成连接的。
四次挥手
这个应该也很熟悉了。
原文地址:https://blog.csdn.net/xhj12138/article/details/137928362
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!