TCP常见问题

在 Linux 系统中查看 TCP 状态

在 Linux 可以通过netstat -napt命令查看 TCP 的连接状态。

有⼀个 IP 的服务器监听了⼀个端口,它的 TCP 的最⼤连接数是多少?

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,客户端 IP 和端口是可变的,其理论值计算公式如下:

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最⼤ TCP 连接数,约为 2 的 48 次方。

当然,服务端最⼤并发 TCP 连接数远不能达到理论上限。

  • 首先主要是⽂件描述符限制,Socket 都是⽂件,所以首先要通过ulimit配置⽂件描述符的数⽬;
  • 另⼀个是内存限制,每个 TCP 连接都要占用⼀定内存,操作系统的内存是有限的。

TCP 和 UDP 区别

UDP TCP
是否连接 无连接 面向连接
是否可靠 不可靠传输,不使用流量控制和拥塞控制 可靠传输,使用流量控制和拥塞控制
连接对象个数 支持一对一,一对多,多对一和多对多交互通信 只能是一对一通信
传输方式 面向报文 面向字节流
首部开销 首部开销小,仅 8 字节 首部最小 20 字节,最大 60 字节
适用场景 适用于实时应用(IP 电话、视频会议、直播等) 适用于要求可靠传输的应用,例如文件传输

为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?

先说说 TCP 是如何计算负载数据长度:

其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。

⼤家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”

这么⼀问,确实感觉 UDP 「包长度」是冗余的。

因为为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。

如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。

为什么客户端和服务端的初始序列号 ISN 是不相同的?

如果⼀个已经失效的连接被重用了,但是该旧连接的历史报⽂还残留在网络中,如果序列号相同,那么就⽆法分辨出该报⽂是不是历史报⽂,如果历史报⽂被新的连接接收了,则会产生数据错乱。

所以,每次建立连接前重新初始化⼀个序列号主要是为了通信双方能够根据序号将不属于本连接的报⽂段丢弃。

另⼀方⾯是为了安全性,防止⿊客伪造的相同序列号的 TCP 报⽂被对方接收。

既然 IP 层会分片,为什么 TCP 层还需要 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 攻击?

我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。

避免 SYN 攻击方式一

其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。

当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:

1
net.core.netdev_max_backlog

SYN_RCVD 状态连接的最大个数:

1
net.ipv4.tcp_max_syn_backlog

超出处理能时,对新的 SYN 直接回报 RST,丢弃连接:

1
net.ipv4.tcp_abort_on_overflow

避免 SYN 攻击方式二

我们先来看下 Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的?

正常流程:

  • 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的 SYN 队列;
  • 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
  • 服务端接收到 ACK 报文后,从 SYN 队列移除放入到Accept队列;
  • 应用通过调用accpet() socket 接口,从Accept队列取出连接。

应用程序过慢:

  • 如果应用程序过慢时,就会导致Accept队列被占满。

受到 SYN 攻击:

  • 如果不断受到 SYN 攻击,就会导致 SYN 队列被占满。

tcp_syncookies的方式可以应对 SYN 攻击的方法:

1
net.ipv4.tcp_syncookies = 1
  • 当 SYN 队列满之后,后续服务器收到SYN包,不进入SYN队列;
  • 计算出一个 cookie 值,再以SYN + ACK中的序列号返回客户端,
  • 服务端接收到客户端的应答报文时,服务器会检查这个ACK包的合法性。如果合法,直接放入到Accept队列。
  • 最后应用通过调用accpet() socket 接口,从Accept队列取出的连接。

TIME_WAIT 过多有什么危害?

如果服务器有处于TIME-WAIT状态的 TCP,则说明是由服务器方主动发起的断开请求。

过多的TIME-WAIT状态主要的危害有两种:

  • 第一是内存资源占用;
  • 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口;

第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定

1
net.ipv4.ip_local_port_range

如果发起连接一方的TIME_WAIT状态过多,占满了所有端口资源,则会导致无法创建新连接。

客户端受端口资源限制:

客户端TIME_WAIT过多,就会导致端口资源被占用,因为端口就65536个,被占满就会导致无法创建新的连接。

服务端受系统资源限制:

