计算机网络学习笔记 ---- TCP三次握手、四次挥手
1 TCP 三次握手与四次挥手面试题
1.1 TCP基础认识
1.1.1 头格式

序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:
· ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
· RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
· SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
· FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
1.1.2 什么是TCP?
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
什么是连接:用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。
建立一个 TCP 连接是需要客户端与服务端达成下面三个信息的共识。
Socket:由 IP 地址和端口号组成
序列号:用来解决乱序问题等
窗口大小:用来做流量控制
TCP四元组可唯一确定一个连接。

源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
服务端通常固定在某个本地端口上监听,等待客户端的连接请求。

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。
服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:
· 文件描述符限制,每个 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。
1.1.3 TCP与UDP区别
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。头部只有 8 个字节(64 位),UDP 的头部格式如下:

· 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
· 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。
· 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计,防止收到在网络传输中受损的 UDP 包
-
连接
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 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:
· 包总量较少的通信,如 DNS 、SNMP 等;
· 视频、音频等多媒体通信;
· 广播通信;
为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?
原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度则是不会变化的,无需多一个字段去记录 UDP 的首部长度。
为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?

其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
至于UDP包长度字段:
第一种说法:因为为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。
如果去掉 UDP 的「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。
第二种说法:如今的 UDP 协议是基于 IP 协议发展的,而当年可能并非如此,依赖的可能是别的不提供自身报文长度或首部长度的网络层协议,因此 UDP 报文首部需要有长度字段以供计算。
1.1.4 TCP 和 UDP 可以使用同一个端口吗?
可以。
在数据链路层中,通过 MAC 地址来寻找局域网中的主机。
在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。
在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突.

1.1.4.1 多个 TCP 服务进程可以绑定同一个端口吗?
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。
如果两个 TCP 服务进程绑定的 IP 地址不同,而端口相同的话,是可以绑定成功的。
注意,如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了。
重启 TCP 服务进程时,为什么会有“Address in use”的报错信息?
当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,(对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。)TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。
可通过对 socket 设置 SO_REUSEADDR 属性

SO_REUSEADDR 作用是:如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。
另一个作用:绑定的 IP地址 + 端口时,只要 IP 地址不是正好(exactly)相同,那么允许绑定。
1.1.4.2 客户端的端口可重复使用吗?

TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。
只要客户端连接的服务器不同,端口资源可以重复使用的。
打开 net.ipv4.tcp_tw_reuse 这个内核参数。可解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题
开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。
开启了 net.ipv4.tcp_tw_reuse 内核参数,是客户端(连接发起方) 在调用 connect() 函数时才起作用,所以在服务端开启这个参数是没有效果的。
1.2 TCP连接建立
1.2.1 三次握手过程

· 首先,服务端主动监听某个端口,处于 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 状态。

1.2.2 为什么是三次握手?
· 三次握手才可以阻止重复历史连接的初始化(主要原因)
· 三次握手才可以同步双方的初始序列号
· 三次握手才可以避免资源浪费
1.2.2.1 避免历史连接
在网络拥堵情况下:
· 一个「旧 SYN 报文」比「最新的 SYN」 报文早到达了服务端,那么此时服务端就会回一个 SYN + ACK 报文给客户端,此报文中的确认号是 91(90+1)。
· 客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。
· 服务端收到 RST 报文后,就会释放连接。
· 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。
TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。
如果服务端在收到 RST 报文之前,先收到了「新 SYN 报文」,就会回 Challenge Ack (opens new window)报文给客户端,这个 ack 报文并不是确认收到「新 SYN 报文」的,而是上一次的 ack 确认号,也就是91(90+1)。所以客户端收到此 ACK 报文时,发现自己期望收到的确认号应该是 101,而不是 91,于是就会回 RST 报文。
为什么 TCP 两次握手为什么无法阻止历史连接呢?
在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费。
1.2.2.2 同步双方初始序列号
TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:
· 接收方可以去除重复的数据;
· 接收方可以根据数据包的序列号按序接收;
· 可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);
1.2.2.3 避免资源浪费
如果只有「两次握手」,当客户端发生的 SYN 报文在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK 报文,所以服务端每收到一个 SYN 就只能先主动建立一个连接。那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
1.2.3 初始化序列号
为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?
主要原因有两个方面:
· 为了防止历史报文被下一个相同四元组的连接接收(主要方面);
· 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;
如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。
如果能正常四次挥手,由于 TIME_WAIT 状态会持续 2 MSL 时长,历史报文会在下一个连接之前就会自然消失。
序列号(SEQ)和初始序列号(ISN)
· 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
· 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
**序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。**即如果序列号回绕的时间极短,会再次面临之前延迟的报文抵达后序列号依然有效的问题。为解决该问题,需要有TCP时间戳
tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项。
它有两个好处:
一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)。
防回绕序列号算法(PAWS)要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。
1.2.4 TCP层为什么需要MSS?

