文章目录
前言
本文将以tcp连接传输断开为导向,以需求为导向来完善功能,最终慢慢展现tcp的全部面部,来加深深刻的理解。(最后来一句,争取明年秋招找个好工作哈哈哈哈哈)
一、TCP介绍之基础知识补充
TCP首部介绍
标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述
序列号: 在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送⼀次数据,就累加⼀次该数据字节数的大小。用来解决网络包乱序问题。
确认应答号: 指下⼀次期望收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决不丢包的问题。
控制位:
- ACK: 该位为 1 时,确认应答的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必
须设置为 1 。 - RST: 该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
- SYN: 该位为 1 时,表示希望建立连接,并在其序列号的字段进⾏序列号初始值的设定。
- FIN: 该位为 1 时,表示今后不会再有数据发送,希望断开连接。 当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
对于tcp首部有了简单了解之后,相信初次学习tcp的人,最开始的疑惑是为什么要有tcp协议,tcp协议处于网络分层中的那一层
TCP协议的作用以及所在网络层
IP 层是不可靠的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
如果需要保障网络数据包的可靠性,那么就需要由上层 (传输层) 的 TCP 协议来负责。
因为 TCP 是⼀个⼯作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
所处网络分层如下:
当你给外人吹嘘tcp的时候,别人肯定会说卧槽tcp这么牛逼啊,那能不能简单介绍下tcp啊。当初你在学的时候,肯定也在想怎么把tcp简单的描述下呢。接下来我们就进行简单的描述哈哈
什么是 TCP
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
- 面向连接:⼀定是⼀对⼀才能连接,不能像 UDP 协议可以⼀个主机同时向多个主机发送消息,也就是⼀对多是无法做到的;
- 可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证⼀个报文⼀定能够到达接收端;
- 字节流:消息是没有边界的,所以无论我们消息有多大都可以进行传输。并且消息是有序的,当前⼀个消息没有收到的时候,即使它先收到了后面的字节,那么也不能扔给应用层去处理,同时对重复的报文会自动丢弃。
对于tcp有了简单了解之后,相信我们才真正网络编程中,总是会说tcp连接,而不是简单的说tcp协议。那什么是tcp连接呢,接下来我们简单了解一下
什么是TCP连接
简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以我们可以知道,建立⼀个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。
- Socket: 由 IP 地址和端口号组成
- 序列号:用来解决乱序问题等
- 窗口大小:用来做流量控制
通过对什么是tcp介绍我们知道,tcp是面向连接的,只有一对一才能连接。那怎么能确定这一对连接呢
唯一确定⼀个 TCP 连接
TCP 四元组可以唯⼀的确定⼀个连接,四元组包括如下:
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。
因此,客户端 IP 和 端⼝是可变的,其理论值计算公式如下:
对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端⼝数最多为 2 的 16 次方,也就是服务端单机最
大 TCP 连接数,约为 2 的 48 次方。
当然,服务端最⼤并发 TCP 连接数远不能达到理论上限。
- 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;
- 另⼀个是内存限制,每个 TCP 连接都要占⽤⼀定内存,操作系统的内存是有限的。
对于tcp有了基本的了解之后,接下来我们从开始建立tcp连接,到数据传输,再到最后的连接断开。我们一整个过程为导向,来zhuo步揭开tcp的全部面纱哈哈哈
二、TCP连接建立
TCP 是面向连接的协议,所以使用TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。
三次握手
直接上图:
⼀开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
报文如下:
- 客户端会随机初始化序号( client_isn ),将此序号置于 TCP 首部的序号字段中,同时把 SYN 标志
位置为 1 ,表示 SYN 报文。接着把第⼀个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不
包含应用层数据,之后客户端处于 SYN-SENT 状态。
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号( server_isn ),将此序号填入
TCP 首部的序号字段中,其次把 TCP 首部的确认应答号字段填⼊ client_isn + 1 , 接着把 SYN
和 ACK 标志位置为 1 。最后把该报⽂发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
客户端收到服务端报文后,还要向服务端回应最后⼀个应答报文,首先该应答报文 TCP 首部 ACK 标志位
置为 1 ,其次确认应答号字段填入 server_isn + 1 ,最后把报⽂发送给服务端,这次报文可以携带客
户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
服务器收到客户端的应答报⽂后,也进⼊ ESTABLISHED 状态。
从上面的过程可以发现第三次握手是可以携带数据的,前两次握⼿是不可以携带数据的,这也是面试常问的题。
⼀旦完成三次握⼿,双⽅都处于 ESTABLISHED 状态,此时连接就已建⽴完成,客户端和服务端就可以相互发送数据了。
知道了tcp建立连接的三次握手过程,那我是怎么知道tcp是处于建立连接的过程中的啊。
即如何查看tcp的状态
在 Linux 系统中查看 TCP 状态
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
了解完这些,接下来我们看一下关于三次握手常见的面试题,毕竟主要目的还是找个好工作啊。
为什么是三次握手?不是两次、四次?
在前⾯我们知道了什么是 TCP 连接:
- 用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以,重要的是为什么三次握手才可以初始化Socket、序列号和窗口大小并建立TCP 连接。
接下来以三个方面分析三次握手的原因:
- 三次握手才可以阻止重复历史连接的初始化(主要原因)
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
原因一:避免历史连接
简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
网络环境是错综复杂的,往往并不是如我们期望的⼀样,先发送的数据包,就先到达目标主机,反而它很骚,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?
客户端连续发送多次 SYN 建⽴连接的报文,在网络拥堵情况下:
- ⼀个旧 SYN 报文比最新的 SYN 报文早到达了服务端;
- 那么此时服务端就会回⼀个 SYN + ACK 报文给客户端;
- 客户端收到后可以根据自身的上下文,判断这是⼀个历史连接(序列号过期或超时),那么客户端就会发送RST 报文给服务端,表示中止这⼀次连接。
如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接:
- 如果是历史连接(序列号过期或超时),则第三次握手发送的报文是 RST 报文,以此中止历史连接;
- 如果不是历史连接,则第三次发送的报⽂是 ACK 报文,通信双⽅就会成功建立连接;
所以,TCP 使用三次握手建立连接的最主要原因是防⽌历史连接初始化了连接。
原因二:同步双方初始序列号
TCP 协议的通信双方, 都必须维护⼀个序列号, 序列号是可靠传输的⼀个关键因素,它的作用:
- 接收方可以去除重复的数据;
- 接收方可以根据数据包的序列号按序接收;
- 可以标识发送出去的数据包中, 哪些是已经被对方收到的;
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带初始序列号的 SYN 报文的时
候,需要服务端回⼀个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送初始序列号给客户端的时候,依然也要得到客户端的应答回应,这样⼀来⼀回,才能确保双方的初始序列号能被可靠的同步。
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成⼀步,所以就成了三次握手。而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
原因三:避免资源浪费
如果只有两次握手,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发
送 SYN ,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到⼀个 SYN 就只能先主动建立⼀个连接,这会造成什么情况呢?
如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会建⽴多个冗余的无效链接,造成不必要的资源浪费。
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
TCP 建立连接时,通过三次握手能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号。序列号能够保证数据包不重复、不丢弃和按序传输。
不使用两次握手和四次握手的原因:
- 两次握手:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
- 四次握手:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
为什么客户端和服务端的初始序列号 ISN 是不相同的?
如果⼀个已经失效的连接被重用了,但是该旧连接的历史报文还残留在网络中,如果序列号相同,那么就无法分辨出该报⽂是不是历史报文,如果历史报文被新的连接接收了,则会产生数据错乱。
所以,每次建⽴连接前重新初始化⼀个序列号主要是为了通信双⽅能够根据序号将不属于本连接的报文段丢弃。
另一方面是为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收。
初始序列号 ISN 是如何随机产生的?
起始 ISN 是基于时钟的,每 4 毫秒 + 1,转⼀圈要 4.55 个小时。
RFC1948 中提出了⼀个较好的初始化序列号 ISN 随机⽣成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
- M 是⼀个计时器,这个计时器每隔 4 毫秒加 1。
- F是⼀个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成⼀个随机数值。要保证 Hash 算法不能
被外部轻易推算得出,用MD5 算法是⼀个比较好的选择。
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
我们先来认识下 MTU 和 MSS
- MTU :⼀个网络包的最大长度,以太网中⼀般为 1500 字节;
- MSS :除去 IP 和 TCP 头部之后,⼀个网络包所能容纳的 TCP 数据的最大长度;
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有⼀个超过 MTU 大小的数据(TCP 头部 + TCP 数据) 要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每⼀个分片都小于 MTU。把⼀份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上⼀层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么当如果⼀个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发整个 TCP 报文(头部 + 数据)。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用IP 分片了。
经过 TCP 层分片后,如果⼀个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
什么是 SYN 攻击?如何避免 SYN 攻击?
-
SYN 攻击:
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到⼀个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
-
避免 SYN 攻击方式一
其中⼀种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度⼤于内核处理的速度时,会有⼀个队列保存这些数据包。控制该队列的最大值如下参数:
net.core.netdev_max_backlog
SYN_RCVD 状态连接的最大个数:
net.ipv4.tcp_max_syn_backlog
超出处理能时,对新的 SYN 直接回报 RST,丢弃连接:
net.ipv4.tcp_abort_on_overflow -
避免 SYN 攻击方式二
我们先来看下 Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的?
正常流程:
(1)当服务端接收到客户端的 SYN 报文时,会将其加入到内核的SYN 队列;
(2)接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
(3)服务端接收到 ACK 报文后,从SYN 队列移除放入到Accept 队列;
(4)应⽤通过调用 accpet() socket 接口,从Accept 队列取出连接。
应用程序过慢:
(1)如果应用程序过慢时,就会导致Accept 队列被占满
受到 SYN 攻击:
(1)如果不断受到 SYN 攻击,就会导致SYN 队列被占满。
tcp_syncookies 的方式可以应对 SYN 攻击的方法:
(1)net.ipv4.tcp_syncookies = 1
- 当 SYN 队列满之后,后续服务器收到 SYN 包,不进入SYN 队列;
- 计算出⼀个 cookie 值,再以 SYN + ACK 中的序列号返回客户端,
- 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放⼊到Accept队列。
- 最后应用通过调用accpet() socket 接口,从Accept 队列取出的连接。
防御 SYN 攻击
这里给出几种防御 SYN 攻击的方法:
- 增大半连接队列;
- 开启 tcp_syncookies 功能
- 减少 SYN+ACK重传次数
方式一:增大半连接队列
要想增大半连接队列,我们得知**不能只单纯增大tcp_max_syn_backlog 的值,还需⼀同增大somaxconn 和 backlog,也就是增大全连接队列。**否则,只单纯增大tcp_max_syn_backlog 是无效的。
增大tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
增大backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:
最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。
方式二:开启 tcp_syncookies 功能
开启 tcp_syncookies 功能的方式也很简单,修改 Linux 内核参数:
方式三:减少 SYN+ACK重传次数
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传
SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
明白了这tcp协议建立连接的原理,作为一个程序员,最终还是要落实到程序上。接下来我们看看程序中是怎么建立tcp连接的吧。
Socket编程之连接建立
针对 TCP 应该如何 Socket 编程?
直接上图:
- 服务端和客户端初始化 socket ,得到文件描述符;
- 服务端调用bind ,将绑定在 IP 地址和端口;
- 服务端调用listen ,进行监听;
- 服务端调用accept ,等待客户端连接;
- 客户端调用connect ,向服务器端的地址和端口发起连接请求;
- 服务端 accept 返回用于传输的 socket 的文件描述符;
- 客户端调用 write 写入数据;服务端调用read 读取数据;
- 客户端断开连接时,会调用 close ,那么服务端 read 读取数据的时候,就会读取到了 EOF ,待处理完
数据后,服务端调用 close ,表示连接关闭。
这里需要注意的是,服务端调用accept 时,连接成功了会返回⼀个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是两个socket,⼀个叫作监听 socket,⼀个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往⼀个文件流里面写东西⼀样。
listen 时候参数 backlog 的意义
Linux内核中会维护两个队列:
- 未完成连接队列(SYN 队列): 接收到⼀个 SYN 建立连接请求,处于 SYN_RCVD 状态;
- 已完成连接队列(Accpet 队列): 已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
int listen (int socketfd, int backlog) - 参数一socketfd 为 socketfd 文件描述符
- 参数二backlog,这参数在历史版本有⼀定的变化
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
accept 发生在三次握手的哪一步?
我们先看看客户端连接服务端时,发送了什么?
- 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进入
SYN_SENT 状态; - 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包
client_isn 的确认,同时服务器也发送⼀个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进入 SYN_RCVD 状态; - 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进⾏应答,应答数据为server_isn+1;
- 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调⽤返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进⼊ ESTABLISHED 状态。
明白了tcp三次握手的过程原理和socket编程建立连接,但是实际在网络传输中怎么传输的啊,还得靠抓包看下,毕竟眼见为识麻。
TCP三次握手抓包实战分析
通过抓包来对tcp三次握手的三个异常情况进行分析
TCP 第一次握手SYN 丢包
SYN丢包后,客户端会发生什么直接上抓包的图:
从上图可以发现, 客户端发起了 SYN 包后,一直没有收到服务端的 ACK ,所以⼀直超时重传传了 5 次,并且每次RTO 超时时间是不同的:
第⼀次是在 1 秒超时重传
第⼆次是在 3 秒超时重传
第三次是在 7 秒超时重传
第四次是在 15 秒超时重传
第五次是在 31 秒超时重传
可以发现,每次超时时间 RTO 是指数(翻倍)上涨的,当超过最大重传次数后,客户端不再发送 SYN 包。
在 Linux 中,第⼀次握手的 SYN 超时重传次数,是如下内核参数指定的:
tcp_syn_retries 默认值为 5,也就是 SYN 最大重传次数是 5 次。
接下来,我们继续做实验,把 tcp_syn_retries 设置为 2 次:
重传抓包后,用Wireshark 打开分析,显示如下图:
由此我们可以得知,当客户端发起的 TCP 第一次握手 SYN 包,在超时时间内没收到服务端的ACK,就会在超时重传 SYN 数据包,每次超时重传的 RTO 是翻倍上涨的,直到 SYN 包的重传次数到达tcp_syn_retries 值后,客户端不再发送 SYN 包。
TCP 第二次握手 SYN、ACK 丢包
直接上抓包的时序图:
从图中可以发现:
- 客户端发起 SYN 后,由于防火墙屏蔽了服务端的所有数据包,所以 curl 是无法收到服务端的 SYN、ACK
包,当发生超时后,就会重传 SYN 包 - 服务端收到客户的 SYN 包后,就会回 SYN、ACK 包,但是客户端⼀直没有回 ACK,服务端在超时后,重传了 SYN、ACK 包,接着⼀会,客户端超时重传的 SYN 包又抵达了服务端,服务端收到后,超时定时器就重新计时,然后回了 SYN、ACK 包,所以相当于服务端的超时定时器只触发了⼀次,又被重置了。(重置重点)
- 最后,客户端 SYN 超时重传次数达到了 5 次(tcp_syn_retries 默认值 5 次),就不再继续发送 SYN 包了。
所以,我们可以发现,当第二次握手的 SYN、ACK 丢包时,客户端会超时重发 SYN 包,服务端也会超时重传
SYN、ACK 包。
tcp_syn_retries 是限制 SYN 重传次数,那第二次握手SYN、ACK 限制最大重传次数是多少?
TCP 第⼆次握⼿ SYN、ACK 包的最大重传次数是通过 tcp_synack_retries 内核参数限制的,其默认值如下:
是的,TCP 第二次握手 SYN、ACK 包的最大重传次数默认值是 5 次。
为了验证 SYN、ACK 包最大重传次数是 5 次,我们继续做下实验,我们先把客户端的 tcp_syn_retries 设置为
1,表示客户端 SYN 最大超时次数是 1 次,目的是为了防止多次重传 SYN,把服务端 SYN、ACK 超时定时器重置。
接着抓包,看时序图:
从上图,我们可以分析出:
- 客户端的 SYN 只超时重传了 1 次,因为 tcp_syn_retries 值为 1
- 服务端应答了客户端超时重传的 SYN 包后,由于⼀直收不到客户端的 ACK 包,所以服务端⼀直在超时重传SYN、ACK 包,每次的 RTO 也是指数上涨的,⼀共超时重传了 5 次,因为 tcp_synack_retries 值为 5
接着,我把 tcp_synack_retries 设置为 2, tcp_syn_retries 依然设置为 1:
接着抓包,看时序图:
可见:
- 客户端的 SYN 包只超时重传了 1 次,符合 tcp_syn_retries 设置的值;
- 服务端的 SYN、ACK 超时重传了 2 次,符合 tcp_synack_retries 设置的值
由此可以得知,当 TCP 第二次握手SYN、ACK 包丢了后,客户端 SYN 包会发生超时重传,服务端 SYN、ACK 也会发生超时重传。(注意重置)
客户端 SYN 包超时重传的最大次数,是由 tcp_syn_retries 决定的,默认值是 5 次;服务端 SYN、ACK 包超时重传的最大次数,是由 tcp_synack_retries 决定的,默认值是 5 次。
TCP第三次握手ACK 丢包
由于服务端收不到第三次握手的 ACK 包,所以一直处于 SYN_RECV 状态:
而客户端是已完成 TCP 连接建立,处于 ESTABLISHED 状态:
过了 1 分钟后,观察发现服务端的 TCP 连接不见了:
过了 30 分钟,客户端依然还是处于 ESTABLISHED 状态:
接着,在刚才客户端建立的 telnet 会话,输入123456 字符,进行发送:
持续好长⼀段时间,客户端的 telnet 才断开连接:
以上就是本次的实现三的现象,这里存在两个疑点:
- 为什么服务端原本处于 SYN_RECV 状态的连接,过 1 分钟后就消失了?
- 为什么客户端 telnet 输入123456 字符后,过了好长⼀段时间,telnet 才断开连接?
看下抓包时序图就明白了:
上图的流程:
- 客户端发送 SYN 包给服务端,服务端收到后,回了个 SYN、ACK 包给客户端,此时服务端的 TCP 连接处于SYN_RECV 状态;
- 客户端收到服务端的 SYN、ACK 包后,给服务端回了个 ACK 包,此时客户端的 TCP 连接处于
ESTABLISHED 状态; - 由于服务端配置了防火墙,屏蔽了客户端的 ACK 包,所以服务端⼀直处于 SYN_RECV 状态,没有进入
ESTABLISHED 状态,tcpdump 之所以能抓到客户端的 ACK 包,是因为数据包进入系统的顺序是先进⼊
tcpudmp,后经过 iptables; - 接着,服务端超时重传了 SYN、ACK 包,重传了 5 次后,也就是超过 tcp_synack_retries 的值(默认值是5),然后就没有继续重传了,此时服务端的 TCP 连接主动中止了,所以刚才处于 SYN_RECV 状态的 TCP连接断开了,而客户端依然处于 ESTABLISHED 状态;
- 虽然服务端 TCP 断开了,但过了一段时间,发现客户端依然处于 ESTABLISHED 状态,于是就在客户端的telnet 会话输入了 123456 字符;
- 此时由于服务端已经断开连接,客户端发送的数据报文,⼀直在超时重传,每⼀次重传,RTO 的值是指数增长的,所以持续了好长一时间,客户端的 telnet 才报错退出了,此时共重传了 15 次。
通过这⼀波分析,刚才的两个疑点已经解除了:
- 服务端在重传 SYN、ACK 包时,超过了最大重传次数 tcp_synack_retries ,于是服务端的 TCP 连接主动断开了。
- 客户端向服务端发送数据包时,由于服务端的 TCP 连接已经退出了,所以数据包⼀直在超时重传,共重传了15 次, telnet 就断开了连接。
TCP 第一次握⼿的 SYN 包超时重传最大次数是由 tcp_syn_retries 指定,TCP 第⼆次握手的 SYN、ACK 包超时重传最大次数是由 tcp_synack_retries 指定,那 TCP 建⽴连接后的数据包最大超时重传次数是由什么参数指定呢?
TCP 建立连接后的数据包传输,最大超时重传次数是由 tcp_retries2 指定,默认值是 15 次,如下:
如果 15 次重传都做完了,TCP 就会告诉应⽤层说:“搞不定了,包怎么都传不过去!”
那如果客户端不发送数据,什么时候才会断开处于 ESTABLISHED 状态的连接?
这里就需要提到 TCP 的 保活机制。这个机制的原理是这样的:
定义⼀个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔⼀个时间间隔,发送⼀个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现⼀个死亡连接。
由此可知,在建立TCP 连接时,如果第三次握手的 ACK,服务端无法收到,则服务端就会短暂处于 SYN_RECV 状态,而客户端会处于 ESTABLISHED 状态。
由于服务端⼀直收不到 TCP 第三次握手的 ACK,则会⼀直重传 SYN、ACK 包,直到重传次数超过
tcp_synack_retries 值(默认值 5 次)后,服务端就会断开 TCP 连接。
而客户端则会有两种情况:
- 如果客户端没发送数据包,⼀直处于 ESTABLISHED 状态,然后经过 2 小时 11 分 15 秒才可以发现⼀个
死亡连接,于是客户端连接就会断开连接。 - 如果客户端发送了数据包,一直没有收到服务端对该数据包的确认报文,则会⼀直重传该数据包,直到重传次数超过 **tcp_retries2 值(默认值 15 次)**后,客户端就会断开 TCP 连接。
对tcp连接建立的三次握手的每次握手的过程了解后,这里还有两个重要的队列,我们也得说下。那就是半连接队列和全连接队列。
TCP半连接队列和全连接队列
什么是 TCP 半连接队列和全连接队列
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
- 半连接队列,也称 SYN 队列;
- 全连接队列,也称 accepet 队列;
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握⼿的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
接下来考虑下,这两个队列溢出的异常情况
TCP 全连接队列溢出
如何知道应用程序的 TCP 全连接队列大小?
在服务端可以使用ss 命令,来查看 TCP 全连接队列的情况;
但需要注意的是 ss 命令获取的 Recv-Q/Send-Q 在LISTEN 状态和非LISTEN 状态所表达的含义是不
同的。从下面的内核代码可以看出区别:
在LISTEN 状态时, Recv-Q/Send-Q 表示的含义如下:
- Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
- Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为128;
在非LISTEN 状态时, Recv-Q/Send-Q 表示的含义如下:
- Recv-Q:已收到但未被应用进程读取的字节数;
- Send-Q:已发送但未收到确认的字节数;
模拟 TCP 全连接队列溢出的场景:
其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP 全连接队列上升到了 129 大小,超过了最大TCP 全连接队列。
当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用netstat -s 命令来查看:
上面看到的 41150 times ,表示全连接队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字⼀直在增加的话肯定全连接队列偶尔满了。
从上面结果可以得知,当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生
TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。
Linux 有个参数可以指定当 TCP 全连接队列满了会使用什么策略来回应客户端:
实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:
- 0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;
- 1 :如果全连接队列满了,server 发送⼀个 reset 包给 client,表示废掉这个握手过程和这个连接;
如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把
tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 TCP 全连接队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何增大 TCP 全连接队列呢?
当发现 TCP 全连接队列发生溢出的时候,我们就需要增大该队列的大小,以便可以应对客户端大量的请求。
TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。 从下面的 Linux 内核代码可以得知:
- somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其
值; - backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改
配置文件设置其长度;
接下来我们把全连接队列的长度搞大
把 somaxconn 设置成 5000:
接着把 Nginx 的 backlog 也同样设置成 5000:
最后要重启 Nginx 服务,因为只有重新调用 listen() 函数 TCP 全连接队列才会重新初始化。
重启完后 Nginx 服务后,服务端执行ss 命令,查看 TCP 全连接队列大小:
从执⾏结果,可以发现 TCP 全连接最大值为 5000。
如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。
TCP半连接队列溢出
如何查看 TCP 半连接队列长度?
TCP 半连接队列长度的长度,没有像全连接队列那样可以用ss 命令查看。但是我们可以抓住 TCP 半连接的特点,就是服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列。于是,我们可以使用如下命令计算当前 TCP 半连接队列长度:
TCP 半连接队列溢出场景:
TCP 半连接溢出场景实际上就是对服务端⼀直发送 TCP SYN 包,但是不回第三次握手ACK,这样就
会使得服务端有大量的处于 SYN_RECV 状态的 TCP 连接。这其实也就是所谓的 SYN 洪泛、SYN 攻击、DDos 攻击。
当服务端受到 SYN 攻击后,连接服务端 ssh 就会断开了,无法再连上。只能在服务端主机上执行查看当前 TCP 半连接队列大小:
同时,还可以通过 netstat -s 观察半连接队列溢出的情况:
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。
半连接队列的长度:
1.如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
2.若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
3. 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去当前半连接队列长度小于(max_syn_backlog >> 2),则会丢弃;
最大值决定如下:
- 当 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;
半连接队列最大值 max_qlen_log 就表示服务端处于 SYN_REVC 状态的最大个数吗?
并不是。max_qlen_log 是理论半连接队列最⼤值,并不⼀定代表服务端处于 SYN_REVC 状态的最大个数。
在前面我们在分析 TCP 第⼀次握手(收到 SYN 包)时会被丢弃的三种条件:
1.如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
2.若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
3. 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去当前半连接队列长度小于(max_syn_backlog >> 2),则会丢弃;
假设条件 1 当前半连接队列的长度 没有超过理论的半连接队列最⼤值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最⼤个数不会是理论值 max_qlen_log。
接着进行实验:
服务端环境如下:
配置完后,服务端要重启 Nginx,因为全连接队列最大值和半连接队列最大值是在 listen() 函数初始化。
计算出半连接队列 max_qlen_log 的最⼤值为 256;
此时,客户端执行hping3 发起 SYN 攻击:
服务端执行如下命令,查看处于 SYN_RECV 状态的最大个数:
可以发现,服务端处于 SYN_RECV 状态的最大个数并不是 max_qlen_log 变量的值。
这就是前面所说的原因:如果当前半连接队列的长度 没有超过理论半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最大个数不会是理论值max_qlen_log。
分析一波条件 3 :
从上面的分析,可以得知如果触发当前半连接队列长度 > 192条件,TCP 第⼀次握手的 SYN 包是会被丢弃的。
在前面我们测试的结果,服务端处于 SYN_RECV 状态的最大个数是 193,正好是触发了条件 3,所以处于
SYN_RECV 状态的个数还没到理论半连接队列最大值 256,就已经把 SYN 包丢弃了。
所以,服务端处于 SYN_RECV 状态的最大个数分为如下两种情况:
- 如果当前半连接队列没超过理论半连接队列最大值,但是超过 max_syn_backlog -(max_syn_backlog >> 2),那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog -(max_syn_backlog >> 2);
- 如果当前半连接队列超过理论半连接队列最大值,那么处于 SYN_RECV 状态的最大个数就是理论半连接队列最大值;
如果 SYN 半连接队列已满,只能丢弃连接吗?
并不是这样,开启 syncookies 功能就可以在不使用SYN 半连接队列的情况下成功建立连接,当开启了 syncookies 功能就不会丢弃连接。
syncookies 是这么做的:服务器根据当前状态计算出⼀个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
syncookies 参数主要有以下三个值:
- 0 值,表示关闭该功能;
- 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
- 2 值,表示⽆条件开启功能;
那么在应对 SYN 攻击时,只需要设置为 1 即可:
syncookies 启用后就不需要半链接了?那请求的数据会存在哪里?
syncookies = 1 时,半连接队列满后,后续的请求就不会存放到半连接队列了,而是在第二次握手的时候,服务端会计算⼀个 cookie 值, 放入到 SYN +ACK 包中的序列号发给客户端,客户端收到后并回 ack ,服务端就会校验连接是否合法,合法就直接把连接放⼊到全连接队列。
对于TCP连接的三次握手到此应该时彻底明白了,但是在实际开发中,难免会让对性能做出优化,接下来就学习下怎么对tcp连接建立的性能的提升
TCP 三次握手的性能提升
TCP 是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。
那么,三次握手的过程在⼀个 HTTP 请求的平均时间占比10% 以上,在网络状态不佳、高并发或者遭遇 SYN 攻击等场景中,如果不能有效正确的调节三次握手中的参数,就会对性能产生很多的影响。
如何正确有效的使用这些参数,来提高TCP 三次握手的性能,这就需要理解三次握手的状态变迁,这样当出现问题时,先用netstat 命令查看是哪个握手阶段出现了问题,再来对症下药,而不是病急乱投医。
客户端和服务端都可以针对三次握手优化性能。主动发起连接的客户端优化相对简单些,而服务端需要监听端口,属于被动连接方,其间保持许多的中间状态,优化方法相对复杂⼀些。
所以,客户端(主动发起连接方)和服务端(被动连接方)优化的方式是不同的,接下来分别针对客户端和服务端优化。
客户端优化
三次握手建立连接的首要目的是同步序列号。
只有同步了序列号才有可靠传输,TCP 许多特性都依赖于序列号实现,比如流量控制、丢包重传等,这也是三次握手中的报⽂称为 SYN 的原因,SYN 的全称就叫 Synchronize Sequence Numbers(同步序列号)。
SYN_SENT 状态的优化
客户端作为主动发起连接方,首先它将发送 SYN 包,于是客户端的连接就会处于 SYN_SENT 状态。
客户端在等待服务端回复的 ACK 报文,正常情况下,服务器会在几毫秒内返回 SYN+ACK ,但如果客户端⻓时间没有收到 SYN+ACK 报⽂,则会重发 SYN 包,重发的次数由 tcp_syn_retries 参数控制,默认是 5 次:
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上⼀次的 2 倍。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就会终⽌三次握⼿。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
服务端优化
当服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。
此时,服务端出现了新连接,状态是 SYN_RCV 。在这个状态下,Linux 内核就会建立⼀个半连接队列来维护未完成的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。
SYN 攻击,攻击的是就是这个半连接队列。
如何查看由于 SYN 半连接队列已满,而被丢弃连接的情况?
通过该 netstat -s 命令给出的统计结果中, 可以得到由于半连接队列已满,引发的失败次数:
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。
SYN_RCV 状态的优化:
当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 给服务器,同时客户端连接状态从 SYN_SENT转换为 ESTABLISHED,表示连接建立成功。
服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的 ACK 后,服务端的连接状态才变为
ESTABLISHED。
如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。
当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数:
tcp_synack_retries 的默认重试次数是 5 次,与客户端重传 SYN 类似,它的重传会经历 1、2、4、8、16 秒,最后⼀次重传后会继续等待 32 秒,如果服务端仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。
服务器收到 ACK 后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调⽤ accept 函数时把连接取出来。
如果进程不能及时地调⽤ accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP连接被丢弃。
accept 队列已满,只能丢弃连接吗?
丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这⼀功能需要将 tcp_abort_on_overflow 参数设置为 1。
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:
- 0 :如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack ;
- 1 :如果 accept 队列满了,server 发送⼀个 RST 包给 client,表示废掉这个握⼿过程和这个连接;
如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把
tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,客户端进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,客户端的请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建⽴连接。
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率(是因为发送数据的报文带有ack),只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何调整 accept 队列的长度呢?
accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中:
- somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 net.core.somaxconn 来设置其值;
- backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小;
Tomcat、Nginx、Apache 常⻅的 Web 服务的 backlog 默认值都是 511。
如何查看服务端进程 accept 队列的长度?
可以通过 ss -ltn 命令查看:
- Recv-Q:当前 accept 队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
- Send-Q:accept 队列最大长度,上面的输出结果说明监听 8088 端口的 TCP 服务,accept 队列的最大长度为 128;
如何查看由于 accept 连接队列已满,而被丢弃的连接?
当超过了 accept 连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用netstat -s 命令来查看:
上面看到的 41150 times ,表示 accept 队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字⼀直在增加的话,说明 accept 连接队列偶尔满了。
如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。
如何绕过三次握手?
以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。
三次握手建立连接造成的后果就是,HTTP 请求必须在⼀个 RTT(从客户端到服务器⼀个往返的时间)后才能发送。
在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。
TCP Fast Open 功能的工作方式:
在客户端首次建立连接时的过程:
- 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast
Open Cookie; - 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 数据包中的 Fast Open 选项以发回客户端;
- 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。
所以,第一次发起 HTTP GET 请求的时候,还是需要正常的三次握手流程。
之后,如果客户端再次向服务器建立连接时的过程:
- 客户端发送 SYN 报文,该报文包含数据(对于非 TFO 的普通 TCP 握手过程,SYN 报文中不包含数
据)以及此前记录的 Cookie; - 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和数据进行确认,服务器随后将数据递送至相应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的数据,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;
- 如果服务器接受了 SYN 报文中的数据,服务器可在握手完成之前发送数据,这就减少了握手带来的1 个 RTT 的时间消耗;
- 客户端将发送 ACK 确认服务器发回的 SYN 以及数据,但如果客户端在初始的 SYN 报文中发送的数
据没有被确认,则客户端将重新发送数据; - 此后的 TCP 连接的数据传输过程和⾮ TFO 的正常情况⼀致。
所以,之后发起 HTTP GET 请求的时候,可以绕过三次握手,这就减少了握手带来的 1 个 RTT 的时间消耗。
开启了 TFO 功能,cookie 的值是存放到 TCP option 字段里的:
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)。
Linux 下怎么打开 TCP Fast Open 功能呢?
在 Linux 系统中,可以通过设置 tcp_fastopn 内核参数,来打开 Fast Open 功能:
tcp_fastopn 各个值的意义:
- 0 关闭
- 1 作为客户端使⽤ Fast Open 功能
- 2 作为服务端使⽤ Fast Open 功能
- 3 ⽆论作为客户端还是服务器,都可以使⽤ Fast Open 功能
TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。
关于优化 TCP 三次握手的几个 TCP 参数:
至此TCP连接的建立相关知识已经学完了,接下来便是数据的传输了
三、TCP数据传输中的机制
TCP 是⼀个可靠传输的协议,那它是如何保证可靠的呢?
为了实现可靠性传输,需要考虑很多事情,例如数据的破坏、丢包、重复以及分片顺序混乱等问题。如不能解决这些问题,也就无从谈起可靠传输。
那么,TCP 是通过序列号、确认应答、重发控制、连接管理以及窗⼝控制等机制实现可靠性传输的。
将重点介绍 TCP 的重传机制、滑动窗口、流量控制、拥塞控制。
重传机制
TCP 实现可靠传输的方式之一,是通过序列号与确认应答。
在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回⼀个确认应答消息,表示已收到消息。
但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?
所以 TCP 针对数据包丢失的情况,会用重传机制解决。
接下来说说常见的重传机制:
- 超时重传
- 快速重传
- SACK
- D-SACK
超时重传
重传机制的其中⼀个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK确认应答报文,就会重发该数据,也就是我们常说的超时重传。
TCP 会在以下两种情况发生超时重传:
- 数据包丢失
- 确认应答丢失
超时时间应该设置为多少呢?
先来了解⼀下什么是 RTT (Round-Trip Time 往返时延),从下图我们就可以知道:
RTT 就是数据从网络⼀端传送到另一端所需的时间,也就是包的往返时间。
超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示。
假设在重传的情况下,超时时间 RTO 较长或较短时,会发生什么事情呢?
上图中有两种超时时间不同的情况:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效。
根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
至此,可能大家觉得超时重传时间 RTO 的值计算,也不是很复杂嘛。
好像就是在发送端发包时记下 t0 ,然后接收端再把这个 ack 回来时再记一个 t1 ,于是 RTT = t1 – t0 。没
那么简单,这只是一个采样,不能代表普遍情况。
实际上报文往返 RTT 的值是经常变化的,因为我们的网络也是时常变化的。也就因为报文往返 RTT 的值是经常波动变化的,所以超时重传时间 RTO 的值应该是⼀个动态变化的值。
我们来看看 Linux 是如何计算 RTO 的呢?
估计往返时间,通常需要采样以下两个:
- 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出⼀个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
- 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有⼀个⼤的波动的话,很难被发现的情况。
RFC6289 建议使用以下的公式计算 RTO:
其中 SRTT 是计算平滑的RTT , DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。
也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?
于是就可以用快速重传机制来解决超时重发的时间等待。
快速重传
TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传。
快速重传机制,是如何工作的呢?其实很简单,一图胜千言。
在上图,发送方发出了 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 不同的实现,以上两种情况都是有可能的。可见,这是一把双刃剑。
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
SACK 方法
还有一种实现重传机制的方式叫: SACK ( Selective Acknowledgment 选择性确认)。
这种方式需要在 TCP 头部选项字段里加⼀个 SACK 的东西,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有
200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
如果要支持 SACK ,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux2.4 后默认打开)。
Duplicate SACK
Duplicate SACK 又称 D-SACK ,其主要使用了 SACK 来告诉发送方有哪些数据被重复接收了。
栗子一号:ACK 丢包
- 接收方发给发送方的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~
3499) - 于是接收方发现数据是重复收到的,于是回了一个 SACK = 3000 ~ 3500,告诉发送方3000~3500
的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个
SACK 就代表着 D-SACK 。 - 这样发送方就知道了,数据没有丢,是接收方的 ACK 确认报文丢了。
栗子二号:网络延时
- 数据包(1000~1499) 被网络延迟了,导致发送方没有收到 Ack 1500 的确认报文。
- 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包
(1000~1499)又到了接收方; - 所以接收方回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表
示收到了重复的包。 - 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。
可见, D-SACK 有这么几个好处:
- 可以让发送方知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
- 可以知道是不是发送方的数据包被网络延迟了;
- 可以知道网络中是不是把发送方的数据包给复制了;
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。
滑动窗口
引入窗口概念的原因:
我们都知道 TCP 是每发送⼀个数据,都要进行一次确认应答。当上⼀个数据包收到了应答了, 再发送下⼀个。这个模式就有点像我和你面对面聊天,你⼀句我⼀句。但这种方式的缺点是效率比较低的。如果你说完⼀句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下⼀句话,很显然这不现实。
所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。
为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。
那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
假设窗口大小为 3 个 TCP 段,那么发送方就可以连续发送 3 个 TCP 段,并且 中途若有 ACK 丢失,可以通过下⼀个确认应答进行确认 。如下图:
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK700 确认应答,就意味着 700 之前的所有数据接收方都收到了。这个模式就叫累计确认或者累计应答。
窗口大小由哪一方决定?
TCP 头里有一个字段叫 Window ,也就是窗口大小。
这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
所以,通常窗口的大小是由接收方的窗口大小来决定的。
发送方发送的数据大小不能超过接收方的窗口大小 ,否则接收方就无法正常接收到数据。
发送方的滑动窗口
我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:
- #1 是已发送并收到 ACK确认的数据:1~31 字节
- #2 是已发送但未收到 ACK确认的数据:32~45 字节
- #3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节
- #4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后
在下图,当发送方把数据全部都⼀下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到ACK 确认之前是无法继续发送数据了。
在下图,当收到之前发送的数据 32 ~ 36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送 52 ~56 这 5 个字节的数据了。
程序是如何表示发送方的四个部分的呢?
TCP 滑动窗口方案使用三个指针来跟综在四个传输类别中的每⼀个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
- SND.WND :表示发送窗口的大小(大小是由接收方指定的);
- SND.UNA :是⼀个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
- SND.NXT :也是⼀个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第⼀个字节。
- 指向 #4 的第⼀个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND ⼤⼩的偏移ᰁ,就可以指向
#4 的第⼀个字节了。
那么可用窗口大小的计算就可以是:
可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方的滑动窗口:
接下来我们看看接收方的窗口,接收窗口相对简单一些,根据处理的情况划分成三个部分:
- #1 + #2 是已成功接收并确认的数据(等待应用进程读取);
- #3 是未收到数据但可以接收的数据;
- #4 未收到数据并不可以接收的数据;
其中三个接收部分,使用两个指针进行划分: - RCV.WND :表示接收窗口的大小,它会通告给发送方
- RCV.NXT :是一个指针,它指向期望从发送方发送来的下⼀个数据字节的序列号,也就是 #3 的第⼀个字节。
- 指向 #4 的第⼀个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向
#4 的第⼀个字节了。
接收窗口和发送窗口的大小是相等的吗?
并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。
如果⼀直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供⼀种机制可以让发送方根据接收方的实际接收能力控制发送的数据量,这就是所谓的流量控制。
下⾯举个栗子,为了简单起见,假设以下场景:
- 客户端是接收方,服务端是发送方
- 假设接收窗口和发送窗口相同,都为 200
- 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响
根据上图的流量控制,说明下每个过程:
- 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口。
- 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,同时
SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321。 - 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节, RCV.NXT 也就指向 321,这意味着客户端期望的下⼀个报文的序列号是 321,接着发送确认报文给服务端
- 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法再继续发送数据。
- 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节, RCV.NXT 也就指向 441,接着发送确认报文给服务端。
- 服务端收到对 80 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 321,于是可用窗口Usable
增大到 80。 - 服务端收到对 120 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable
增⼤到 200 - 服务端可以继续发送了,于是发送了 160 字节的数据后, SND.NXT 指向 601,于是可⽤窗⼝ Usable 减
少到 40。 - 客户端收到 160 字节后,接收窗⼝往右移动了 160 字节, RCV.NXT 也就是指向了 601,接着发送确认报⽂给服务端。
- 服务端收到对 160 字节数据的确认报⽂后,发送窗⼝往右移动了 160 字节,于是 SND.UNA 指针偏移了
160 后指向 601,可⽤窗⼝ Usable 也就增⼤⾄了 200。
操作系统缓冲区与滑动窗口的关系
前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。
当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。
那操作系统的缓冲区,是如何影响发送窗口和接收窗口的呢?
我们先来看看第⼀个例子:
当应用程序没有及时读取缓存时,发送窗口和接收窗口的变化。
考虑以下场景:
- 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 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 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会说,这里先简单提⼀下。
我们先来看看第二个例子:
当服务端系统资源非常紧张的时候,操作系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。
说明下每个过程:
- 客户端发送 140 字节的数据,于是可用窗口减少到了 220。
- 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 120 字节,当收到 140 字节数据后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方。
- 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40。
- 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了。
- 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗⼝到 100,把窗⼝的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值。
所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。
为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
窗口关闭
在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。
如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
窗口关闭潜在的危险:
接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。
那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告⼀个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络络中丢失了,那麻烦就大了。
这会导致发送方一直等待接收方的非0窗口通知,接收方也⼀直等待发送方的数据,如不采取措施,这种相互等待的过程,会造成了死锁的现象。
TCP 是如何解决窗口关闭时,潜在的死锁现象呢?
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。
如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
- 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;
- 如果接收窗口不是 0,那么死锁的局面就可以被打破了。
窗口探测的次数⼀般为 3 次,每次大约 30-60 秒(不同的实现可能会不⼀样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。
糊涂窗口综合症
如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。
到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
要知道,我们的 TCP + IP 头有 40 个字节,为了传输那几个字节的数据,要达上这么大的开销,这太不经济
了。
就好像⼀个可以承载 50 人的大巴车,每次来了⼀两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。
现举个糊涂窗口综合症的栗子,考虑以下场景:
接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:
- 接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据;
- 在下⼀个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节;
每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。
所以,糊涂窗口综合症的现象是可以发生在发送方和接收方: - 接收方可以通告⼀个小的窗口
- 而发送方可以发送小数据
于是,要解决糊涂窗口综合症,就解决上面两个问题就可以了
- 让接收方不通告小窗口给发送方
- 让发送方避免发送小数据
怎么让接收方不通告小窗口呢?
接收方通常的策略如下:
当窗口大小小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0 ,也就阻止了发送方再发数据过来。
等到接收方处理了⼀些数据后 ,窗口大小 >= MSS,或者接收方缓存空间有⼀半可以使用,就可以把窗口打开让发送方发送数据过来。
怎么让发送方避免发送小数据呢?
发送方通常的策略:
使用Nagle 算法,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:
- 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
- 收到之前发送数据的 ack 回包
只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。
另外,Nagle 算法默认是打开的,如果对于⼀些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
拥塞控制
为什么要有拥塞控制呀,不是有流量控制了吗?
前面的流量控制是避免发送方的数据填满接收方的缓存,但是并不知道网络的中发生了什么。
⼀般来说,计算机网络都处在⼀个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大…
所以,TCP 不能忽略网络上发生的事,它被设计成⼀个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
于是,就有了拥塞控制,控制的目的就是避免发送方的数据填满整个网络。
为了在发送方调节所要发送数据的量,定义了⼀个叫做拥塞窗口的概念。
什么是拥塞窗口?和发送窗口有什么关系呢?
拥塞窗口cwnd是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此
时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 变化的规则:
- 只要网络中没有出现拥塞, cwnd 就会增大;
- 但网络络中出现了拥塞, cwnd 就减少;
那么怎么知道当前网络是否出现了拥塞呢?
其实只要发送方没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。
拥塞控制有哪些控制算法?
拥塞控制主要是四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是⼀点⼀点的提高发送数据包的数量,如果⼀上来就发大量的数据,这不是给网络添堵吗?
慢启动的算法记住⼀个规则就行:当发送方每收到⼀个 ACK,拥塞窗口 cwnd 的大小就会加 1。
这里假定拥塞窗口cwnd 和发送窗口swnd 相等,下面举个栗子:
- 连接建立完成后,一开始初始化 cwnd = 1 ,表示可以传⼀个 MSS大小的数据。
- 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
- 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
- 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1,4 个确认 cwnd 增加 4,于是就可以比之前多发4 个,所以这⼀次能够发送 8 个。
可以看出慢启动算法,发包的个数是指数性的增长。
那慢启动涨到什么时候是个头呢?
有⼀个叫慢启动门限 ssthresh (slow start threshold)状态变量。
- 当 cwnd < ssthresh 时,使用慢启动算法。
- 当 cwnd >= ssthresh 时,就会使用拥塞避免算法。
拥塞避免算法
前面说道,当拥塞窗口cwnd 超过慢启动门限 ssthresh 就会进入拥塞避免算法。
⼀般来说 ssthresh 的大小是 65535 字节。
那么进入拥塞避免算法后,它的规则是:每当收到⼀个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的栗子,现假定 ssthresh 为 8 :
- 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd ⼀共增加 1,于是这⼀次能够发送 9个MSS 大小的数据,变成了线性增长。
所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了⼀些。
就这么⼀直增长着后,网络就会慢慢进⼊了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
当触发了重传机制,也就进入了拥塞发生算法。
拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
- 快速重传
这两种使用的拥塞发送算法是不同的,接下来分别来说说。
发生超时重传的拥塞发生算法:
当发生了超时重传,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
- ssthresh 设为 cwnd/2 ,
- cwnd重置为 1
接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是⼀旦超时重传,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。
就好像本来在秋名山高速漂移着,突然来个紧急刹车,轮胎受得了吗。。。
发生快速重传的拥塞发生算法:
还有更好的方式,前面我们讲过快速重传算法。当接收方发现丢了⼀个中间包的时候,发送三次前⼀个包的
ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了⼀小部分,则 ssthresh 和 cwnd 变化如下:
- cwnd = cwnd/2 ,也就是设置为原来的⼀半;
- ssthresh = cwnd ;
- 进入快速恢复算法
快速恢复
快速重传和快速恢复算法⼀般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。
正如前面所说,进入快速恢复之前, cwnd 和 ssthresh 已被更新了:
- cwnd = cwnd/2 ,也就是设置为原来的⼀半;
- ssthresh = cwnd ;
然后,进入快速恢复算法如下:
- 拥塞窗口cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第⼀步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
也就是没有像超时重传⼀夜回到解放前,而是还在比较高的值,后续呈线性增长。
学完了理论基础后,接下来那就又到了一年一度的抓包分析实战环节了啊哈哈哈哈
TCP 快速建立连接
客户端在向服务端发起 HTTP GET 请求时,一个完整的交互过程,需要 2.5 个 RTT 的时延。
由于第三次握手是可以携带数据的,这时如果在第三次握手发起 HTTP GET 请求,需要 2 个 RTT 的时延。
但是在下⼀次(不是同个 TCP 连接的下⼀次)发起 HTTP GET 请求时,经历的 RTT 也是⼀样,如下图:
在 Linux 3.7 内核版本中,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。
- 在第一次建立连接的时候,服务端在第二次握手产生一个 Cookie (已加密)并通过 SYN、ACK 包一起发
给客户端,于是客户端就会缓存这个 Cookie ,所以第⼀次发起 HTTP Get 请求的时候,还是需要 2 个 RTT的时延; - 在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程,因为
Cookie 中维护了⼀些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就
只需要 1 个 RTT 的时延;
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)
在 Linux 上如何打开 Fast Open 功能?
可以通过设置 net.ipv4.tcp_fastopn 内核参数,来打开 Fast Open 功能。
net.ipv4.tcp_fastopn 各个值的意义:
0 关闭
1 作为客户端使用 Fast Open 功能
2 作为服务端使用 Fast Open 功能
3 无论作为客户端还是服务器,都可以使用Fast Open 功能
TCP Fast Open 抓包分析
在下图,数据包 7 号,客户端发起了第二次 TCP 连接时,SYN 包会携带 Cooike,并且长度为 5 的数据。
服务端收到后,校验 Cooike 合法,于是就回了 SYN、ACK 包,并且确认应答收到了客户端的数据包,ACK = 5 +1 = 6
TCP重复确认和快速重传
当接收方收到乱序数据包时,会发送重复的 ACK,以便告知发送方要重发该数据包,当发送方收到 3 个重复 ACK时,就会触发快速重传,立刻重发丢失数据包。
TCP重复确认和快速重传的⼀个案例,⽤ Wireshark 分析,显示如下:
- 数据包 1 期望的下一个数据包 Seq 是 1,但是数据包 2 发送的 Seq 却是 10945,说明收到的是乱序数据包,于是回了数据包 3 ,还是同样的 Seq = 1,Ack = 1,这表明是重复的 ACK;
- 数据包 4 和 6 依然是乱序的数据包,于是依然回了重复的 ACK;
- 当对方收到三次重复的 ACK 后,于是就快速重传了 Seq = 1 、Len = 1368 的数据包 8;
- 当收到重传的数据包后,发现 Seq = 1 是期望的数据包,于是就发送了个确认收到快速重传的 ACK
以上案例在 TCP 三次握手时协商开启了选择性确认 SACK,因此⼀旦数据包丢失并收到重复 ACK ,即使在丢失数据包之后还成功接收了其他数据包,也只需要重传丢失的数据包。如果不启用 SACK,就必须重传丢失包之后的每个数据包。
如果要支持 SACK ,必须双⽅都要⽀持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能 (Linux2.4 后默认打开)。
TCP 流量控制
TCP 为了防止发送方无脑的发送数据,导致接收方缓冲区被填满,所以就有了滑动窗口的机制,它可利用接收方的接收窗口来控制发送方要发送的数据量,也就是流量控制。
接收窗口是由接收方指定的值,存储在 TCP 头部中,它可以告诉发送方自己的 TCP 缓冲空间区大小,这个缓冲区是给应用程序读取数据的空间:
- 如果应用程序读取了缓冲区的数据,那么缓冲空间区就会把被读取的数据移除
- 如果应用程序没有读取数据,则数据会⼀直滞留在缓冲区。
接收窗口的大小,是在 TCP 三次握手中协商好的,后续数据传输时,接收方发送确认应答 ACK 报文时,会携带当前的接收窗口的大小,以此来告知发送方。
假设接收方接收到数据后,应用层能很快的从缓冲区里读取数据,那么窗口大小会⼀直保持不变,过程如下:
但是现实中服务器会出现繁忙的情况,当应用程序读取速度慢,那么缓存空间会慢慢被占满,于是为了保证发送方发送的数据不会超过缓冲区大小,服务器则会调整窗口大小的值,接着通过 ACK 报文通知给对方,告知现在的接收窗口大小,从而控制发送方发送的数据大小。
零窗口通知与窗口探测
假设接收方处理数据的速度跟不上接收数据的速度,缓存就会被占满,从而导致接收窗口为 0,当发送方接收到零窗口通知时,就会停止发送数据。
如下图,可以看到接收方的窗口大小 在不断的收缩至0:
接着,发送方会定时发送窗口大小探测报文,以便及时知道接收方窗口大小的变化。
以下图 Wireshark 分析图作为例子说明:
- 发送方发送了数据包 1 给接收方 ,接收方收到后,由于缓冲区被占满,回了个零窗口通知;
- 发送方收到零窗口通知后,就不再发送数据了,直到过了 3.4 秒后,发送了一个 TCP Keep-Alive 报文,也就是窗口大小探测报文;
- 当接收方收到窗口探测报文后,就立马回⼀个窗口通知,但是窗口大小还是 0;
- 发送方发现窗口还是 0,于是继续等待了 6.8 (翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;
- 发送方发现窗口还是 0,于是继续等待了 13.5 (翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;
可以发现,这些窗口探测报文以 3.4s、6.5s、13.5s 的间隔出现,说明超时时间会翻倍递增。
这连接暂停了 25s,想象⼀下你在打王者的时候,25s 的延迟你还能上王者吗?
发送窗口的分析
在 Wireshark 看到的 Windows size 也就是 " win = ",这个值表示发送窗口吗?
这不是发送窗口,而是在向对方声明自己的接收窗口。
你可能会好奇,抓包文件里有Window size scaling factor,它其实是算出实际窗口大小的乘法因子,
Window size value实际上并不是真实的窗口大小,真实窗口大小的计算公式如下:
「Window size value」 * 「Window size scaling factor」 = 「Caculated window size 」
对应的下图案例,也就是 32 * 2048 = 65536。
实际上是 Caculated window size 的值是 Wireshark 工具帮我们算好的,Window size scaling factor 和 Windos
size value 的值是在 TCP 头部中,其中 Window size scaling factor 是在三次握手过程中确定的,如果你抓包的数据没有 TCP 三次握手,那可能就无法算出真实的窗口大小的值,如下图:
如何在包里看出发送窗口的大小?
很遗憾,没有简单的办法,发送窗口虽然是由接收窗口决定,但是它又可以被网络因素影响,也就是拥塞窗口,实际上发送窗口是值是 min(拥塞窗口,接收窗口)。
发送窗口和 MSS 有什么关系?
发送窗口决定了一口气能发多少字节,而MSS 决定了这些字节要分多少包才能发完。
举个例子,如果发送窗口为 16000 字节的情况下,如果 MSS 是 1000 字节,那就需要发送 1600/1000 = 16 个
包。
发送方在一个窗口发出 n 个包,是不是需要 n 个 ACK 确认报文?
不一定,因为 TCP 有累计确认机制,所以当收到多个数据包时,只需要应答最后⼀个数据包的 ACK 报文就可以了。
TCP 延迟确认与 Nagle 算法
当我们 TCP 报文的承载的数据非常小的时候,例如几个字节,那么整个网络的效率是很低的,因为每个 TCP 报文中都会有 20 个字节的 TCP 头部,也会有 20 个字节的 IP 头部,而数据只有几个字节,所以在整个报文中有效数据占有的比重就会非常低。
这就好像快递员开着大货车送⼀个小包裹一样浪费。
那么就出现了常见的两种策略,来减少小报文的传输,分别是:
- Nagle 算法
- 延迟确认
Nagle 算法是如何避免大量 TCP 小数据报文的传输?
Nagle 算法做了⼀些策略来避免过多的小数据报文发送,这可提高传输效率。
Nagle 算法的策略:
- 没有已发送未确认报文时,立刻发送数据。
- 存在未确认报文时,直到没有已发送未确认报文或数据长度达到 MSS大小时,再发送数据。
只要没满足上面条件中的⼀条,发送方一直在囤积数据,直到满足上面的发送条件。
- 一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符;
- 接着,在还没收到对 H 字符的确认报文时,发送方就⼀直在囤积数据,直到收到了确认报文后,此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方;
- 待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去
可以看出,Nagle 算法⼀定会有⼀个小报文,也就是在最开始的时候。
另外,Nagle 算法默认是打开的,如果对于⼀些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)。
那延迟确认又是什么?
事实上当没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。
为了解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。
TCP 延迟确认的策略:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
- 当没有响应数据要发送时,ACK 将会延迟⼀段时间,以等待是否有响应数据可以⼀起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
延迟等待的时间是在 Linux 内核中定义的,如下图:
关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不⼀样,在我的 Linux 系统中 HZ大小是 1000 ,如下图:
知道了 HZ 的大小,那么就可以算出: - 最大延迟确认时间是 200 ms (1000/5)
- 最短延迟确认时间是 40 ms (1000/25)
TCP 延迟确认可以在 Socket 设置 TCP_QUICKACK 选项来关闭这个算法。
延迟确认 和 Nagle 算法混合使用时,会产生新的问题
当 TCP 延迟确认 和 Nagle 算法混合使用时,会导致时耗增长,如下图:
发送方使用了 Nagle 算法,接收方使用了 TCP 延迟确认会发生如下的过程:
- 发送方先发出⼀个小报文,接收方收到后,由于延迟确认机制,自己又没有要发送的数据,只能干等着发送
方的下⼀个报文到达; - 而发送方由于 Nagle 算法机制,在未收到第⼀个报文的确认前,是不会发送后续的数据;
- 所以接收方只能等待最大时间 200 ms 后,才回 ACK 报文,发送方收到第⼀个报文的确认报文后,也才可以发送后续的数据。
很明显,这两个同时使用会造成额外的时延,这就会使得网络"很慢"的感觉。
要解决这个问题,只有两个办法:
- 要不发送方关闭 Nagle 算法
- 要不接收方关闭 TCP 延迟确认
抓包分析完之后,又到了最后的性能优化了
TCP传输数据的性能提升
TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区:
- 如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低;
- 如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;
因此,我们必须理解 Linux 下 TCP 内存的用途,才能正确地配置内存大小。
滑动窗口是如何影响传输速度的?
TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文
ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。
所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。
由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过 free 命令观察到
buff/cache 内存是会增大。
如果 TCP 是每发送⼀个数据,都要进行⼀次确认应答。当上⼀个数据包收到了应答了, 再发送下⼀个。这个模式就有点像我和你⾯对⾯聊天,你⼀句我⼀句,但这种⽅式的缺点是效率⽐较低的。
所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。
要解决这一问题不难,并行批量发送报文,再批量确认报文即可。
然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?当然这不现实,我们还得考虑接收方的处理能力。
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,使得网络效率非常低。
为了解决这种现象发生,TCP 提供⼀种机制可以让发送方根据接收方的实际接收能力控制发送的数据量,这就是滑动窗口的由来。
接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。
因此,接收窗⼝并不是恒定不变的 ,接收方会把当前可接收的大小放在 TCP 报文头部中的窗⼝字段,这样就可以起到窗口大小通知的作⽤。
发送方的窗口等价于接收方的窗口吗? 如果不考虑拥塞控制,发送方的窗口大小约等于接收方的窗口大小,因为窗⼝通知报文在网络传输是存在时延的,所以是约等于的关系。
从上图中可以看到,窗口字段只有 2 个字节,因此它最多能表达 65535 字节大小的窗⼝,也就是 64KB 大小。
这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:在 TCP 选项字段定义了窗口扩大因⼦,用于扩大 TCP 通告窗口,其值大小是 2^14, 这样就使 TCP 的窗口大小从 16 位扩大为 30位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB。
Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1 (默认打开):
要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项:
- 主动建立连接的一方在 SYN 报文中发送这个选项;
- 而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。
这样看来,只要进程能及时地调用read 函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。
这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。
如何确定最大传输速度?
在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最⼤化。
问题来了,如何计算网络的传输能力呢?
相信大家都知道网络是有带宽限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:
- 带宽是单位时间内的流量,表达是速度,比如常见的带宽 100 MB/s;
- 缓冲区单位是字节,当网络速度乘以时间才能得到字节数;
这里需要说⼀个概念,就是带宽时延积,它决定网络中飞行报文的大小,它的计算方式:
比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络⼀共可以存放 100MB/s * 0.01s = 1MB 的字节。
这个 1MB 是带宽和时延的乘积,所以它就叫带宽时延积(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示飞行中的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1 MB,就会导致网络过载,容易丢包。
由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了已发送未确认的飞行报文的上限。因此,发送缓冲区不能超过带宽时延积。
发送缓冲区与带宽时延积的关系:
- 如果发送缓冲区超过带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;
- 如果发送缓冲区小于带宽时延积,就不能很好的发挥出网络的传输效率。
所以 ,发送缓冲区的大小最好是往带宽时延积靠近。
怎样调整缓冲区大小?
在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行动态调节。
先来看看发送缓冲区,它的范围通过 tcp_wmem 参数配置;
上面三个数字单位都是字节,它们分别表示:
- 第⼀个数值是动态范围的最小值,4096 byte = 4K;
- 第二个数值是初始默认值,87380 byte ≈ 86K;
- 第三个数值是动态范围的最大值,4194304 byte = 4096K(4M);
发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。
调节接收缓冲区范围:
接收缓冲区的调整就比较复杂⼀些,先来看看设置接收缓冲区范围的 tcp_rmem 参数:
上面三个数字单位都是字节,它们分别表示:
- 第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte =4K;
- 第二个数值是初始默认值,87380 byte ≈ 86K;
- 第三个数值是动态范围的最大值,6291456 byte = 6144K(6M);
接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:
- 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;
- 反之,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;
发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:
调节 TCP 内存范围:
接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的:
上⾯三个数字单位不是字节,而是页面大小,1 ⻚表示 4KB,它们分别表示:
- 当 TCP 内存小于第 1 个值时,不需要进行自动调节;
- 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;
- 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;
⼀般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是
177120,当内存为 (177120 * 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。
根据实际场景调节的策略:
在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。
同时,如果这是网络 IO 型服务器,那么,调大tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而tcp_mem 的单位是页面大小。而且,千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。
数据传输完了,终于到最后一步了。断开TCP连接。终于要结束啦
四、TCP 连接断开
TCP 断开连接是通过四次挥手方式。双方都可以主动断开连接,断开连接后主机中的资源将被释放。
- 客户端打算关闭连接,此时会发送一个 TCP首部 FIN 标志位被置为 1 的报文 ,也即 FIN 报文,之后客
户端进入FIN_WAIT_1 状态。 - 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入CLOSED_WAIT 状态。
- 客户端收到服务端的 ACK 应答报⽂后,之后进入 FIN_WAIT_2 状态。
- 等待服务端处理完数据后,也向客户端发送 FIN 报⽂,之后服务端进⼊ LAST_ACK 状态。
- 客户端收到服务端的 FIN 报文后,回⼀个 ACK 应答报文,之后进⼊ TIME_WAIT 状态
- 服务器收到了 ACK 应答报文后,就进入了 CLOSED 状态,⾄此服务端已经完成连接的关闭。
- 客户端在经过 2MSL ⼀段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭。
你可以看到,每个方向都需要⼀个 FIN 和⼀个 ACK,因此通常被称为四次挥手。
这里⼀点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
为什么挥手需要四次?
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务器收到客户端的 FIN 报文时,先回⼀个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等
服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN ⼀般都会分开发
送,从而比三次握手导致多了⼀次。
为什么 TIME_WAIT 等待的时间是 2MSL?
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:
如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里TCP_TIMEWAIT_LEN 的值,并重新编译 Linux内核。
为什么需要 TIME_WAIT 状态?
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
需要 TIME-WAIT 状态,主要是两个原因:
- 防止具有相同四元组的旧数据包被收到;
- 保证被动关闭连接的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭⽅接收,从而帮助其正常关
闭;
原因一:防止旧连接的数据包
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
- 如上图黄色框框服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
- 这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。
所以,TCP 就设计出了这么⼀个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来
连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
原因二:保证连接正确关闭
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收 ,从而帮助其正常关闭。假设 TIME-WAIT 没有等待时间或时间过短,断开连接会造成什么问题呢?
- 如上图红色框框客户端四次挥手的最后⼀个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-
WAIT 过短或没有,则就直接进入了 CLOSED 状态了,那么服务端则会⼀直处在 LASE_ACK 状态。 - 当客户端发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程就会被
终止。
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
- 服务端正常收到四次挥手的最后⼀个 ACK 报文,则服务端正常关闭连接。
- 服务端没有收到四次挥手的最后⼀个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文。
所以客户端在 TIME-WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。
TIME_WAIT 过多有什么危害?
如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。
过多的 TIME-WAIT 状态主要的危害有两种:
- 第⼀是内存资源占用;
- 第二是对端口资源的占用,⼀个 TCP 连接至少消耗⼀个本地端口 ;
第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,⼀般可以开启的端口为 32768~61000 ,也可以通过如下参数设置指定:
net.ipv4.ip_local_port_range
如果发起连接一⽅的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
客户端受端口资源限制:
- 客户端TIME_WAIT过多,就会导致端口资源被占用,因为端口就65536个,被占满就会导致无法创建新的连接。
服务端受系统资源限制:
- 由于⼀个四元组表示 TCP 连接,理论上服务端可以建立很多连接,服务端确实只监听⼀个端口但是会把连接扔给处理线程,所以理论上监听的端口可以继续监听。但是线程池处理不了那么多⼀直不断的连接了。所以当服务端出现大量TIME_WAIT 时,系统资源被占满时,会导致处理不过来新的连接。
如何优化 TIME_WAIT?
这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:
- 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
- net.ipv4.tcp_max_tw_buckets
- 程序中使用SO_LINGER ,应用强制使用RST 关闭。
方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps
如下的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。
有⼀点需要注意的是,tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用connect()函数时,内核会随机找⼀个 time_wait 状态超过 1 秒的连接给新的连接复用。
net.ipv4.tcp_tw_reuse = 1
使用这个选项,还有⼀个前提,需要打开对 TCP 时间戳的支持,即
net.ipv4.tcp_timestamps=1(默认即为 1)
这个时间戳的字段是在 TCP 头部的选项里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。
由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被⾃然丢弃。
方式二:net.ipv4.tcp_max_tw_buckets
这个值默认为 18000,当系统中处于 TIME_WAIT 的连接⼀旦超过这个值时,系统就会将后⾯的 TIME_WAIT 连接状态重置。
这个⽅法过于暴⼒,⽽且治标不治本,带来的问题远⽐解决的问题多,不推荐使⽤。
方式三:程序中使用SO_LINGER
我们可以通过设置 socket 选项,来设置调用close 关闭连接行为。
如果 l_onoff 为非0, 且 l_linger 值为 0,那么调用close 后,会立该发送⼀个 RST 标志给对端,该 TCP 连接
将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。但这为跨越 TIME_WAIT 状态提供了⼀个可能,不过是⼀个非常危险的行为,不值得提倡。
如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP 有⼀个机制是保活机制。这个机制的原理是这样的:
定义⼀个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔⼀个时间间隔,发送⼀个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默值:
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现⼀个死亡连接。
这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。
如果开启了 TCP 保活,需要考虑以下几种情况:
第⼀种,对端程序是正常⼯作的。当 TCP 保活的探测报⽂发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下⼀个 TCP 保活时间的到来。
第⼆种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生⼀个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
学完了理论接下来看下在编程中的编程方式
Socket编程断开连接
客户端调用 close 了,连接是断开的流程是什么?
我们看看客户端主动调了 close ,会发⽣什么?
- 客户端调用 close ,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入FIN_WAIT_1 状态;
- 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入⼀个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入
CLOSE_WAIT 状态; - 接着,当处理完数据后,自然就会读到 EOF ,于是也调用close 关闭它的套接字,这会使得客户端会发出⼀个 FIN 包,之后处于 LAST_ACK 状态;
- 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进⼊ TIME_WAIT 状态;
- 服务端收到 ACK 确认包后,就进⼊了最后的 CLOSE 状态;
- 客户端经过 2MSL 时间之后,也进入CLOSE 状态;
了解了编程中的实现,接下来也就到了性能的优化了啊
TCP 四次挥手的性能提升
通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。
主动方的优化
关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。
如果进程异常退出了,内核就会发送 RST 报⽂来关闭,它可以不走四次挥手流程,是⼀个暴力关闭连接的方式。
安全关闭连接的方式必须通过四次挥手 ,它由进程调用close 和 shutdown 函数发起 FIN 报文(shutdown 参数须传入SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。
调用close 函数和 shutdown 函数有什么区别?
调用了 close 函数意味着完全断开连接,完全断开不仅指无法传输数据,而且也不能发送数据。 此时,调用了close 函数的一方的连接叫做孤儿连接,如果你用netstat -p 命令,会发现连接对应的进程名为空。
使⽤ close 函数关闭连接是不优雅的。于是,就出现了⼀种优雅关闭连接的 shutdown 函数,它可以控制只关闭⼀个方向的连接:
第二个参数决定断开连接的方式,主要有以下三种方式:
- SHUT_RD(0):关闭连接的读这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
- SHUT_WR(1):关闭连接的写这个方向,这就是常被称为半关闭的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送⼀个 FIN 报文给对端。
- SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各⼀次,关闭套接字的读和写两个方向。
close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。
FIN_WAIT1 状态的优化:
主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。
但是当迟迟收不到对方返回的 ACK 时,连接就会⼀直处于 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。
如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过
tcp_orphan_retries 时,连接就会直接关闭掉。
对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,FIN 报⽂根本⽆法发送出去,这由 TCP 两个特性导致的:
- 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。
- 其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会⼀直处于
FIN_WAIT1 状态。
解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了孤儿连接的最大数量:
当进程调用了 close 函数关闭连接,此时连接就会是孤儿连接,因为它无法再发送和接收数据。Linux 系统
为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。
FIN_WAIT2 状态的优化:
当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送FIN 报文,关闭对文的发送通道。
这时,如果连接是用 shutdown 函数关闭的,连接可以⼀直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,⽽tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒:
它意味着对于孤儿连接(调用close关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。
这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的
TIME_WAIT 状态的优化
为什么不是 4 或者 8 MSL 的时⻓呢?你可以想象⼀个丢包率达到百分之⼀的糟糕⽹络,连续两次丢包的概率只有万分之⼀,这个概率实在是太⼩了,忽略它⽐解决它更具性价⽐。
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_tw_reuse 从协议角度理解是安全可控的,可以复用处于 TIME_WAIT 的端口为新的连接所用。
什么是协议⻆度理解的安全可控呢?主要有两点:
- 只适用于连接发起方,也就是 C/S 模型中的客户端;
- 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复⽤。
使用这个选项,还有⼀个前提,需要打开对 TCP 时间戳的支持(对方也要打开 ):
由于引入了时间戳,它能带来了些好处:
- 我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃;
- 同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃;
开启了 tcp_tw_reuse 功能,如果四次挥手中的最后一次 ACK 在网络中丢失了,会发⽣什么?
上图的流程:
- 四次挥手中的最后⼀次 ACK 在网络中丢失了,服务端⼀直处于 LAST_ACK 状态;
- 客户端由于开启了 tcp_tw_reuse 功能,客户端再次发起新连接的时候,会复用超过 1 秒后的 time_wait 状态的连接。但客户端新发的 SYN 包会被忽略(由于时间戳),因为服务端比较了客户端的上⼀个报文与 SYN报文的时间戳,过期的报⽂就会被服务端丢弃;
- 服务端 FIN 报⽂迟迟没有收到四次挥⼿的最后⼀次 ACK,于是超时重发了 FIN 报⽂给客户端;
- 处于 SYN_SENT 状态的客户端,由于收到了 FIN 报⽂,则会回 RST 给服务端,于是服务端就离开了
LAST_ACK 状态; - 最初的客户端 SYN 报⽂超时重发了( 1 秒钟后),此时就与服务端能正确的三次握⼿了。
所以⼤家都会说开启了 tcp_tw_reuse,可以在复用了 time_wait 状态的 1 秒过后成功建立连接,这 1 秒主要是花费在 SYN 包重传。
被动方的优化
当被动⽅收到 FIN 报⽂时,内核会⾃动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应⽤进程调⽤ close 函数关闭连接。
内核没有权利替代进程去关闭连接,因为如果主动⽅是通过 shutdown 关闭连接,那么它就是想在半关闭连接上接收数据或发送数据。因此,Linux 并没有限制 CLOSE_WAIT 状态的持续时间。
当然,⼤多数应⽤程序并不使⽤ shutdown 函数关闭连接。所以,当你⽤ netstat 命令发现⼤量 CLOSE_WAIT 状态。就需要排查你的应⽤程序,因为可能因为应⽤程序出现了 Bug,read 函数返回 0 时,没有调⽤ close 函数。
处于 CLOSE_WAIT 状态时,调⽤了 close 函数,内核就会发出 FIN 报⽂关闭发送通道,同时连接进⼊ LAST_ACK状态,等待主动⽅返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动⽅重发 FIN 报⽂的优化策略⼀致。
还有⼀点我们需要注意的,如果被动⽅迅速调⽤ close 函数,那么被动⽅的 ACK 和 FIN 有可能在⼀个报⽂中发送,这样看起来,四次挥⼿会变成三次挥⼿,这只是⼀种特殊情况,
如果连接双方同时关闭连接,会怎么样?
由于 TCP 是双全工的协议,所以是会出现两⽅同时关闭连接的现象,也就是同时发送了 FIN 报文。
此时,上面介绍的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进⼊了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。
接下来,双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是⼀种新情况,所以连接会进入⼀种叫做
CLOSING 的新状态,它替代了 FIN_WAIT2 状态。接着,双⽅内核回复 ACK 确认对⽅发送通道的关闭后,进⼊TIME_WAIT 状态,等待 2MSL 的时间后,连接⾃动关闭。
完结。等遇到别的再来补充。
转载:https://blog.csdn.net/weixin_52259848/article/details/127778501