由于一个四元组表示 TCP 连接,理论上服务端可以建立很多连接,服务端确实只监听一个端口 但是会把连接扔给处理线程,所以理论上监听的端口可以继续监听。但是线程池处理不了那么多一直不断的连接了。所以当服务端出现大量TIME_WAIT时,系统资源被占满时,会导致处理不过来新的连接。

如何优化 TIME_WAIT?

这里给出优化TIME-WAIT的几个方式,都是有利有弊:

  • 打开net.ipv4.tcptwreusenet.ipv4.tcp_timestamps选项;
  • net.ipv4.tcpmaxtw_buckets
  • 程序中使用SO_LINGER,应用强制使用 RST 关闭。

方式一:net.ipv4.tcptwreusetcp_timestamps

如下的 Linux 内核参数开启后,则可以复用处于TIME_WAITsocket为新的连接所用。

有一点需要注意的是,tcptwreuse功能只能用客户端(连接发起方),因为开启了该功能,在调用connect()函数时,内核会随机找一个time_wait状态超过 1 秒的连接给新的连接复用。

1
net.ipv4.tcp_tw_reuse = 1

使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即

1
net.ipv4.tcp_timestamps=1(默认即为 1)

这个时间戳的字段是在 TCP 头部的「选项」里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。

由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

方式二:net.ipv4.tcpmaxtw_buckets

这个值默认为 18000,当系统中处于TIMEWAIT的连接一旦超过这个值时,系统就会将后面的TIMEWAIT连接状态重置。

这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。

方式三:程序中使用 SO_LINGER

我们可以通过设置socket选项,来设置调用close关闭连接行为。

1
2
3
4
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));

如果l_onoff为非 0,且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。

但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

如果已经建立了连接,但是客户端突然出现故障了怎么办?

TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

1
2
3
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
  • tcpkeepalivetime=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcpkeepaliveintvl=75:表示每次检测间隔 75 秒;
  • tcpkeepaliveprobes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说在 Linux 系统中,最少需要经过2小时11分15秒才可以发现一个死亡连接。

这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。

如果开启了 TCP 保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。

第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。

第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 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

成功连接建立之后,双方开始通过readwrite函数来读写数据,就像往一个文件流里面写东西一样。

listen 时候参数 backlog 的意义?

Linux内核中会维护两个队列:

  • 未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
  • 已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
1
int listen (int socketfd, int backlog)
  • 参数一socketfdsocketfd文件描述符
  • 参数二backlog,这参数在历史版本有一定的变化

在早期 Linux 内核backlog是 SYN 队列大小,也就是未完成的队列大小。

在 Linux 内核 2.2 之后,backlog变成accept队列,也就是已完成连接建立的队列长度,所以现在通常认为backlogaccept队列。

但是上限值是内核参数somaxconn的大小,也就说accpet队列长度 = min(backlog, somaxconn)

accept 发生在三次握手的哪一步?

我们先看看客户端连接服务端时,发送了什么?

  • 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号client_isn,客户端进入 SYNSENT 状态;
  • 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为client_isn+1,表示对 SYN 包client_isn的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为server_isn,服务器端进入 SYNRCVD 状态;
    客户端协议栈收到 ACK 之后,使得应用程序从connect调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 server_isn+1
  • 应答包到达服务器端后,服务器端协议栈使得accept阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。

从上面的描述过程,我们可以得知客户端connect成功返回是在第二次握手,服务端accept成功返回是在三次握手成功之后。

客户端调用 close 了,断开的流程是什么?

我们看看客户端主动调用了 close,会发生什么?

  • 客户端调用close,表明客户端没有数据需要发送了,则此时会向服务端发送FIN报文,进入FINWAIT1状态;
  • 服务端接收到了FIN报文,TCP 协议栈会为FIN包插入一个文件结束符EOF到接收缓冲区中,应用程序可以通过read调用来感知这个FIN包。这个EOF会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为EOF表示在该连接上再无额外数据到达。此时,服务端进入CLOSE_WAIT状态;
  • 接着,当处理完数据后,自然就会读到EOF,于是也调用close关闭它的套接字,这会使得客户端会发出一个FIN包,之后处于LAST_ACK状态;
  • 客户端接收到服务端的FIN包,并发送ACK确认包给服务端,此时客户端将进入TIME_WAIT状态;
  • 服务端收到ACK确认包后,就进入了最后的CLOSE状态;
  • 客户端经过 2MSL 时间之后,也进入CLOSE状态。