· MTU:一个网络包的最大长度,以太网中一般为 1500 字节;
· MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,
当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
1.2.4 三次握手丢失,分别发生什么?
1.2.4.1 第一次握手丢失
首先客户端第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。
不同版本的操作系统可能超时时间不同,由内核决定。在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries内核参数控制,这个参数是可以自定义的,默认值一般是 5。

通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。
当超过 tcp_syn_retries 参数的值,会等待一段时间,若还未收到第二次握手,则断开连接
1.2.4.2 第二次握手丢失
当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD 状态。
第二次握手的 SYN-ACK 报文其实有两个目的 :
第二次握手里的 ACK, 是对第一次握手的确认报文;
第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文;
如果客户端迟迟没有收到第二次握手,就会触发超时重传机制,重传 SYN 报文。服务端这边会触发超时重传机制,重传 SYN-ACK 报文。
因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。如果第二次握手丢失了,服务端就收不到第三次握手。
在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定,默认值是 5。

当第二次握手丢失了,客户端和服务端都会重传:
· 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries内核参数决定;
· 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定。
1.2.4.3 第三次握手丢失
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文。
1.2.4.4 SYN攻击
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
· 半连接队列,也称 SYN 队列;
· 全连接队列,也称 accept 队列;

正常流程:
· 当服务端接收到客户端的 SYN 报文时,会创建一个半连接的对象,然后将其加入到内核的「 SYN 队列」;
· 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
· 服务端接收到 ACK 报文后,从「 SYN 队列」取出一个半连接对象,然后创建一个新的连接对象放入到「 Accept 队列」;
· 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接对象。
SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
避免 SYN 攻击方式,可以有以下四种方法:
· 调大 netdev_max_backlog;
· 增大 TCP 半连接队列;
· 开启 tcp_syncookies;
· 减少 SYN+ACK 重传次数
方式一:调大 netdev_max_backlog
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000:

方式二:增大 TCP 半连接队列
同时增大下面这三个参数:
增大 net.ipv4.tcp_max_syn_backlog
增大 listen() 函数中的 backlog
增大 net.core.somaxconn
方式三:开启 net.ipv4.tcp_syncookies
开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。

具体过程:
· 当 「 SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个 cookie 值;
· 将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端;
· 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「 Accept 队列」。
· 最后应用程序通过调用 accpet() 接口,从「 Accept 队列」取出的连接。
net.ipv4.tcp_syncookies 参数主要有以下三个值:
0 值,表示关闭该功能;
1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
2 值,表示无条件开启功能;

方式四:减少 SYN+ACK 重传次数
通过减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
SYN-ACK 报文的最大重传次数由 tcp_synack_retries内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次:

1.3 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,因此通常被称为四次挥手。主动关闭连接的,才有 TIME_WAIT 状态。
服务器收到客户端的 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 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完整的 TCP 四次挥手,所以我们常说,调用 close 是粗暴的关闭。

当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:
· 如果是读操作,则会返回 RST 的报错,也就是我们常见的Connection reset by peer。
· 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。
shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以我们常说,调用 shutdown 是优雅的关闭。

shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核是不会发送 FIN 报文的,因为发送 FIN 报文是意味着我方将不再发送任何数据,而 shutdown 如果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN。
1.3.1 三次挥手
当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。
** TCP 延迟确认机制**
当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。
TCP 延迟确认的策略:
· 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
· 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
· 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK

如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里启用 TCP_QUICKACK。

1.3.2 第一次挥手丢失(客户端重传FIN报文)
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。
如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close 状态。
1.3.3 第二次挥手丢失(客户端重传FIN报文)
当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT 状态。
如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数,然后断开连接。
当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。对于 close 函数关闭的连接,由于无法再发送和接收数据,所以FIN_WAIT2 状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。
如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭

如果主动关闭方使用 shutdown 函数关闭连接,指定了只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。即,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 FIN_WAIT2 状态,死等(tcp_fin_timeout 无法控制 shutdown 关闭的连接)

