TCP 连接的建立过程

TCP 连接的建立是一个从 Client 侧调用 connect(),到 Server 侧 accept() 成功返回的过程。Client 调用 connect() 后,Linux 内核就开始进行三次握手。首先 Client 会给 Server 发送一个 SYN 包,但是该 SYN 包可能会在传输过程中丢失,或者因为其他原因导致 Server 无法处理,此时 Client 这一侧就会触发超时重传机制。但是也不能一直重传下去,重传的次数也是有限制的,这就是 tcp_syn_retries 这个配置项来决定的。对于 tcp_syn_retries 为 3 而言,总共会重传 3 次,也就是说从第一次发出 SYN 包后,会一直等待(1 + 2 + 4 + 8)秒,如果还没有收到 Server 的响应,connect() 就会产生 ETIMEOUT 的错误。tcp_syn_retries 的默认值是 6。
SYN Flood 攻击
如果 Server 没有响应 Client 的 SYN,还有可能是因为 Server 太忙没有来得及响应,或者是 Server 已经积压了太多的半连接(incomplete)而无法及时去处理。
半连接,即收到了 SYN 后还没有回复 SYNACK 的连接,Server 每收到一个新的 SYN 包,都会创建一个半连接,然后把该半连接加入到半连接队列(syn queue)中,客户端回复 ACK,服务器从半连接队列中取出信息,建立完整连接。syn queue 的长度就是 tcp_max_syn_backlog 这个配置项来决定的,当系统中积压的半连接个数超过了该值后,新的 SYN 包就会被丢弃。对于服务器而言,可能瞬间会有非常多的新建连接,所以我们可以适当地调大该值。
Server 中积压的半连接较多,也有可能是因为有些恶意的 Client 在进行 SYN Flood 攻击。典型的 SYN Flood 攻击如下:Client 高频地向 Server 发 SYN 包,并且这个 SYN 包的源 IP 地址不停地变换,那么 Server 每次接收到一个新的 SYN 后,都会给它分配一个半连接,Server 的 SYNACK 根据之前的 SYN 包找到的是错误的 Client IP, 所以也就无法收到 Client 的 ACK 包,导致无法正确建立 TCP 连接,这就会让 Server 的半连接队列耗尽,无法响应正常的 SYN 包。
SYN Cookies 可以防止部分 SYN Flood 攻击。
SYN Cookie 机制:
- 服务器收到 SYN,不分配半连接,也不存储任何信息,只是根据 SYN 计算一个 Cookie 作为校验值。
- 将这个 Cookie 记录到 SYNACK 包中发送出去。
- 客户端返回 ACK 时,服务器根据 ACK 中的 Cookie 检查这个 ACK 包的合法性,只有合法才真正创建 TCP 连接。
这样,服务器就不需要维护大量的半连接状态,防止 SYN Flood 攻击占满队列。
Client 在收到 Server 的 SYNACK 包后,就会发出 ACK,Server 收到该 ACK 后,三次握手就完成了,即产生了一个 TCP 全连接(complete),它会被添加到全连接队列(accept queue)中。然后 Server 就会调用 accept() 来完成 TCP 连接的建立。但是,就像半连接队列(syn queue)的长度有限制一样,全连接队列(accept queue)的长度也有限制,目的就是为了防止 Server 不能及时调用 accept() 而浪费太多的系统资源。accept() 成功返回后,一个新的 TCP 连接就建立完成了,TCP 连接进入到了 ESTABLISHED 状态。
TCP 连接的断开过程