TCP 四次挥手,可以变成三次吗?

TCP 四次挥手中,能不能把第二次的 ACK 报文, 放到第三次FIN报文一起发送?

虽然我们在学习 TCP 挥手时,学到的是需要四次来完成 TCP 挥手,但是在一些情况下,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,因此通常被称为四次挥手。

为什么 TCP 挥手需要四次呢?

服务器收到客户端的FIN报文时,内核会马上回一个ACK应答报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送FIN报文,而是将发送FIN报文的控制权交给服务端应用程序:

  • 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
  • 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,

从上面过程可知,是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送FIN报文了,所以服务端的ACKFIN一般都会分开发送。

FIN报文一定得调用关闭连接的函数,才会发送吗?

不一定。

如果进程退出了,不管是不是正常退出,还是异常退出(如进程崩溃),内核都会发送FIN报文,与对方完成四次挥手。

粗暴关闭 vs 优雅关闭

其实关闭的连接的函数有两种函数:

  • close函数,同时socket关闭发送方向和读取方向,也就是socket不再有发送和接收数据的能力。如果有多进程/多线程共享同一个socket,如果有一个进程调用了close关闭只是让socket引用计数 -1,并不会导致socket不可用,同时也不会发出FIN报文,其他进程还是可以正常读写该socket,直到引用计数变为 0,才会发出 FIN 报文。
  • shutdown函数,可以指定socket只关闭发送方向而不关闭读取方向,也就是socket不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个socketshutdown则不管引用计数,直接使得该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

什么情况会出现三次挥手?

当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。

然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见三次挥手的次数比四次挥手还多。

TCP 延迟确认机制

当发送没有携带数据的ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决ACK传输效率低问题,所以就衍生出了 TCP 延迟确认。 TCP 延迟确认的策略:

  • 当有响应数据要发送时,ACK会随着响应数据一起立刻发送给对方
  • 当没有响应数据要发送时,ACK将会延迟一段时间,以等待是否有响应数据可以一起发送
  • 如果在延迟等待发送ACK期间,对方的第二个数据报文又到达了,这时就会立刻发送ACK

延迟等待的时间是在 Linux 内核中定义的:

1
2
#define TCP_DELACK_MAX((unsigned)(HZ/5)) # 最大延迟确认时间
#define TCP_DELACK_MN((unsigned)(HZ/25)) # 最小延迟确认时间

关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的 Linux 系统中 HZ 大小是 250,如下图:

1
2
3
cat /boot/config-5.15.0-69-generic | grep '^CONFIG_HZ='

CONFIG_HZ=250

知道了 HZ 的大小,那么就可以算出:

  • 最大延迟确认时间是 50 ms (250/5)
  • 最短延迟确认时间是 10 ms (250/25)

怎么关闭 TCP 延迟确认机制?

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

1
2
3
// 1 表示开启 TCP_QUICKACK,即关闭 TCP 延迟确认机制
int value = 1;
setsockopt(socketfd, IPPROTO_TCP, TCP_QUICKACK, (char*)& value, sizeof(int));

拔掉网线后, 原本的 TCP 连接还存在吗?

TCP 连接在 Linux 内核中是一个名为struct socket的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。

拔掉网线这个动作并不会影响 TCP 连接的状态。

接下来,要看拔掉网线后,双方做了什么动作。

所以, 针对这个问题,要分场景来讨论:

  • 拔掉网线后,有数据传输;
  • 拔掉网线后,没有数据传输;

拔掉网线后,有数据传输

在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一定时长后,服务端就会触发超时重传机制,重传未得到响应的数据报文。

如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于ESTABLISHED状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。

此时,客户端和服务端的 TCP 连接依然存在的,就感觉什么事情都没有发生。

但是,如果如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。

而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。

此时,客户端和服务端的 TCP 连接都已经断开了。

那 TCP 的数据报文具体重传几次呢?

在 Linux 系统中,提供了一个叫tcp_retries2配置项,默认值是 15,如下图:

1
2
[root@localhost ~]# cat /proc/sys/net/ipv4/tcp_retries2
15

这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。

不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核还会基于「最大超时时间」来判定。