1.3.4 第三次挥手丢失(服务端重发Fin报文)
当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。
此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。
服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。
1.3.5 第四次挥手丢失(服务端重发FIN报文)
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT 状态。服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。
如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
1.3.6 TIME_WAIT状态
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 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
为什么需要该状态?
需要 TIME-WAIT 状态,主要是两个原因:
原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收
状态会持续 2MSL 时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
原因二:保证「被动关闭连接」的一方,能被正确的关闭
TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
TIME_WAIT过多的危害
过多的 TIME-WAIT 状态主要的危害有两种:
第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。
如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。
如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。
优化 TIME_WAIT
优化方式:
· 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
· net.ipv4.tcp_max_tw_buckets
· 程序中使用 SO_LINGER ,应用强制使用 RST 关闭。
1.3.6.1 服务器出现大量 TIME_WAIT 状态的原因有哪些?
什么场景下服务端会主动断开连接呢?
第一个场景:HTTP 没有使用长连接
从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive
如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息
也就是说,只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制。
根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
第二个场景:HTTP 长连接超时
为了避免客户端完成一个 HTTP 请求后就不再发起新的请求导致资源浪费的情况,
web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。
第三个场景:HTTP 长连接的请求数量达到上限
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
1.3.6.2 服务器出现大量 CLOSE_WAIT 状态的原因有哪些?
CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。
当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接。
当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close。
1.4 Socket编程
1.4.1 针对 TCP 应该如何 Socket 编程?

· 服务端和客户端初始化 socket,得到文件描述符;
· 服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口;
· 服务端调用 listen,进行监听;
· 服务端调用 accept,等待客户端连接;
· 客户端调用 connect,向服务端的地址和端口发起连接请求;
· 服务端 accept 返回用于传输的 socket 的文件描述符;
· 客户端调用 write 写入数据;服务端调用 read 读取数据;
· 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
1.4.2 listen 时候参数 backlog 的意义?
Linux内核中会维护两个队列:
· 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
· 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;


在早期 Linux 内核 backlog 是 SYN 队列大小(未完成的队列大小)。在 Linux 内核 2.2 之后,backlog 变成 accept 队列大小(已完成连接建立的队列长度),所以现在通常认为 backlog 是 accept 队列。但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
1.4.3 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;
· ACK 应答包到达服务端后,服务端的 TCP 连接进入 ESTABLISHED 状态,同时服务端协议栈使得 accept 阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功。
客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
1.4.4 没有 accept,能建立 TCP 连接吗?
accpet 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,用户层通过 accpet 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了。

建立连接的过程中根本不需要accept()参与, 执行accept()只是为了从全连接队列里取出一条连接。
· 半连接队列(SYN队列),服务端收到第一次握手后,会将sock加入到这个队列中,队列内的sock都处于SYN_RECV 状态。
· 全连接队列(ACCEPT队列),在服务端收到第三次握手后,会将半连接队列的sock取出,放到全连接队列中。队列里的sock都处于 ESTABLISHED状态。这里面的连接,就等着服务端执行accept()后被取出了。
全连接队列(icsk_accept_queue)是个链表,而半连接队列(syn_table)是个哈希表。

全连接队列满了会怎么样?
如果队列满了,服务端还收到客户端的第三次握手ACK,默认当然会丢弃这个ACK。
还有一些附带行为,这会受 tcp_abort_on_overflow 参数的影响。
· tcp_abort_on_overflow设置为 0,全连接队列满了之后,会丢弃这个第三次握手ACK包,并且开启定时器,重传第二次握手的SYN+ACK,如果重传超过一定限制次数,还会把对应的半连接队列里的连接给删掉。
· tcp_abort_on_overflow设置为 1,全连接队列满了之后,就直接发RST给客户端,效果上看就是连接断了。
半连接队列要是满了会怎么样?
一般是丢弃,通过 tcp_syncookies 参数去控制
SYN Flood攻击:攻击方模拟客户端疯狂发第一次握手请求过来,而不发送第三次握手,从而把服务端半连接队列打满,从而导致正常连接不能正常进来。
通过tcp_syncookies参数,该参数为1时
客户端发来第一次握手SYN时,服务端不会将其放入半连接队列中,而是直接生成一个cookies,这个cookies会跟着第二次握手,发回客户端。
客户端在发第三次握手的时候带上这个cookies,服务端验证到它就是当初发出去的那个,就会建立连接并放入到全连接队列中。
但可能收到ACK攻击 (攻击者构造大量的第三次握手包(ACK包),同时带上各种瞎编的cookies信息)
ACK攻击:通过构造大量ACK包去消耗服务端资源的攻击,受到攻击的服务器可能会因为CPU资源耗尽导致没能响应正经请求。
1.4.5 没有 listen,能建立 TCP 连接吗?
客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能 TCP 建立连接。
服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。
更多推荐

所有评论(0)