Skip to content

1. TCP常见问题

1.1 TCP 半连接队列和全连接队列

在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:

  • 半连接队列,也称 SYN 队列;
  • 全连接队列,也称 accept 队列;

服务端收到客户端发起的 SYN 请求之后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK报文,接着客户端就会返回ACK,服务端收到第三次握手的ACK后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来

1.1.1  TCP 全连接队列

1.1.1.1 查看状态

在服务端可以使用 ss 命令,来查看 TCP 全连接队列的情况。 ss 命令获取的 Recv-Q/Send-Q 在「LISTEN 状态」和「非 LISTEN 状态」所表达的含义是不同的:

  • LISTEN 状态
  • Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
  • Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128;
  • 非LISTEN 状态
  • Recv-Q:已收到但未被应用进程读取的字节数;
  • Send-Q:已发送但未收到确认的字节数;

1.1.1.2 溢出时的处理

当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s | grep overflow 命令来查看。

tcp_abort_on_overflow 可以控制accept 队列溢出之后的行为:

  • 0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;并且开启定时器,重传第二次握手的 SYN+ACK,如果重传超过一定限制次数,还会把对应的半连接队列里的连接给删掉。
  • 1 :如果全连接队列满了,server 发送一个 reset 包(RST 报文)给 client,表示废掉这个握手过程和这个连接;

通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。

举个例子,当 TCP 全连接队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。

所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。

1.1.1.3 增大 TCP 全连接队列

TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)

  • somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其值;
  • backloglisten(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;

1.1.2 TCP 半连接队列

1.1.2.1 查看状态

TCP 半连接队列长度的长度,没有像全连接队列那样可以用 ss 命令查看。

但可以统计服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列。netstat -antp | grep SYN_RECV

上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。

1.1.2.2 溢出情况

  • 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;
  • 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;
  • 如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2=max_syn_backlog/4),即当前半连接队列长度大于max_syn_backlog*3/4时,则会丢弃;

开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,详见上文 SYN 攻击。

1.1.2.3 半连接队列最大值

半连接队列最大值不是单单由 max_syn_backlog 决定,还跟 somaxconn 和 backlog 有关系

在 Linux 2.6.32 内核版本,它们之间的关系,总体可以概况为:

  • 当 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;

但这只是计算了理论值,实际上服务端处于 SYN_RECV 状态的最大个数分为如下两种情况:

  • 如果「当前半连接队列」没超过「理论半连接队列最大值」,但是超过 max_syn_backlog * 3/4 ,那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog * 3/4
  • 如果「当前半连接队列」超过「理论半连接队列最大值」,那么处于 SYN_RECV 状态的最大个数就是「理论半连接队列最大值」;

不同内核版本的计算是不同的。比如在 Linux 5.0.0 的时候,「理论」半连接最大值就是全连接队列最大值,但依然还是有队列溢出的三个条件判断。

1.2 TCP 优化

1.2.1 TCP 三次握手的性能提升

三次握手的过程在一个 HTTP 请求的平均时间占比 10% 以上。

1.2.1.1 客户端优化

因为每次重传等待的时间间隔都是上次的两倍,所以可以减少 SYN 包重传次数tcp_syn_retries

可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

1.2.1.2 服务端优化

如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。

当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数。

1.2.1.3 如何绕过三次握手

TCP Fast Open。

1.2.2 TCP 四次挥手的性能提升

1.2.2.1 主动方的优化

关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。

如果进程收到 RST 报文,就直接关闭连接了,不需要走四次挥手流程,是一个暴力关闭连接的方式。

安全关闭连接的方式必须通过四次挥手,它由进程调用 closeshutdown 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。

1.2.2.1.1 close 和 shutdown 函数的区别

调用了 close 函数意味着完全断开连接,完全断开不仅指无法接收数据,而且也不能发送数据。 此时,调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p 命令,会发现连接对应的进程名为空。这种方式不优雅。

shutdown函数用于部分关闭TCP连接,可以选择关闭读取方向、写入方向或同时关闭两个方向。

int shutdown(int sock, int howto);
第二个参数决定断开连接的方式,主要有以下三种方式:

  • SHUT_RD(0):关闭连接的「读」这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
  • SHUT_WR(1):关闭连接的「写」这个方向,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。
  • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。
1.2.2.1.2 FIN_WAIT1 状态的优化

当主动方迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。实际上当为 0 时,特指 8 次。

对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的:

  • 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。
  • 其次,TCP 有流量控制功能,当接收窗口为 0 时,发送方就不能再发送数据,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。例:
  • 如果接收方的处理能力不足,例如接收方的CPU或磁盘IO等资源受到限制,可能会导致接收方无法及时处理接收到的数据,从而导致接收窗口变为0。
  • 攻击者使用窗口攻击工具:攻击者可能会使用窗口攻击工具,通过向发送方发送TCP ACK报文来欺骗TCP协议,使其认为接收方的接收窗口为0,从而导致TCP连接失效。这种攻击方式也被称为TCP RST攻击或TCP拒绝服务攻击。

解决这种问题的方法,是调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量

$ echo 16384 > /proc/sys/net/ipv4/tcp_max_orphans
当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

1.2.2.1.3 FIN_WAIT2 状态的优化

如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。

但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。

