TCP 的主要特点如下:
面向连接
只能点对点通信,无广播能力
保证交付的数据无差错、不丢失、有序
提供全双工通信,通信两端可以在任意时刻、同时发送数据
面向字节流
TCP 的首部长度为 20B ~ 60B
Failed to generate image: mmdc failed: Generating single mermaid chart [@zenuml/core] Store is a function and is not initiated in 1 second. packet-beta 0-15: "源端口号" 16-31: "目的端口号" 32-63: "序列号" 64-95: "Acknowledgment Number" 96-99: "数据开始位置" 100-105: "保留位" 106: "URG" 107: "ACK" 108: "PSH" 109: "RST" 110: "SYN" 111: "FIN" 112-127: "窗口大小" 128-143: "校验和" 144-159: "紧急指针" 160-191: "(Options and Padding)" 192-255: "Data (variable length)"
TCP 状态迁移
上面显示了 TCP 状态迁移图,其中动态的曲线显示了一个典型的客户端连接经历的状态转换。
这里需要注意的是 TIME_WAIT 状态,当 TCP 到达此状态后会等待 2MSL,其目的主要是:
可靠地实现 TCP 全双工连接的终止。
在 TCP 挥手时,最后一个 ACK 是由主动关闭连接的一发发送的,若此 ACK 丢失而没有没有 TIME_WAIT 状态,当对端请求重发 FIN 时,会被回复 RST 报文。
允许老的重复字节在网络中消失。
等待 2MSL 可以保证当建立一个新的连接时,来自旧连接的重复分组已经在网络中消失。
在使用操作系统 TCP 协议栈的情况下 TIME_WAIT 状态发生在内核,因此引用程序感知不到。但是在用户态协议栈时 TIME_WAIT 必须由应用程序自己处理。
在某些情况下可能希望避免 TIME_WAIT 状态,这时可以通过 close 之后立即发送 RST 来强制终止连接。
粘包
由于 TCP 本身是数据流协议,因此需要自己决定如何拆分成数据流。让应用逻辑出现问题导致包边界无法确认是就认为发生了粘包。
一般情况下可以先发送数据的长度,然后发送数据。这样就能很好地避免数据粘包。
三次握手和四次挥手
TCP 通信流程如下:
这里 ack 是指确认号
TCP 通信双方任意一方都可以终止连接。其状态如下:
TIME_WAIT_1 这种被称为半连接状态。
当 client 进入 TIME_WAIT 后,TCP 会启动一个定时器,若超过 2MSL 的时间都未收到 ACK,则强制关闭连接并释放资源。
之所以会存在 TIME_WAIT 主要是防止新连接收到旧数据。由于 TCP 的序列号是有限的,因此当序列号过大时会发生回绕,这就导致 TCP 无法仅通过序列号来判断新老连接。
在高并发条件下,若频繁创建和销毁连接,就会导致操作系统存在大量处于 TIME_WAIT 状态的连接。对于这种情况解决办法有:
|
TIME_WAIT 快速回收
对于 Client 而言,可以使用 TIME_WAIT 快速回收功能。当 TCP 开启了时间戳功能后,TCP 就能够判断新老连接,这时再打开 net.ipv4.tcp_tw_reuse
就能够复用连接。
在启动此功能时,在调用 connect 时操作系统会找到一个 TIME_WAIT 状态超过 1s 的连接来进行连接复用。
SYN 泛洪攻击
对于 Linux server 而言,其在收到第一个 SYN 包时会创建一个连接,并将其放入半连接队列中,以此锁定系统的端口号。当握手完成后会将连接移入全连接队列中。
半连接队列和全连接队列的容量都是有限的,当半连接队列时操作系统要么简单地丢弃新的 SYN 数据包,要么回复 RST 包关闭连接。
由于服务端在第一步就分配了资源,因此客户端可以发送大量 SYN 包来来占满服务器资源,从而导致服务器无法正常使用。这就是 SYN 泛洪攻击。
可靠数据传输
TCP 的可靠数据传输依赖于序列号和重传机制。重传机制包括了超时重传、快速重传、选择重传。
TCP 中的 ACK 携带的序列号是接收端下一个期望的序列号。如果在传输过程中某个数据段丢失,接收方会一直重复确认最后一个正确接收到的数据的序列号。发送方在检测到重复确认时,会触发重传,但是它只能重传从丢失的数据开始的整个窗口。
名称 | 发生时机 |
---|---|
超时重传 | 若发送的数据包超时未收到 ACK(确认应答),则重传此数据包。 |
快速重传 | 若连续收到三个相同的 ACK,则重传此数据包。 |
选择重传 | 选择重传是对 TCP 的拓展,允许在 ACK 中携带缺失的报文范围。 |
流量控制
流量控制是指发送端根据接收方的能力来决定发送包的数量。TCP 通过发送窗口和接受窗口来确定能够发送的数据的数量。
接受方通过系统缓存的大小来动态调整接收窗口的大小,并将可接收的数据的大小通过窗口字段通告给发送方。
若缓存已满,则可以通过设置接收窗口为零来关闭发送方的发送能力。
当 TCP 双方的任意一方收到零窗口通知后,会启动一个定时器,当定时器超时后会发送窗口探测报文。对方收到报文后需要回复自己接收窗口的大小。
拥塞控制
拥塞控制用来探测网络的拥塞情况,并在网络拥塞时降低发送频率。
发送发通过维持拥塞窗口 cwnd(Congestion Window) 来决定发送的数据量。拥塞窗口的变化遵循下面的算法:
慢启动:发送方每收到一个 ACK,cwnd 的大小就加 1。
在慢启动阶段,没增加一次 cwnd 会导致下轮可发送的包数量加 1,进而导致收到的 ACK 数量加 1。因此第一轮只能收到一个 ACK,第二轮能收到 2 个 ACK,第三轮能收到 4 个 ACK,以此类推,导致 cwnd 以指数性增长。
当 \(cwnd >= ssthresh(slow start threshold)\) 时,启动拥塞避免算法。 在拥塞避免时期,每收到一个 ACK 会导致 cwnd 增加 \(\frac{1}{cwnd}\)。从而导致 cwnd 线性增长。
拥塞发生有两种情况:
若因超时重传探测到拥塞发生,则将 cwnd 重置为初始值,并将 ssthresh 设置为原来的一半,然后启动慢启动算法。
若因快速重传探测到拥塞发生,则将 cwnd 减半,并将 ssthresh 设置为 cwnd。同时应用快速启动算法。
在快速启动算法中,执行下面的步骤:
将 cwnd 设置为 \(ssthresh + 3\)。
重传数据。
若收到重复的 ACK,则将 cwnd 加 1。
若收到新数据的 ACK,则将 cwnd 设置为第一步中 ssthresh 的值。然后再次进入拥塞避免阶段。
TCP_NODELAY
TCP 默认打开了 Nagel 算法以对小包进行优化。使用 TCP_NODELAY 可以禁用 Nagel 算法以防止包延迟。
Nagle 算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的 ACK 确认该数据已收到。
如果包长度达到MSS,则允许发送;
如果该包含有FIN,则允许发送;
设置了TCP_NODELAY选项,则允许发送;
未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;
上述条件都未满足,但发生了超时(一般为200ms),则立即发送。
Nagle 算法只允许一个未被 ACK 的包存在于网络,它并不管包的大小,因此它事实上就是一个扩展的停-等协议,只不过它是基于包停-等的,而不是基于字节停-等的。Nagle 算法完全由 TCP 协议的 ACK 机制决定,这会带来一些问题,比如如果对端 ACK 回复很快的话,Nagle 事实上不会拼接太多的数据包,虽然避免了网络拥塞,网络总体的利用率依然很低。
TCP_CORK
CORK 和 Nagel 算法类似。但是 Nagel 是将多个小包放一起发送。而 CORK 算法则会将多个包拼成一个包进行发送。
TCP_CORK 同样会导致包发送延迟,内核如果在指定时间内没有拼出来一个 MTU 大小的数据包,就会直接发送数据。
TSO
TCP Segmentation Offload(TSO,TCP 分段卸载)允许操作系统将分包的功能从 CPU 转交给网卡,从而极大程度地提高网络吞吐量。
一般情况下若用户层传入过大的 TCP 数据包,TCP 将此数据包拆分为多个小的 TCP 数据包后转交给 NIC,在拆分工作中 CPU 需要多次计算校验和等。TSO 允许操作系统直接将大包转交给 NIC,由 NIC 完成数据包拆分和校验和计算工作。从而减轻 CPU 的负担、减小上下文切换的次数。
与 TSO 类似的还有 USO(UDP Segmentation Offload) 和 GSO(Generic Segmentation Offload)。
TSO 技术要求硬件和软件的配合,而且对小数据包没有意义。
和 TSO 相反的一个技术是 LRO(Large Receive Offload),允许网卡将多个小数据包合并成大数据包,从而减小中断和复制次数。
队头阻塞
当在 TCP 的基础上进行多路复用时就会出现队头阻塞的问题。队头阻塞是指由于队头的数据包丢失从而导致其他数据被阻塞在链路中。直观表现就是一条连接的阻塞影响了其他连接的阻塞。
这种情况可以考虑使用 Quic 等协议。
TCP Fast Open
TFO(TCP Fast Open) 是 TCP 的一个拓展协议。当 Client 连接到 Server 后,Server 会生成一个 Cookie 发给 Client。当 Client 后续重连 Server 时通过携带 Cookie 来恢复对话,从而允许 Server 在等待 ACK 期间就开始发送数据,从而节省一个 RTT。
然而,由于隐私等问题。TFO 默认情况下并没有打开。
reuseport 和 reuseaddr
正常情况下一个 (addr, port) 只能由一个 socket 绑定,但是出于负载均衡等方面的考虑可能希望多个进程监听一个 socket。这时就是 reuseport 和 reuseaddr 起作用的时机。
reuseport 和 reuseaddr 必须在 bind 之前启用。 |
一个 TCP 连接关闭时会进入 TIME_WAIT 等待时间,这个等待时间最长为 2MSL。当服务器程序崩溃后,会由于 TIME_WAIT 导致无法绑定到旧的地址上(即报错为地址已经在使用)。这时需要设置 reuseaddr,允许操作系统复用处于 TIME_WAIT 状态的 socket。
功能 | |
---|---|
reuseaddr |
|
reuseport | 允许多个进程绑定同一个地址和端口 |
下面展示了一个 Rust 程序的 reuseport:
use std::net::{SocketAddr, TcpListener};
use socket2::{Domain, Type};
fn main() {
let sck = socket2::Socket::new(Domain::IPV4, Type::STREAM, None).unwrap();
sck.set_reuse_port(true).unwrap();
sck.set_reuse_address(true).unwrap();
let addr: SocketAddr = "0.0.0.0:9072".parse().unwrap();
let addr = addr.into();
sck.bind(&addr).unwrap();
sck.listen(512).unwrap();
let listener: TcpListener = sck.try_into().unwrap();
while let Ok(i) = listener.accept() {
println!("{i:?}");
}
}