每一轮的超时时间都是倍数增长的,比如第一次触发超时重传是在 2s 后,第二次则是在 4s 后,第三次则是 8s 后,以此类推。

内核会根据 tcp_retries2 设置的值,计算出一个最大超时时间。

在重传报文且一直没有收到对方响应的情况时,先达到「最大重传次数」或者「最大超时时间」这两个的其中一个条件后,就会停止重传,然后就会断开 TCP 连接。

拔掉网线后,没有数据传输

针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制 (TCP 保活机制)。

如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。

而如果开启了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:

如果对端是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。

TCP keepalive 机制具体是怎么样的?

这个机制的原理是这样的:

定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

1
2
3
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
  • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
  • tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。

注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。

TCP keepalive 机制探测的时间也太长了吧?

对的,是有点长。

TCP keepalive 是 TCP 层(内核态) 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。

实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。

比如,web 服务软件一般都会提供keepalive_timeout参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。

总结

客户端拔掉网线后,并不会直接影响 TCP 连接状态。所以,拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。

有数据传输的情况:

  • 在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。
  • 在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。

没有数据传输的情况:

  • 如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。
  • 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。

除了客户端拔掉网线的场景,还有客户端「宕机和杀死进程」的两种场景。

第一个场景,客户端宕机这件事跟拔掉网线是一样无法被服务端的感知的,所以如果在没有数据传输,并且没有开启 TCP keepalive 机制时,,服务端的 TCP 连接将会一直处于ESTABLISHED连接状态,直到服务端重启进程。

所以,我们可以得知一个点。在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在ESTABLISHED状态时,并不代表另一方的 TCP 连接还一定是正常的。

第二个场景,杀死客户端的进程后,客户端的内核就会向服务端发送FIN报文,与客户端进行四次挥手。

所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。

服务端挂了,客户端的 TCP 连接还在吗?

如果「服务端挂掉」指的是「服务端进程崩溃」,那么服务端的进程在发生崩溃的时候,内核会发送 FIN 报文,与客户端进行四次挥手。

但是,如果「服务端挂掉」指的是「服务端主机宕机」,那么是不会发生四次挥手的,具体后续会发生什么?还要看客户端会不会发送数据?

  • 如果客户端会发送数据,由于服务端已经不存在,客户端的数据报文会超时重传,当重传次数达到一定阈值后,会断开 TCP 连接;
  • 如果客户端一直不会发送数据,再看客户端有没有开启 TCP keepalive 机制?
  • 如果有开启,客户端在一段时间后,检测到服务端的 TCP 连接已经不存在,则会断开自身的 TCP 连接;
  • 如果没有开启,客户端的 TCP 连接会一直存在,并不会断开。

服务端进程崩溃,客户端会发生什么?

TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手FIN报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。

使用kill -9命令来模拟进程崩溃的情况,发现在kill掉进程后,服务端会发送FIN报文,与客户端进行四次挥手。

服务端主机宕机后,客户端会发生什么?

当服务端的主机突然断电了,这种情况就是属于服务端主机宕机了。

当服务端的主机发生了宕机,是没办法和客户端进行四次挥手的,所以在服务端主机发生宕机的那一时刻,客户端是没办法立刻感知到服务端主机宕机了,只能在后续的数据交互中来感知服务端的连接已经不存在了。

因此,我们要分三种情况来讨论:

  • 服务端主机宕机后,客户端会发送数据;
  • 服务端主机宕机后,客户端一直不会发送数据;
  • 服务端主机宕机后,然后马上重启了服务端,重启完成后,如果这时客户端发送了数据

服务端主机宕机后,如果客户端会发送数据

在服务端主机宕机后,客户端发送了数据报文,由于得不到响应,在等待一定时长后,客户端就会触发超时重传机制,重传未得到响应的数据报文。

当重传次数达到达到一定阈值后,内核就会判定出该 TCP 连接有问题,然后通过Socket接口告诉应用程序该 TCP 连接出问题了,于是客户端的 TCP 连接就会断开。

那 TCP 的数据报文具体重传几次呢?

在 Linux 系统中,提供了一个叫tcp_retries2配置项,默认值是 15:

1
2
[root@localhost ~]# cat /proc/sys/net/ipv4/tcp_retries2
15