它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。 这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的,原因见下文《TIME_WAIT 状态的优化》。

1.2.2.1.4 TIME_WAIT 状态的优化

TIME_WAIT 状态的等待时间是 2MSL

这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间(报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。

2 MSL 相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。

因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。

1.2.2.1.4.1 优化方式一

当 TIME_WAIT 的连接数量超过tcp_max_tw_buckets 参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。

当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets 参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟系统资源是有限的。

1.2.2.1.4.2 优化方式二

有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态时间超过 1 秒的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。

需要打开对 TCP 时间戳的支持 timestamps(对方也要打开 )

开启 tcp_timestamps 之后 PAWS 机制也就开启了。

1.2.2.1.4.3 tcp_tw_recycle

老版本的 Linux 还提供了 tcp_tw_recycle 参数,但是当开启了它,允许处于 TIME_WAIT 状态的连接被快速回收,但是有个大坑。

开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS(Protect Against Wrapped Sequence numbers,防止序列号回绕) 机制,判断TCP 报文中时间戳是否是历史报文,per-host 是对「对端 IP 做 PAWS 检查」,而非对「IP + 端口」四元组做 PAWS 检查。

⚠️:tcp_tw_recycle 会 影响 PAWS 的行为变成 per-host,而 PAWS 与 tcp_tw_recycle 没有必然联系。

如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。

Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。

当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包。

因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。

所以在 Linux 4.12 版本后,Linux 内核直接取消了这一参数。

1.2.2.1.4.4 优化方式三

通过设置 socket 选项,来设置调用 close 关闭连接行为

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: 表示是否开启SO_LINGER选项。如果 l_onoff 设置为非0,表示开启SO_LINGER选项;如果设置为0,则表示关闭SO_LINGER选项。
  • l_linger: 表示在关闭连接时等待的时间。如果l_onoff为1(开启SO_LINGER选项),则l_linger表示等待的时间(以秒为单位)。如果l_linger设置为0,则表示立即关闭连接,不等待任何时间。

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

这种方式只推荐在客户端使用,服务端千万不要使用。因为服务端一调用 close,就发送 RST 报文的话,客户端就总是看到 TCP 连接错误 “connnection reset by peer”。

1.2.2.2 被动方的优化

当被动方收到 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 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。

1.2.3 TCP 传输数据的性能提升

TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区:

  • 如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低;
  • 如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;

因此,我们必须理解 Linux 下 TCP 内存的用途,才能正确地配置内存大小。

1.2.3.1 滑动窗口是如何影响传输速度的

TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。

由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过 free 命令观察到 buff/cache 内存是会增大。

考虑到接收方的处理能力,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。

接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 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 函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度?

考虑到网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。

1.2.3.2 如何确定最大传输速度

在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。

带宽时延积(Bandwidth Delay Product),它决定网络中飞行报文的大小:

带宽时延积 BDR = RTT * 带宽

比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。

这 1MB 表示「正在传输中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。

由于缓冲区大小决定了发送窗口的上限,而窗口又决定了已发送未确认的飞行报文上限。因此发送缓冲区大小不能超过带宽时延积。

发送缓冲区与带宽时延积的关系:

  • 如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;
  • 如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。

所以,发送缓冲区的大小最好是往带宽时延积靠近。

1.2.3.3 怎样调整缓冲区大小

在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行动态调节。

1.2.3.3.1 调节发送缓冲区范围

发送缓冲区的范围通过 tcp_wmem 参数配置:

$ echo "4096 16384 4194304" > /proc/sys/net/ipv4/tcp_wmem

单位都是字节,它们分别表示:

  1. 动态范围的最小值
  2. 初始默认值,≈16K
  3. 动态范围的最大值,≈4M

发送缓冲区是自行调节的。当发送方的数据被确认后,且没有新的数据要发送,就会把发送缓冲区的内存释放掉。

1.2.3.3.2 调节接收缓冲区范围

发送缓冲区的范围通过 tcp_rmem 参数配置:

$ echo "4096 87380 6291456" > /proc/sys/net/ipv4/tcp_rmem

单位都是字节,它们分别表示:

  1. 动态范围的最小值,表示在内存压力下也可以保证的最小接收缓冲区大小,4K
  2. 初始默认值,≈86K
  3. 动态范围的最大值,≈6M

接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:

  • 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会增大,因而提升发送方的传输数据量
  • 反之,如果系统内存紧张,就会减少缓冲区,虽然会降低传输效率,但可以保证更多的并发连接正常工作

发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:

$ echo 1 > /proc/sys/net/ipv4/tcp_moderate_rcvbuf
1.2.3.3.3 调节 TCP 内存范围

接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的:

$ echo "88560 118080 177120" > /proc/sys/net/ipv4/tcp_mem

单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示:

  1. 当分配给TCP的内存小于第一个值时,不需要自动调节
  2. 当在第一个和第二个值之间时,内核开始调节接收缓冲区的大小
  3. 大于第三个值时,内核不再为TCP分配新内存,此时新连接是无法建立的。

一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是 177120,当内存为 (177120 * 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。

1.2.3.3.3.1 根据实际场景调节的策略

在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。

同时,如果这是网络 IO 型服务器,那么,调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。

1.3 如何理解 TCP 是面向字节流的协议

1.3.1 如何理解字节流

之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作系统对 TCP 和 UDP 协议的发送方的机制不同,也就是问题原因在发送方。

1.3.1.1 为什么 UDP 是面向报文的协议

当用户消息通过 UDP 协议传输时,操作系统不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。

操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文,这样当用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。

1.3.1.2 为什么 TCP 是面向字节流的协议

当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。

这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。

在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。

至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。

因此,我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。

当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。

要解决这个问题,要交给应用程序

1.3.2 如何解决粘包

粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。

一般有三种方式分包的方式:

  • 固定长度的消息;
  • 特殊字符作为边界;
  • 自定义消息结构。

1.3.2.1 固定长度的消息

1.3.2.2 特殊字符作为边界

我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。

HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。

注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。

1.3.2.3 自定义消息结构

我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。

当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。

1.4 为什么 TCP 每次建立连接时,初始化序列号都要不一样呢

如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。参见前文《TCP连接》。

如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文:

1.4.1 随机序列号的生成

RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)

  • M是一个计时器,这个计时器每隔 4 微秒加1。
  • F 是一个 Hash 算法,根据源IP、目的IP、源端口、目的端口生成一个随机数值,要保证 hash 算法不能被外部轻易推算得出。

可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。

1.4.1 完全避免历史报文被接收的问题

序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。

tcp_timestamps 参数是默认开启的,开启之后,TCP 头部就会使用时间戳选项,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)