当应用程序调用 close() 时,会向对端发送 FIN 包,然后会接收 ACK;接收ACK后可能会有数据还未传输完成,当数据传输完成时,对端也会调用 close() 来发送 FIN,然后本端也会向对端回 ACK,这就是 TCP 的四次挥手大致过程。四次挥手比三次握手多,主要是体现在数据传输这一步。
我们首先来看 FIN_WAIT_2 状态,TCP 进入到这个状态后,如果本端迟迟收不到对端的 FIN 包,那就会一直处于这个状态,于是就会一直消耗系统资源。Linux 为了防止这种资源的开销,设置了这个状态的超时时间 tcp_fin_timeout,默认为 60s,超过这个时间后就会自动销毁该连接。大家都建议将 tcp_fin_timeout 调小一些,以尽量避免这种状态下的资源开销。
我们再来看 TIME_WAIT 状态,TIME_WAIT 状态存在的意义是:最后发送的这个 ACK 包可能会被丢弃掉或者有延迟,这样对端就会再次发送 FIN 包。如果不维持 TIME_WAIT 这个状态,那么再次收到对端的 FIN 包后,本端就会回一个 Reset 包,这可能会产生一些异常。
TIME_WAIT 的作用
- 确保被动关闭方(对端)正确接收 FIN-ACK
- 这样可以确保如果最后的 ACK 丢失,导致对端重传 FIN,主动关闭方还能正确接收并再次回复 ACK,避免对端错误地认为连接没有完全关闭。
- 防止旧的重复数据影响新连接
- 如果立即释放端口,新的连接可能会复用(如快速重启应用程序)相同的 {源 IP, 源端口, 目标 IP, 目标端口},出现端口被占用而无法创建新连接的情况
注意,此时连接的数据传输已经完成,不会再收发新的数据。
TCP 数据包的发送过程会受到的影响
应用程序调用 write(2) 或者 send(2) 系列系统调用开始往外发包时,这些系统调用会把数据包从用户缓冲区拷贝到 TCP 发送缓冲区(TCP Send Buffer),这个 TCP 发送缓冲区的大小是受限制的,这里也是容易引起问题的地方。TCP 发送缓冲区的大小默认是受 net.ipv4.tcp_wmem 来控制。tcp_wmem 中这三个数字的含义分别为 min、default、max。TCP 发送缓冲区的大小会在 min 和 max 之间动态调整,初始的大小是 default,这个动态调整的过程是由内核自动来做的,应用程序无法干预。tcp_wmem 中的 max 不能超过 net.core.wmem_max 这个配置项的值,如果超过了,TCP 发送缓冲区最大就是 net.core.wmem_max。tcp_wmem 以及 wmem_max 的大小设置都是针对单个 TCP 连接的,这两个值的单位都是 Byte(字节)。系统中可能会存在非常多的 TCP 连接,如果 TCP 连接太多,就可能导致内存耗尽。因此,所有 TCP 连接消耗的总内存由net.ipv4.tcp_mem控制。该选项中这些值的单位是 Page。
发送端在发送一个 TCP 数据包后,会把该数据包放在发送端的发送队列里,也叫重传队列。如果可以收到接收端对这个数据包的 ACK,该数据包就会在发送队列中被删掉,然后队列长度变为 0;如果收不到这个数据包的 ACK,就会触发重传机制,也就是说发送端在发送数据包的时候,会启动一个超时重传定时器(RTO),如果超过了这个时间,发送端还没有收到 ACK,就会重传该数据包。
TCP 层处理完数据包后,就继续往下来到了 IP 层。为了能够对 TCP/IP 数据流进行流控,Linux 内核在 IP 层实现了 qdisc(排队规则)。主要用于控制发送队列(txqueuelen)中的数据包如何排队、调度和发送。qdisc 的队列长度是我们用 ifconfig 来看到的 txqueuelen。
[root@my_website ~]# ifconfig eth0 | grep -A 5 txqueuelen
ether 00:16:3e:26:5a:4c txqueuelen 1000 (Ethernet)
RX packets 185141026 bytes 87919871175 (81.8 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 130735310 bytes 99115076161 (92.3 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
如果观察到 dropped 这一项不为 0,那就有可能是 txqueuelen 太小导致的。当遇到这种情况时,你就需要增大该值了,比如增加 eth0 这个网络接口的 txqueuelen:
ifconfig eth0 txqueuelen 2000
经过 IP 层后,数据包再往下就会进入到网卡了,然后通过网卡发送出去。至此,你需要发送出去的数据就走完了 TCP/IP 协议栈,然后正常地发送给对端了。
TCP 数据包的接收过程会受到的影响
TCP 数据包的接收流程在整体上与发送流程类似,只是方向是相反的。数据包到达网卡后,就会触发中断来告诉 CPU 读取这个数据包。但是在高性能网络场景下,数据包的数量会非常大,如果每来一个数据包都要产生一个中断,那 CPU 的处理效率就会大打折扣,所以就产生了 NAPI(New API)这种机制让 CPU 一次性地去轮询(poll)多个数据包,以批量处理的方式来提升效率,降低中断带来的性能开销。net.core.netdev_budget控制一次可以 poll 多少个数据包。
这些 poll 的数据包紧接着就会到达 IP 层去处理,然后再达到 TCP 层,这时就会面对另外一个很容易引发问题的地方了:TCP Receive Buffer(TCP 接收缓冲区)。通常情况下,默认都是使用 tcp_rmem 来控制缓冲区的大小。TCP 接收缓冲区大小也是在 min 和 max 之间动态调整 ,不过跟发送缓冲区不同的是,这个动态调整是可以通过控制选项来关闭的。之所以接收缓冲区有选项可以控制自动调节,而发送缓冲区没有,那是因为 TCP 接收缓冲区会直接影响 TCP 拥塞控制,进而影响到对端的发包,所以使用该控制选项可以更加灵活地控制对端的发包行为。
TCP 拥塞控制
TCP 拥塞控制的核心思想是 逐步探测网络带宽的极限,并根据 TCP 的数据传输状况来灵活地调整拥塞窗口,从而控制发送方发送数据包的行为。

1. 慢启动
TCP 连接建立好后,发送方就进入慢速启动阶段,然后逐渐地增大发包数量(TCP Segments)。这个阶段每经过一个 RTT(往返时间),发包数量就会翻倍。如1,2,4,......。初始发送数据包的数量是由 init_cwnd(初始拥塞窗口)来决定的,该值在 Linux 内核中被设置为 10,这是由 Google 的研究人员总结出的一个经验值。增大 init_cwnd 可以显著地提升网络性能,因为这样在初始阶段就可以一次性发送很多 TCP Segments。但是如果初始拥塞窗口设置得过大的话,可能会引起很高的 TCP 重传率(网络队列溢出)。在慢启动阶段,当拥塞窗口(cwnd)增大到一个阈值(慢启动阈值)后,TCP 拥塞控制就进入了下一个阶段:拥塞避免。
2. 拥塞避免
在这个阶段 cwnd 不再成倍增加,而是一个 RTT 增加 1,即缓慢地增加 cwnd,以防止网络出现拥塞。网络出现拥塞是难以避免的,由于网络链路的复杂性,甚至会出现乱序报文。

在上图中,发送端一次性发送了 4 个 TCP segments,但是第 2 个 segment 在传输过程中被丢弃掉了,那么接收方就接收不到该 segment 了。然而第 3 个 TCP segment 和第 4 个 TCP segment 能够被接收到,此时 3 和 4 就属于乱序报文,它们会被加入到接收端的 ofo queue(乱序队列)里。丢包率高就会导致网络响应特别慢(如在电梯里)。上图中,由于接收端没有接收到第 2 个 segment(数据段),因此接收端每次收到一个新的 segment 后都会去 ack 第 2 个 segment,即 ack 17,ACK 号确认接收方已正确接收的所有数据并始终指向“我期望收到的下一个字节”,正常情况下ack的值应该是seq+数据段长度。连续出现了 3 个响应的 ack 后,发送端会据此判断数据包出现了丢失,于是就进入了下一个阶段:快速重传。
3. 快速重传和快速恢复
快速重传和快速恢复是一起工作的,它们是为了应对丢包这种行为而做的优化,在这种情况下,由于网络并没有出现拥塞,所以拥塞窗口不必恢复到初始值。判断丢包的依据就是收到 3 个相同的 ack。除了快速重传外,还有一种重传机制是超时重传。不过,这是非常糟糕的一种情况。如果发送出去一个数据包,超过一段时间(RTO)都收不到它的 ack,那就认为是网络出现了拥塞。这个时候就需要将 cwnd 恢复为初始值,再次从慢启动开始调整 cwnd 的大小。
使用tcpdump配合tshark可以分析 TCP 重传。因为TCP 重传整体上可以分为丢包和拥塞两类。
RTO 一般发生在网络链路有拥塞的情况下,如果某一个连接数据量太大,就可能会导致其他连接的数据包排队,从而出现较大的延迟。比如下载坏坏的电影影响到别人玩堡垒之夜😂。
接收方影响发送方发送数据
接收方在收到数据包后,会给发送方回一个 ack,然后把自己的 rwnd 大小写入到 TCP 头部的 win 这个字段,这样发送方就能根据这个字段来知道接收方的 rwnd 了。接下来,发送方在发送下一个 TCP segment 的时候,会先对比发送方的 cwnd 和接收方的 rwnd,得出这二者之间的较小值,然后控制发送的 TCP segment 个数不能超过这个较小值。
小工具记录
dstat命令查看TCP 连接状态
$ dstat --tcp
------tcp-sockets-------
lis act syn tim clo
27 38 0 0 0
27 38 0 0 0