这个内核参数是控制在 TCP 连接建立的情况下,超时重传的最大次数。

不过tcp_retries2设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核会根据tcp_retries2设置的值,计算出一个timeout(如果tcp_retries2=15,那么计算得到的timeout = 924600 ms),如果重传间隔超过这个timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接。

在发生超时重传的过程中,每一轮的超时时间(RTO)都是倍数增长的,比如如果第一轮 RTO 是 200ms,那么第二轮 RTO 是 400ms,第三轮 RTO 是 800ms,以此类推。

而 RTO 是基于 RTT(一个包的往返时间) 来计算的,如果 RTT 较大,那么计算出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的timeout值了。

举个例子,如果tcp_retries2=15,那么计算得到的timeout = 924600 ms,如果重传总间隔时长达到了timeout就会停止重传,然后就会断开 TCP 连接:

  • 如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200ms,由于timeout总时长是 924600ms,表现出来的现象刚好就是重传了 15 次,超过了timeout值,从而断开 TCP 连接
  • 如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000ms,也就是第一轮的超时时间是 1 秒,那么根本不需要重传 15 次,重传总间隔就会超过 924600ms。

最小 RTO 和最大 RTO 是在 Linux 内核中定义好了:

1
2
#define TCP_RTO_MAX ((unsigned)(120*HZ))
#define TCP_RTO_MIN ((unsigned)(HZ/5))

Linux 2.6+ 使用 1000 毫秒的 HZ,因此TCP_RTO_MIN约为 200 毫秒,TCP_RTO_MAX约为 120 秒。

如果tcp_retries设置为 15,且 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,这意味着它需要 924.6 秒才能将断开的 TCP 连接通知给上层(即应用程序),每一轮的 RTO 增长关系如下表格:

服务端主机宕机后,如果客户端一直不发数据

在服务端主机发送宕机后,如果客户端一直不发送数据,那么还得看是否开启了TCP keepalive机制(TCP 保活机制)。

如果没有开启TCP keepalive机制,在服务端主机发送宕机后,如果客户端一直不发送数据,那么客户端的 TCP 连接将一直保持存在,所以我们可以得知一个点,在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在ESTABLISHED状态时,并不代表另一方的 TCP 连接还一定是正常的。

而如果开启了TCP keepalive机制,在服务端主机发送宕机后,即使客户端一直不发送数据,在持续一段时间后,TCP 就会发送探测报文,探测服务端是否存活:

  • 如果对端是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
  • 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。

所以,TCP keepalive机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。

TCP keepalive机制机制的原理是这样的:

定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

1
2
3
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9

每个参数的意思,具体如下:
tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。

注意,应用程序如果想使用 TCP 保活机制,需要通过socket接口设置SO_KEEPALIVE选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。

TCP keepalive机制探测的时间是有点长。

TCP keepalive是 TCP 层(内核态) 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。

实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。

比如,web 服务软件一般都会提供keepalive_timeout参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。

服务端主机宕机后,然后马上重启了服务端,客户端发送了数据

如果服务端主机宕机后,然后马上重启了服务端,重启完成后,如果这时客户端发送了数据,由于服务端之前的连接信息已经不存在,所以会回RST报文给客户端,客户端收到RST报文后,就断开连接。

总结

如果「服务端挂掉」指的是「服务端进程崩溃」,服务端的进程在发生崩溃的时候,内核会发送FIN报文,与客户端进行四次挥手。

但是,如果「服务端挂掉」指的是「服务端主机宕机」,那么是不会发生四次挥手的,具体后续会发生什么?还要看客户端会不会发送数据?

  • 如果客户端会发送数据,由于服务端已经不存在,客户端的数据报文会超时重传,当重传总间隔时长达到一定阈值(内核会根据tcp_retries2设置的值计算出一个阈值)后,会断开 TCP 连接;
  • 如果客户端一直不会发送数据,再看客户端有没有开启TCP keepalive机制?
  • 如果有开启,客户端在一段时间没有进行数据交互时,会触发TCP keepalive机制,探测对方是否存在,如果探测到对方已经消亡,则会断开自身的 TCP 连接;
  • 如果没有开启,客户端的 TCP 连接会一直存在,并且一直保持在ESTABLISHED状态。
打赏
  • Copyrights © 2017-2023 WSQ
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信