防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。

1.4.2 如果时间戳也回绕了怎么办

时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。

时间戳回绕的速度只与对端主机时钟频率有关。

Linux 以本地时钟计数(jiffies)作为时间戳的值,不同的增长时间会有不同的问题:

  • 如果时钟计数加 1 需要1ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。
  • 如果时钟计数提高到 1us 加1,则回绕需要约71.58分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过70分钟,只是如果70分钟没有报文收发则会有一个包越过PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的TCP连接少之又少),但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题;
  • 如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,对于TCP连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加;

Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。

要解决时间戳回绕的问题,可以考虑以下解决方案:

1)增加时间戳的大小,由32 bit扩大到64bit

这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的IPv4与IPv6一样

2)将一个与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳的增速不变

随着时钟频率的提高,TCP在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。

1.5 SYN 报文什么时候情况下会被丢弃

  • 防火墙或网络安全设备过滤:防火墙或其他网络安全设备可能配置有规则,用于检查并过滤来自特定源地址或端口的SYN报文。如果SYN报文被认为是威胁或违反了安全策略,它可能会被丢弃。
  • 过载:如果目标主机或目标服务器负载过高,无法及时处理所有传入的SYN报文,有可能会导致一部分SYN报文被丢弃。这种情况下,发送方可能会进行重传尝试。
  • 开启了 tcp_tw_recycle 参数并且处于 nat 模式下
  • 半连接队列满了
    • 在开启 tcp_syncookies 的情况下,即使半连接队列满了,也不会丢弃syn 包
  • 全连接队列满了

1.6 已建立连接的TCP,收到SYN会发生什么

一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理?

这个场景中,客户端的 IP、服务端 IP、目的端口并没有变化,所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。

1.6.1  客户端的 SYN 报文里的端口号与历史连接不相同

新的连接: 服务端会认为是新的连接要建立,于是就会通过三次握手来建立新的连接。

旧的连接: 如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。 如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。

1.6.2 客户端的 SYN 报文里的端口号与历史连接相同

处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK

接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。

1.7 如何关闭一个 TCP 连接

  1. 杀掉进程 杀掉客户端进程和服务端进程影响的范围会有所不同:

  2. 在客户端杀掉进程的话,就会发送 FIN 报文,来断开这个客户端进程与服务端建立的所有 TCP 连接,这种方式影响范围只有这个客户端进程所建立的连接,而其他客户端或进程不会受影响。

  3. 而在服务端杀掉进程影响就大了,此时所有的 TCP 连接都会被关闭,服务端无法继续提供访问服务。

所以,关闭进程的方式并不可取,最好的方式要精细到关闭某一条 TCP 连接。

  1. 伪造 RST 报文 要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。

直接伪造符合预期的序列号是比较困难,因为如果一个正在传输数据的 TCP 连接,序列号都是时刻都在变化,因此很难刚好伪造一个正确序列号的 RST 报文。

1.7.1 killcx 工具

如果处于 Established 状态的服务端,收到四元组相同的 SYN 报文后,会回复一个 Challenge ACK,这个 ACK 报文里的「确认号」,正好是服务端下一次想要接收的序列号,说白了,就是可以通过这一步拿到服务端下一次预期接收的序列号。如此,就可以伪造一个四元组相同的 SYN 报文,来拿到“合法”的序列号!

然后用这个确认号作为 RST 报文的序列号,发送给服务端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接!

在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,它会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号伪造两个 RST 报文分别发给客户端和服务端,这样双方的 TCP 连接都会被释放,这种方式活跃和非活跃的 TCP 连接都可以杀掉。

它伪造客户端发送 SYN 报文,服务端收到后就会回复一个携带了正确「序列号和确认号」的 ACK 报文(Challenge ACK),然后就可以利用这个 ACK 报文里面的信息,伪造两个 RST 报文:

用 Challenge ACK 里的确认号伪造 RST 报文发送给服务端,服务端收到 RST 报文后就会释放连接。 用 Challenge ACK 里的序列号伪造 RST 报文发送给客户端,客户端收到 RST 也会释放连接。

1.7.2 tcpkill 工具

tcpkill 工具是在双方进行 TCP 通信时,拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文,并将其发送给服务端和客户端,达到关闭 TCP 连接的效果。

  • tcpkill 工具属于被动获取,,很显然这种方式无法关闭非活跃的 TCP 连接,只能用于关闭活跃的 TCP 连接。因为如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。
  • killcx 工具则是属于主动获取,它是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取正确的序列号,所以这种方式无论 TCP 连接是否活跃,都可以关闭。

1.8 四次挥手中收到乱序的 FIN 包会如何处理

在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就会被加入到乱序队列,并不会进入到 TIME_WAIT 状态。

等再次接收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文序列号保持顺序的报文,就看该报文是否有 FIN 标志,如果有才会进入 TIME_WAIT 状态。

1.8.1 乱序队列

TCP使用序号(Sequence Number)来标识每个传输的数据段,并使用确认序号(Acknowledgment Number)来确认已经收到的数据段。当接收方收到乱序的数据段时,TCP会根据序号来对它们进行重新排序,然后交给上层应用程序。这样,无论数据段的到达顺序如何,接收端的应用程序都可以按照正确的顺序重建数据。

TCP乱序队列的处理通常是由TCP协议栈自动完成的,上层应用程序通常无需关心。TCP协议通过维护一个乱序队列缓冲区,将乱序的数据段暂时存储在其中,并等待缺失的数据段到达以便进行重组。一旦缺失的数据段到达,TCP会将它插入到正确的位置,从而保证数据的有序交付。

需要注意的是,TCP乱序队列只是TCP协议栈内部的处理机制,对上层应用程序来说,数据是按正确的顺序交付的。因此,无论是否发生乱序,应用程序都可以像正常情况一样处理接收到的数据。

1.9 在 TIME_WAIT 状态的 TCP 连接,收到相同四元组的  SYN 后会发生什么

针对这个问题,关键是要看 SYN 的「序列号和时间戳」是否合法

什么是「合法」的 SYN:

  • 合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大,并且 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要大。
  • 非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小,或者 SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要小。

上面 SYN 合法判断是基于双方都开启了 TCP 时间戳机制的场景,如果双方都没有开启 TCP 时间戳机制,则 SYN 合法判断如下:

  • 合法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要大。
  • 非法 SYN:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要小。

1.9.1 收到合法 SYN

如果处于 TIME_WAIT 状态的连接收到「合法的 SYN 」后,就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。

1.9.2 收到非法的 SYN

如果处于 TIME_WAIT 状态的连接收到「非法的 SYN 」后,就会再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号(ack num),就回 RST 报文给服务端。

1.10 在 TIME_WAIT 状态,收到 RST 会断开连接吗

取决于 net.ipv4.tcp_rfc1337 内核参数(默认情况是为 0):

  • 如果这个参数设置为 0, 收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。
  • 如果这个参数设置为 1, 就会丢掉 RST 报文。

TIME_WAIT 状态之所以要持续 2MSL 时间,主要有两个目的:

  1. 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
  2. 保证「被动关闭连接」的一方,能被正确的关闭;

TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。

1.11 TCP 连接,一端断电和进程崩溃有什么区别

1.11.1 没有数据传输的场景

附加条件:

  • 没有开启TCP保活机制
  • 双方没有数据传输

1.11.1.1 主机崩溃

客户端主机崩溃了,服务端是无法感知到的,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。

1.11.1.2 进程崩溃

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

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

1.11.2 有数据传输的场景

1.11.2.1 客户端主机宕机,又迅速重启

Client 宕机后, Server 向 Client 发送的报文不会得到任何响应,在一定时间后 Server 就会触发超时重传机制。

Server 重传报文的过程中, Client 重启完成值, Client 内核就会收到重传的报文,然后根据报文的信息传递给对应的进程:

  • 如果 Client 主机没有进程绑定该TCP报文的目标端口号,那么 Client 内核就会回复 RST 报文,重置该TCP连接
  • 如果 Client 主机上有进程绑定该TCP报文的目标端口号,由于 Client 重启后,之前的TCP连接信息已经丢失了, Client 内核协议栈会发现找不到该TCP连接的socket结构体,就会回复RST报文,重置该TCP连接。

所以,只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接。

1.11.2.2 客户端主机宕机,一直没有重启

这种情况,服务端超时重传报文的次数达到一定阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。

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

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

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

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

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

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

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

  • 如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。
  • 但是,如果如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值(由tcp_retries2控制)后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。 而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。

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

是否开启了TCP保活机制(TCP-keepalive):

  • 没有开启:在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。
  • 开启:在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文: 如果对端是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。 如果对端主机宕机(注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。

1.13 为什么 tcp_tw_reuse 默认是关闭的

tcp_tw_reuse 的作用是让客户端快速复用处于 TIME_WAIT 状态的端口,相当于跳过了 TIME_WAIT 状态,这可能会出现这样的两个问题:

  • 历史 RST 报文可能会终止后面相同四元组的连接,因为 PAWS 检查到即使 RST 是过期的,也不会丢弃。
  • 如果第四次挥手的 ACK 报文丢失了,有可能被动关闭连接的一方不能被正常的关闭;

1.13.1 第一个问题

开启 tcp_tw_reuse 的同时,也需要开启 tcp_timestamps,意味着可以用时间戳的方式有效的判断回绕序列号的历史报文。

但是,对于 RST 报文来说,即使时间戳过期了,只要序列号在对方的接收窗口内,也是能接受的。

假设有这样的场景,如下图:

RFC 1323 对此的解释是:

建议 RST 段不携带时间戳,并且无论其时间戳如何,RST 段都是可接受的。老的重复的 RST 段应该是极不可能的,并且它们的清除功能应优先于时间戳。

1.13.2 第二个问题

  • 如果第四次挥手的 ACK 报文丢失了,服务端会触发超时重传,重传第三次挥手报文,处于 syn_sent 状态的客户端收到服务端重传第三次挥手报文,则会回 RST 给服务端。如下图:

  • 如果 TIME_WAIT 状态被快速复用后,刚好第四次挥手的 ACK 报文丢失了,那客户端复用 TIME_WAIT 状态后发送的 SYN 报文被处于 last_ack 状态的服务端收到了会发生什么呢?

处于 last_ack 状态的服务端收到了 SYN 报文后,会回复确认号与服务端上一次发送 ACK 报文一样的 ACK 报文,这个 ACK 报文称为 Challenge ACK,并不是确认收到 SYN 报文。

处于 syn_sent 状态的客户端收到服务端的 Challenge ACK (opens new window)后,发现不是自己期望收到的确认号,于是就会回复 RST 报文,服务端收到后,就会断开连接。

1.14 HTTPS 中 TLS 和 TCP 能同时握手吗

首次建立连接一定是先TCP三次握手,再进行 TLS 四次握手。

不过 TLS 握手过程的次数还得看版本。 TLSv1.2 握手过程基本都是需要四次,也就是需要经过 2-RTT 才能完成握手,然后才能发送请求,而 TLSv1.3 只需要 1-RTT 就能完成 TLS 握手。

需要下面这两个条件同时满足才可以同时握手:

  1. 客户端和服务端都开启了 TCP Fast Open 功能,并且 TLS 的版本是 v1.3
  2. 客户端和服务端已完成一次通信

客户端和服务端同时支持 TCP Fast Open 功能的情况下,在第二次以后到通信过程中,客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据。

如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。

因此如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。

如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。

1.15 TCP 协议有什么缺陷

1.15.1 升级 TCP 的工作很困难

TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。

而升级内核这个工作是很麻烦的事情,麻烦的事情不是说升级内核这个操作很麻烦,而是由于内核升级涉及到底层软件和运行库的更新,我们的服务程序就需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。

即使 TCP 有比较好的特性更新,也很难快速推广,用户往往要几年或者十年才能体验到。

1.15.2 TCP 建立连接的延迟

基于 TCP 实现的应用协议,都是需要先建立三次握手才能进行数据传输,比如 HTTP 1.0/1.1、HTTP/2、HTTPS。

现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS 四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。

TCP Fast Open 这个特性是不错,但是它需要服务端和客户端的操作系统同时支持才能体验到,而 TCP Fast Open 是在 2013 年提出的,所以市面上依然有很多老式的操作系统不支持,而升级操作系统是很麻烦的事情,因此 TCP Fast Open 很难被普及开来。

还有一点,针对 HTTPS 来说,TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,这两个握手过程是无法结合在一起的,总是得先完成 TCP 握手,才能进行 TLS 握手。

也正是 TCP 是在内核实现的,所以 TLS 是无法对 TCP 头部加密的,这意味着 TCP 的序列号都是明文传输,所以就存安全的问题。

1.15.3 TCP 存在队头阻塞问题

TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。但只有这样做才能保证数据的有序性。

HTTP/2 多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,整个 TCP 都要等待重传,那么就会阻塞该 TCP 连接中的所有请求,所以 HTTP/2 队头阻塞问题就是因为 TCP 协议导致的。

1.15.4 网络迁移需要重新建立 TCP 连接

TCP是通过四元组来确定一条连接的。

当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。

而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。

1.16 端口相关问题

1.16.1 TCP 和 UDP 可以使用同一个端口吗

当然可以。

UDP 网络编程,服务端是没有监听这个动作的,只有执行 bind() 系统调用来绑定端口的动作。

在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。

所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。

传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。

当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。

1.16.2 多个 TCP 服务进程可以绑定同一个端口吗

如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。

注意⚠️:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。

这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了

但是!

对 socket 设置 SO_REUSEPORT 属性(内核 3.9 版本提供的新特性)就可以多个进程绑定相同的 IP 地址和端口。

1.16.3 SO_REUSEADDR

  • 如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功。 因此,在所有 TCP 服务器程序中,调用 bind 之前最好对 socket 设置 SO_REUSEADDR 属性,这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序。

  • 如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。 这个问题也可以由 SO_REUSEADDR 解决,因为它的另外一个作用:绑定的 IP地址 + 端口时,只要 IP 地址不是正好(exactly)相同,那么允许绑定。

使用方式: 在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性:

int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

1.16.4 重启 TCP 服务进程时,为什么会有“Address in use”的报错信息

当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。

当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 “Address already in use” 的错误。

如何避免: 在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性

1.16.5 客户端的端口可以重复使用吗

TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。

客户端的端口选择的发生在 connect 函数,内核在选择端口的时候,会从 net.ipv4.ip_local_port_range 这个内核参数指定的范围来选取一个端口作为客户端端口。

该参数的默认值是 32768 61000,意味着端口总可用的数量是 61000 - 32768 = 28232 个。

1.16.6 多个客户端可以 bind 同一个端口吗

如果我们想自己指定连接的端口,就可以用 bind 函数来实现:客户端先通过 bind 函数绑定一个端口,然后调用 connect 函数就会跳过端口选择的过程了,转而使用 bind 时确定的端口。

如果多个客户端同时绑定的 IP 地址和端口都是相同的,那么执行 bind() 时候就会出错,错误是“Address already in use”。

一般而言,客户端不建议使用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常都没什么意义。

1.16.7 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗

如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。

但是,因为只要客户端连接的服务器不同,端口资源可以重复使用的。

所以,如果客户端都是与不同的服务器建立连接,即使客户端端口资源只有几万个, 客户端发起百万级连接也是没问题的(当然这个过程还会受限于其他资源,比如文件描述符、内存、CPU 等)。

1.16.8 如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题

与同一个服务器(目标地址和目标端口一样)建立连接的情况下可以打开 net.ipv4.tcp_tw_reuse 这个内核参数。

开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。

1.16.9 客户端端口选择的流程总结

1.17 服务端没有 listen,客户端发起连接建立,会发生什么

服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。

1.18 没有 listen,能建立 TCP 连接吗

结论:可以。

  • 客户端是可以自己连自己的形成连接(TCP自连接)
  • 也可以两个客户端同时向对方发出请求建立连接(TCP同时打开)

这两个情况都有个共同点,就是没有服务端参与,也就是没有listen,就能建立连接。

1.18.1 原因

执行 listen 方法时,会创建半连接队列和全连接队列。三次握手的过程中会在这两个队列中暂存连接信息。所以形成连接,前提是你得有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。

客户端显然没有这两个队列因为客户端没有执行listen,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。

但内核还有个全局 hash 表,可以用于存放 sock 连接的信息。

在TCP自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到全局hash 表,然后将信息发出,消息经过回环地址重新回到 TCP 传输层时,就会根据 IP+端口 信息,再一次从这个全局hash 中取出信息。于是握手包一来一回,最后成功建立连接。

TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。

这两种情况下也是能收发数据的。

1.19 没有 accept,能建立 TCP 连接吗

可以。

就算不执行 accept() 方法,三次握手照常进行,并顺利建立连接。 在服务端执行 accept() 前,如果客户端发送消息给服务端,服务端是能够正常回复 ack 确认包的。 并且,sleep(20) 结束后,服务端正常执行 accept(),客户端前面发送的消息,还是能正常收到的。

1.19.1 原因

建立连接的过程中根本不需要 accept() 参与, 执行 accept() 只是为了从全连接队列里取出一条连接。

虽然都叫队列,但其实全连接队列(icsk_accept_queue)是个链表,半连接队列(syn_table)是个哈希表。

全连接里队列,他本质是个链表,因为也是线性结构,说它是个队列也没毛病。它里面放的都是已经建立完成的连接,这些连接正等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就行,所以直接从队列头取就行了。这个过程算法复杂度为 O(1)。

而半连接队列却不太一样,因为队列里的都是不完整的连接,嗷嗷等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列里把相应 IP 端口的连接取出,如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是 O(n)。

因此出于效率考虑,才对两个队列设计成了不同的数据结构。

1.20 用了 TCP 协议,数据一定不会丢吗

先说下数据传输的大致过程。

数据包会从软件的用户空间拷贝到内核空间的发送缓冲区(send buffer),数据包顺着传输层、网络层直到数据链路层,在这里数据包会经过流控(qdisc),再通过 RingBuffer 发送到物理层的网卡。然后经过网络的传输到达目的机器的网卡。

此时目的机器的网卡会通知 DMA将数据包信息放到 RingBuffer 中,再触发一个硬中断给 CPU,CPU 触发软中断ksoftirqdRingBuffer 收包,然后数据包顺着 物理层、数据链路层 、网络层、传输层,最后从内核空间拷贝到软件的用户空间里。

整条链路下来,有不少地方可能会发生丢包。

TCP 保证的可靠性,是传输层的可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。

至于数据到了接收端的传输层之后,能不能保证到应用层,TCP 并不管。

TCP 任务结束后,但上层应用的任务没结束。应用还需要将数据从 TCP 的接收缓冲区里读出来。如果此时发生了什么可能就会导致应用收不到消息。于是乎,消息就丢了。

1.20.1 建立连接时丢包

TCP 的半连接队列和全连接队列满了之后就会导致丢包。

1.20.2 流量控制丢包

所有数据不加控制一股脑冲入到网卡,网卡会吃不消。

让数据按一定的规则排个队依次处理,也就是所谓的qdisc(Queueing Disciplines,排队规则),这也是我们常说的流量控制机制。

当发送数据过快,流控队列长度txqueuelen又不够大时,就容易出现丢包现象。

查看丢包,TX 下的 dropped 字段:

# ifconfig eth0
        TX packets 9688919  bytes 2072511384 (1.9 GiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

修改长度:

# ifconfig eth0 txqueuelen 1500

1.20.3 网卡丢包

网卡和它的驱动导致丢包的场景也比较常见,原因很多,比如网线质量差,接触不良。除此之外,以下是几个常见的场景。

1.20.3.1 RingBuffer 过小导致丢包

在接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个缓冲区过小,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生丢包。

查看丢包:

# ifconfig
eth0:  RX errors 0  dropped 0  overruns 0  frame 0

上面的overruns指标,它记录了由于RingBuffer长度不足导致的溢出次数。

注意⚠️:一个网卡里是可以有多个 RingBuffer的。

当发现有这类型丢包的时候,可以通过下面的命令查看当前网卡的配置:

#ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:        4096
RX Mini:    0
RX Jumbo:    0
TX:        4096
Current hardware settings:
RX:        1024
RX Mini:    0
RX Jumbo:    0
TX:        1024

上面的输出内容,含义是RingBuffer 最大支持 4096 的长度,但现在实际只用了 1024。

修改这个长度可以执行ethtool -G eth1 rx 4096 tx 4096 将发送和接收 RingBuffer 的长度都改为 4096。

1.20.3.2 网卡性能不足

网卡作为硬件,传输速度是有上限的。当网络传输速度过大,达到网卡上限时,就会发生丢包。

通过以下命令获取网卡支持的最大速度:

# ethtool eth0
Settings for eth0:
    Speed: 1000Mb/s

这是个千兆网卡,但注意这里的单位是Mb,这里的b 是指 bit,而不是 Byte。1Byte=8bit。所以 10000Mb/s 还要除以 8,也就是理论上网卡最大传输速度是1000/8 = 125MB/s。

通过sar命令从网络接口层面来分析数据包的收发情况:

# sar -n DEV 1
Linux 3.10.0-1127.19.1.el7.x86_64      2022年07月27日     _x86_64_    (1 CPU)

08时35分39秒     IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s    rxcmp/s   txcmp/s  rxmcst/s
08时35分40秒      eth0      6.06      4.04      0.35    121682.33   0.00    0.00     0.00

txkB/s 是指当前每秒发送的字节(byte)总数,rxkB/s 是指每秒接收的字节(byte)总数。

当两者加起来的值约等于12~13w字节的时候,也就对应大概125MB/s的传输速度。此时达到网卡性能极限,就会开始丢包。

1.20.4 接收缓冲区丢包

使用TCP socket进行网络编程的时候,内核都会分配一个发送缓冲区和一个接收缓冲区。

当发一个数据包,会在代码里执行send(msg),这时候数据包并不是一把梭直接就走网卡飞出去的。而是将数据拷贝到内核发送缓冲区就完事返回了,至于什么时候发数据,发多少数据,这个后续由内核自己做决定。

而接收缓冲区作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。

这两个缓冲区是有大小限制的,可以通过下面的命令去查看。

# 查看接收缓冲区
# 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 之间动态调整。

1.20.4.1 发送缓冲区

对于发送缓冲区,执行 send 的时候,如果是阻塞调用,那就会等,等到缓冲区有空位可以发数据。

如果是非阻塞调用,就会立刻返回一个 EAGAIN 错误信息,意思是 Try again。让应用程序下次再重试。这种情况下一般不会发生丢包。

1.20.4.2 接收缓冲区

它的 TCP 接收窗口会变为 0,也就是所谓的零窗口,并且会通过数据包里的win=0,告诉发送端不要再发送了。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生丢包。

内核5.9版本引入了打点可以查看丢包:

cat /proc/net/netstat
TcpExt: SyncookiesSent TCPRcvQDrop SyncookiesFailed
TcpExt: 0              157              60116

1.20.5 两端之间的网络丢包

前面提到的是两端机器内部的网络丢包,除此之外,两端之间那么长的一条链路都属于外部网络,这中间有各种路由器和交换机还有光缆啥的,丢包也是很经常发生的。

1.20.5.1 ping 命令

1.20.5.2 mtr 命令

mtr 命令可以查看到你的机器和目的机器之间的每个节点的丢包情况。

默认使用 ICMP 进行探测:

mtr -r baidu.com

有些节点限制了ICMP 包,导致不能正常展示某些节点的IP。

使用udp 包,就能看到部分???对应的 IP。

mtr -u -r baidu.com

1.20.6 应用层丢包问题怎么解决

可以使用服务器同步消息。

  • 对于发送方,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。

  • 如果接收方崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,同步上来就是了,所以也不存在上面提到的丢包情况。

两端通信的时候也能对账,为什么还要引入第三端服务器?

  1. 资源问题。如果是两端通信,你聊天软件里有1000个好友,你就得建立1000个连接。但如果引入服务端,你只需要跟服务器建立1个连接就够了,聊天软件消耗的资源越少,手机就越省电。
  2. 安全问题。如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种鉴权校验。
  3. 版本问题。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,且两端的软件版本跨度太大,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本升级,否则不能使用软件。但对于大部分兼容性问题,给服务端加兼容逻辑就好了,不需要强制用户更新软件。

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

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

1.21.1 粗暴关闭 vs 优雅关闭

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

  • close 函数。同时关闭socket的发送和读取方向,socket 不再有发送和接收的能力。如果有多进程/多线程共享同一个socket,某一个进程调用了close 只会让 socket的引用计数减一,不会导致socket不可用,同时也不会发出 FIN 报文,其他进程仍然可以正常读写该 socket,知道引用计数变为0,才会发出 FIN 报文。
  • shutdown 函数。可以指定socket的关闭方向,需要关闭连接的时候只关闭发送方向,使socket不再有发送数据的能力,但是还有接收数据的能力。如果有多进程/多线程共享socket,shutdown 也不管引用计数,直接使socket不可用,然后发出 FIN 报文。如果别的进程企图使用该 socket,将受到影响。

1.21.1.1 close

如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中如果收到了服务端发送的数据,客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手,所以说,调用 close 是粗暴的关闭。

当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:

  • 如果是读操作,则会返回 RST 的报错,也就是我们常见的 Connection reset by peer。
  • 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。

1.21.1.2 shutdown

相对的,shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以我们常说,调用 shutdown 是优雅的关闭。

但是注意,shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核是不会发送 FIN 报文的,因为发送 FIN 报文是意味着我方将不再发送任何数据,而 shutdown 如果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN。

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

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

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

延迟确认参考《TCP特性》。

1.22 TCP 序列号和确认号是如何变化的

1.22.1 万能公式

发送的 TCP 报文:

  • 公式1: 序列号=上次发送的报文中的序列号+数据长度。特殊情况,如果上次发送的报文是 SYN 或 FIN 报文,则改为 上次发送的序列号+1
  • 公式2: 确认号=上次收到的报文中的序列号+数据长度。特殊情况,如果收到是 SYN 或 FIN 报文,则改为上次收到的报文中的序列号+1

1.22.2 三次握手阶段的变化

TCP 将 SYN 报文视为 1 字节的数据,当对方收到了 SYN 报文后,在回复 ACK 报文时,就需要将 ACK 报文中的确认号设置为 SYN 的序列号 + 1 ,这样做是有两个目的:

  1. 告诉对方,我方已经收到 SYN 报文。
  2. 告诉对方,我方下一次「期望」收到的报文的序列号为此确认号,比如客户端与服务端完成三次握手之后,服务端接下来期望收到的是序列号为 client_isn + 1 的 TCP 数据报文。

1.22.3 数据传输阶段的变化

客户端发送 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 你也能得到这个结论。

客户端与服务端完成 TCP 三次握手后,发送的第一个 「TCP 数据报文的序列号和确认号」都是和「第三次握手的 ACK 报文中序列号和确认号」一样的

接着,当服务端收到客户端 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 字节的数据报文。

1.22.4 四次挥手阶段的变化

客户端发送的第一次挥手的序列号和确认号分别设置为:

  • 序列号设置为 client_isn + 11。客户端上一次发送的报文是 [PSH, ACK] ,该报文的 seq = client_isn + 1, len = 10,根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 client_isn + 11。
  • 确认号设置为 server_isn + 1。客户端上一次收到的报文是服务端发来的 ACK 报文,该报文的 seq = server_isn + 1,是单纯的 ACK 报文,不携带用户数据,所以 len 为 0。那么根据公式 2(确认号 = 上一次收到的序列号 + len),可以得出当前的确认号为 server_isn + 1 + 0 (len = 0),也就是 server_isn + 1。

服务端发送的第二次挥手的序列号和确认号分别设置为:

  • 序列号设置为 server_isn + 1。服务端上一次发送的报文是 ACK 报文,该报文的 seq = server_isn + 1,而该报文是单纯的 ACK 报文,不携带用户数据,所以 len 为 0,根据公式 1(序列号 = 上一次发送的序列号 + len),可以得出当前的序列号为 server_isn + 1 + 0 (len = 0),也就是 server_isn + 1。
  • 确认号设置为 client_isn + 12。服务端上一次收到的报文是客户端发来的 FIN 报文,该报文的 seq = client_isn + 11,根据公式 2(_确认号= 上一次_收到的序列号 + len,特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的确认号为 client_isn + 11 + 1,也就是 client_isn + 12。

服务端发送的第三次挥手的序列号和确认号还是和第二次挥手中的序列号和确认号一样。

  • 序列号设置为 server_isn + 1。
  • 确认号设置为 client_isn + 12。

客户端发送的四次挥手的序列号和确认号分别设置为:

  • 序列号设置为 client_isn + 12。客户端上一次发送的报文是 FIN 报文,该报文的 seq = client_isn + 11,根据公式 1(序列号 = 上一次发送的序列号 + len。特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的序列号为 client_isn + 11 + 1,也就是 client_isn + 12。
  • 确认号设置为 server_isn + 2。客户端上一次收到的报文是服务端发来的 FIN 报文,该报文的 seq = server_isn + 1,根据公式 2(_确认号 = 上一次_收到的序列号 + len,特殊情况,如果收到报文是 SYN 报文或者 FIN 报文,则改为 + 1),可以得出当前的确认号为 server_isn + 1 + 1,也就是 server_isn + 2。