通过实验深入了解 TCP 数据的发送和接收
阿里妹导读
本系列文章是组内写给新人和实习生的 TCP入门系列教程,结合了理论和实践,本篇为第二篇,建议先读上篇《通过实验深入了解TCP 连接的建立和关闭》。
写在前面
TCP 数据的发送与接收是个很大的话题,拖了很久也想不到好的切入点。这部分可以简单分为 「超时重传」、「窗口管理」 以及 「拥塞控制」 三个主要部分。掉书袋的话恐怕会让这篇文章被束之高阁(每写一道数学公式就会失去一半读者),所以这篇文档对具体算法只谈思想不谈公式,然后佐以代码或命令来验证实现细节。
TCP 超时重传
理解 TCP,就是理解如何在一个会出现信息丢失、重复、乱序的底层通信媒介上,设计一组交互协议来可靠保序地传递信息,同时还要兼顾传输的性能和稳定性。上篇提到 TCP 是基于自动重复请求的机制(Automnatic Repeat Request, ARQ)来设计的。简单说就是对待发送的数据(Payload)进行字节级别的编号(Sequence Number)。在 TCP 完成三次握手建立了连接后(在不携带数据的包里,握手的 SYN 包和挥手的 FIN 也要占用编号),发送的数据均带有相应的位置序号。接收端可以根据序号对数据进行重排序来解决传输过载中存在的乱序送达的情况,并回复 ACK 包给发送端以告知数据送达(不需要请求回复一对一,只需要 ACK 最大的位置即可)。基于这个朴素的数据编号的想法,TCP 完成了发送和接收数据的基本逻辑。
再次回顾下 TCP 协议头的定义:
TCP 基于时间的重传
# vm-1# 如果只输出到控制台而不需要保存包到文件的话,将 -w tcp.pcap --print 参数删除即可$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp.pcap --print# 上面命令的 --print 参数在 tcpdump v4.99.0 版本才引入,用于 -w 写文件的同时在控制台也输出详情。如果实验环境的 tcpdump 版本过低,可以从源码编译安装,或者使用下面低版本 tcpdump 等效命令:$ sudo tcpdump -s0 "tcp port 9527" -w - -U | tee tcp.pcap | tcpdump -r - -X -nn在 vm-1 上使用 nc 监听 TCP 9527 端口:
# vm-1$ nc -k -l 10.0.0.3 9527
在 vm-1 上再开个窗口循环打印连接状态:
# vm-1$ while true; do sudo netstat -anpo | grep 9527 | grep -v LISTEN; sleep 1; done在 vm-2 上打开一个终端,使用 nc 连接服务端:
# vm-2$ nc 10.0.0.3 9527
连接建立好后在 vm-2 上拦截所有 vm-1 发来的包:
# 直接拦截端口$ sudo iptables -A INPUT -p tcp --sport 9527 -j DROP
# vm-1$ sudo tcpdump -s0 -nn "tcp port 9527" -w tcp.pcap --printtcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes# 三次握手包18:23:26.292107 IP 10.0.0.4.58290 > 10.0.0.3.9527: Flags [S], seq 3350238918, win 64240, options [mss 1460,sackOK,TS val 100736976 ecr 0,nop,wscale 7], length 018:23:26.292204 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [S.], seq 956527722, ack 3350238919, win 65160, options [mss 1460,sackOK,TS val 4067628109 ecr 100736976,nop,wscale 7], length 018:23:26.292497 IP 10.0.0.4.58290 > 10.0.0.3.9527: Flags [.], ack 1, win 502, options [nop,nop,TS val 100736977 ecr 4067628109], length 0# 发送数据包18:23:52.634568 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067654452 ecr 100736977], length 4# 第 1 次重传18:23:52.840375 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067654657 ecr 100736977], length 418:23:53.043925 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067654861 ecr 100736977], length 418:23:53.447031 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067655264 ecr 100736977], length 418:23:54.281683 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067656099 ecr 100736977], length 418:23:55.911371 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067657728 ecr 100736977], length 418:23:59.210005 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067661027 ecr 100736977], length 418:24:05.863329 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067667680 ecr 100736977], length 418:24:18.921724 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067680739 ecr 100736977], length 418:24:46.055221 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067707872 ecr 100736977], length 418:25:39.303611 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067761120 ecr 100736977], length 418:27:23.753849 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067865571 ecr 100736977], length 418:29:24.584549 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4067986401 ecr 100736977], length 418:31:25.418558 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4068107235 ecr 100736977], length 418:33:26.246973 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4068228064 ecr 100736977], length 418:35:27.081681 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4068348898 ecr 100736977], length 418:37:27.915367 IP 10.0.0.3.9527 > 10.0.0.4.58290: Flags [P.], seq 1:5, ack 1, win 510, options [nop,nop,TS val 4068469732 ecr 100736977], length 4
这里包的序号是 1:5(左闭右开区间)是因为 tcpdump 默认启用了相对序号的特性。想让 tcpdump 显示原始数据序号范围的话,命令行参数里加上 -S 就行。
# vm-1$ while true; do sudo netstat -anpo | grep 9527 | grep -v LISTEN; sleep 1; donetcp 0 0 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc off (0.00/0/0)tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (0.72/2/0)tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (1.32/3/0)tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (0.30/3/0)tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (2.50/4/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (5.91/5/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (11.79/6/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (25.30/7/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (50.30/8/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (102.28/9/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.51/10/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.04/11/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.34/12/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.79/13/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.29/14/0)...tcp 0 4 10.0.0.3:9527 10.0.0.4:58290 ESTABLISHED 87178/nc on (119.79/15/0)
vm-1 发出的数据包在没有回应之后开始了重传,总的重传次数由 net.ipv4.tcp_retries2 参数决定:
# vm-1$ sysctl net.ipv4.tcp_retries2net.ipv4.tcp_retries2 = 15
从 tcpdump 的抓包结果可以看出,从第一次重传到最后一次经历了大约 15 分钟。从 netstat 的定时器信息可以看出再次超时后定时器数值放大了约 2 倍,最后放大到约 120s 之后不再放大。每次重传时间间隔加倍称之为二进制指数退避(Binary Exponential Backoff)。
# vm-1$ sysctl net.ipv4.tcp_retries1 net.ipv4.tcp_retries2net.ipv4.tcp_retries1 = 3net.ipv4.tcp_retries2 = 15
# vm-1$ sudo ss -tip | grep -A 1 9527ESTAB 0 0 10.0.0.3:9527 10.0.0.4:49308 users:(("nc",pid=116608,fd=4))cubic wscale:7,7 rto:204 rtt:0.338/0.169 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 segs_in:2 send 343Mbps lastsnd:295488 lastrcv:295488 lastack:295488 pacing_rate 685Mbps delivered:1 rcv_space:14600 rcv_ssthresh:64076 minrtt:0.338 snd_wnd:64256
先忽略别的信息,主要关注这里 rto:204 rtt:0.338/0.169 的部分。RTO 当前是 204 毫秒,RTT 是 0.338 毫秒(平均偏差 0.169ms)。上文也提到 Linux 内核的 TCP_RTO_MIN 是 200ms,明明 RTT 这么小,TCP_RTO_MIN 为什么要这么大?这是因为 RTO 要给 Delayed ACK(ACK 确认延迟) 兜底。在一个全双工通信的 TCP 连接中,当一端接收到数据后,何时发送 ACK 给对端呢?最理想的情况是接收端恰好也有数据要发送,所以让 ACK 顺便「搭车」是效率最优的。如果接收端没有数据要发送呢?大部分的 TCP 实现都会稍微等待一会,尽可能的让 ACK「搭车」发送以提升整体的网络利用效率(协议头可不小,网络上都是小包的话整体传输效率低)。因为 Delayed ACK 的存在,TCP_RTO_MIN 太小的话就会导致很多没有必要的重传产生了。
TCP Fast Retransmit(快速重传)
import timefrom scapy.all import *from scapy.layers.inet import *def main():ip = IP(dst="10.0.0.3")tcp = TCP(sport=9528, dport=9527, flags='S', seq=1)print("send SYN, seq=0")resp = sr1(ip/tcp, timeout=2)if not resp:print("recv timeout")returnresp_tcp = resp[TCP]if 'SA' in str(resp_tcp.flags):recv_seq = resp_tcp.seqrecv_ack = resp_tcp.ackprint(f"received SYN, seq={recv_seq}, ACK={recv_ack}")send_ack = recv_seq + 1tcp = TCP(sport=9528, dport=9527, flags='A', seq=2, ack=send_ack)print(f"send ACK={send_ack}")send(ip/tcp)elif 'R' in str(resp_tcp.flags):print(f"received RST")else:print("received different TCP flags")time.sleep(100)if __name__ == "__main__":main()
运行这个脚本:
# vm-1 开启服务端$ nc -k -l 10.0.0.3 9527# vm-2 先拦截掉自己给远端发送的 RST 包# 因为 scapy 在这里是冒充协议栈给 vm-1 发包的,当 vm-1 的回包进入 vm-2 的协议栈后,vm-2 发现这个包不在合法流程里就回 RST 了$ sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST --dport 9527 -j DROP$ sudo python3 ./tcp_conn.py
# vm-1$ sudo netstat -anpo | grep -E "Recv-Q|9527"Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timertcp 0 0 10.0.0.3:9527 0.0.0.0:* LISTEN 28331/nc off (0.00/0/0)tcp 0 0 10.0.0.3:9527 10.0.0.4:9528 ESTABLISHED 28331/nc off (0.00/0/0)
vm-1 上的抓包记录如下(因为方便对照验证 TCP 的真实 Sequence,后续 tcpdump 抓包时都加 -S 参数显示真实的 TCP Sequence):
# vm-1$ sudo tcpdump -S -s0 -nn "tcp port 9527" -w tcp.pcap --printtcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes11:24:38.626133 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [S], seq 1, win 8192, length 011:24:38.626170 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [S.], seq 2987883333, ack 2, win 64240, options [mss 1460], length 011:24:38.658416 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 2987883334, win 8192, length 0
那在 vm-2 上用 netstat 能看到吗?当然看不到,因为这是我们“绕过” vm-2 的协议栈在直接和 vm-1 通信(其实不能叫绕过,只是使用了 raw socket 机制,没走标准的 TCP socket 通信而已)。
先在 vm-1 上写个监听 9527 端口并且对新连接发送数据的脚本(因为要多次发送数据,用 nc 「拼手速」不现实):
import socketimport timedef start_server(host, port, backlog):server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.bind((host, port))server.listen(backlog)client, _ = server.accept()client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 禁用 Nagle 算法client.sendall(b"a" * 1460)time.sleep(0.01) # 避免协议栈合并包的方式,不严谨但是凑合能工作client.sendall(b"b" * 1460)time.sleep(0.01)client.sendall(b"c" * 1460)time.sleep(0.01)client.sendall(b"d" * 1460)time.sleep(0.01)client.sendall(b"e" * 1460)time.sleep(0.01)client.sendall(b"f" * 1460)time.sleep(0.01)client.sendall(b"g" * 1460)time.sleep(10000)if __name__ == '__main__':start_server('10.0.0.3', 9527, 8)
import threadingimport timefrom scapy.all import *from scapy.layers.inet import *class ACKDataThread(threading.Thread):def __init__(self):super().__init__()self.first_data_ack_seq = 0def run(self):def packet_callback(packet):ip = IP(dst="10.0.0.3")resp_tcp = packet[TCP]# 收到第二次握手包if 'SA' in str(resp_tcp.flags):recv_seq = resp_tcp.seqrecv_ack = resp_tcp.ackprint(f"received SYN, seq={recv_seq}, ACK={recv_ack}")send_ack = recv_seq + 1tcp = TCP(sport=9528, dport=9527, flags='A', seq=2, ack=send_ack)print(f"send ACK={send_ack}")# 第三次握手send(ip/tcp)return# 收到数据包elif resp_tcp.payload:print("-" * 50)print(f"Received TCP packet")print(f"Flags: {resp_tcp.flags}")print(f"Sequence: {resp_tcp.seq}")print(f"ACK: {resp_tcp.ack}")print(f"Payload: {resp_tcp.load}")# send_ack = resp_tcp.seq + len(resp_tcp.load)if self.first_data_ack_seq == 0:self.first_data_ack_seq = resp_tcp.seq + len(resp_tcp.load)send_ack = self.first_data_ack_seqtcp = TCP(sport=9528, dport=9527, flags='A', seq=2, ack=send_ack)print(f"send ACK={send_ack}")send(ip/tcp)send(ip/tcp)send(ip/tcp)send(ip/tcp)interface = "enp0s5" # 根据实际络接口名称更改sniff(iface=interface, prn=packet_callback, filter="tcp and port 9527", store=0)def main():thread = ACKDataThread()thread.start()time.sleep(1)ip = IP(dst="10.0.0.3")tcp = TCP(sport=9528, dport=9527, flags='S', seq=1, options=[('MSS', 1460)])# 第一次握手print("send SYN, seq=0")send(ip/tcp)thread.join()if __name__ == "__main__":main()
分别在 vm-1 和 vm-2 上运行这两个脚本:
# vm-1$ python3 ./tcp_server.py# vm-2# 确认下禁止协议栈往 vm-1 发 RST$ sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST --dport 9527 -j DROP$ sudo python3 ./tcp_dup_ack.py
# vm-1$ sudo tcpdump -S -s0 -nn "tcp port 9527" -w tcp.pcap --printtcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes13:51:35.318314 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [S], seq 1, win 8192, options [mss 1460], length 013:51:35.318585 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [S.], seq 3309748530, ack 2, win 64240, options [mss 1460], length 013:51:35.352255 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309748531, win 8192, length 013:51:35.352899 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309748531:3309749991, ack 2, win 64240, length 146013:51:35.364258 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309749991:3309751451, ack 2, win 64240, length 146013:51:35.374723 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309751451:3309752911, ack 2, win 64240, length 146013:51:35.387491 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309752911:3309754371, ack 2, win 64240, length 146013:51:35.400133 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309754371:3309755831, ack 2, win 64240, length 146013:51:35.406093 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.410723 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309755831:3309757291, ack 2, win 64240, length 146013:51:35.440428 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.478151 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.510656 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.510714 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [P.], seq 3309749991:3309751451, ack 2, win 64240, length 146013:51:35.544376 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.601033 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.633382 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.665611 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.697836 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 013:51:35.731493 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3309749991, win 8192, length 0
看起来不是很直观,直接用 Wireshark 打开抓到的包吧:
# vm-1$ sysctl net.ipv4.tcp_reorderingnet.ipv4.tcp_reordering = 3
改成 1 后就重新抓包就能看到 1 次 [TCP Dup ACK] 就会触发重传包了:
# vm-1$ sudo sysctl -w net.ipv4.tcp_reordering=1net.ipv4.tcp_reordering = 1
试验完记得把参数改回去,正常的生产环境也请不要乱动这些参数。
TCP Selective Acknowledgment(SACK)
# vm-1$ sysctl net.ipv4.tcp_sacknet.ipv4.tcp_sack = 1
需要注意的是,接收方有权利丢弃(Reneging)通过 SACK 声明过的包。也就是说发送方不能认为接收方前一次 SACK 声明了某些区间时,就认为不用重传已经收到的包了。所以在 TCP 协议里,只有 ACK 过的点位才是明确收到的,所以发送方还是得老老实实地根据 ACK 维护好定时器,时刻准备着 RTO 重传。
# vm-1$ nc -k -l 10.0.0.3 9527
#!/usr/bin/env python3# -*- coding: utf-8 -*-import timefrom scapy.all import *from scapy.layers.inet import *def main():ip = IP(dst="10.0.0.3")myself_seq = 1tcp = TCP(sport=9528, dport=9527, flags='S', seq=myself_seq, options=[("SAckOK", '')])print("send SYN, seq=0")resp = sr1(ip/tcp, timeout=2)if not resp:print("recv timeout")returnresp_tcp = resp[TCP]if 'SA' in str(resp_tcp.flags):recv_seq = resp_tcp.seqrecv_ack = resp_tcp.ackprint(f"received SYN, seq={recv_seq}, ACK={recv_ack}")myself_seq += 1send_ack = recv_seq + 1tcp = TCP(sport=9528, dport=9527, flags='A', seq=myself_seq, ack=send_ack)print(f"send ACK={send_ack}")send(ip/tcp)# 特意注释掉,让发的数据有空洞# send data# payload = b"a" * 10# tcp = TCP(sport=9528, dport=9527, flags='A', seq=myself_seq, ack=send_ack)# send(ip/tcp/payload)myself_seq += 10payload = b"b" * 10tcp = TCP(sport=9528, dport=9527, flags='A', seq=myself_seq, ack=send_ack)send(ip/tcp/payload)myself_seq += 10# 特意注释掉,让发的数据有空洞# payload = b"c" * 10# tcp = TCP(sport=9528, dport=9527, flags='A', seq=myself_seq, ack=send_ack)# send(ip/tcp/payload)myself_seq += 10payload = b"d" * 10tcp = TCP(sport=9528, dport=9527, flags='A', seq=myself_seq, ack=send_ack)send(ip/tcp/payload)elif 'R' in str(resp_tcp.flags):print(f"received RST")else:print("received different TCP flags")time.sleep(100)if __name__ == "__main__":main()
在 vm-2 上运行这个脚本,vm-1 的 tcpdump 输出如下:
# vm-1$ sudo tcpdump -S -s0 -nn "tcp port 9527" -w tcp.pcap --printtcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes14:53:08.394652 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [S], seq 1, win 8192, options [sackOK,eol], length 014:53:08.394674 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [S.], seq 884520693, ack 2, win 64240, options [mss 1460,nop,nop,sackOK], length 014:53:08.394849 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 3743896409, win 502, options [nop,nop,TS val 138055818 ecr 4104949608], length 014:53:08.394855 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [R], seq 3743896409, win 0, length 014:53:08.426952 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], ack 884520694, win 8192, length 014:53:08.458978 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], seq 12:22, ack 884520694, win 8192, length 1014:53:08.458986 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [.], ack 2, win 64240, options [nop,nop,sack 1 {12:22}], length 014:53:08.491274 IP 10.0.0.4.9528 > 10.0.0.3.9527: Flags [.], seq 32:42, ack 884520694, win 8192, length 1014:53:08.491280 IP 10.0.0.3.9527 > 10.0.0.4.9528: Flags [.], ack 2, win 64240, options [nop,nop,sack 2 {32:42}{12:22}], length 0
可以看到对面 ACK 了 12:22 和 32:42 这两个区间(左闭右开)以提示发送方重传空洞的数据。
# vm-1$ sysctl net.ipv4.tcp_dsacknet.ipv4.tcp_dsack = 1
当 SACK 开启的时候,快速重传的行为会有些区别。SACK 开启后的 Dup ACK 和没开启的 Dup ACK 的定义是不一样的。Linux 内核有个参数叫 net.ipv4.tcp_fack,用来控制 Forward Acknowledgement 开关。FACK 其实是用来做重传过程中的拥塞流控的。很多资料讲这个开关和 SACK 的关系然后教如何开启这个选项。事实上搜下内核代码就知道,这个选项早就是 obsolete 状态了。FACK 已经成为默认行为而不是需要开关控制的机制了。只是配置项还没删掉罢了。
TCP 窗口管理
http://www.tcpipguide.com/free/t_TCPSlidingWindowAcknowledgmentSystemForDataTranspo-6.htm
# vm-1$ sudo ss -tip | grep -A 1 9527ESTAB 0 0 10.0.0.3:9527 10.0.0.4:55652 users:(("nc",pid=219761,fd=4))cubic wscale:7,7 rto:204 rtt:2.097/1.048 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 segs_in:2 send 55.2Mbps lastsnd:54800 lastrcv:54800 lastack:54800 pacing_rate 110Mbps delivered:1 rcv_space:14600 rcv_ssthresh:64076 minrtt:2.097 snd_wnd:64256# vm-2$ sudo ss -tip | grep -A 1 9527ESTAB 0 0 10.0.0.4:55652 10.0.0.3:9527 users:(("nc",pid=93231,fd=3))cubic wscale:7,7 rto:204 rtt:3.064/1.532 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 37.8Mbps lastsnd:56592 lastrcv:56592 lastack:56592 pacing_rate 75.6Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:3.064 snd_wnd:65160
下面用 Python 写个不读数据的服务端和循环发送的客户端来观察 TCP 窗口的变化情况:
import socketimport timedef start_server(host, port, backlog):server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.bind((host, port))server.listen(backlog)client, _ = server.accept()time.sleep(10000)if __name__ == '__main__':start_server('10.0.0.3', 9527, 8)import socketimport timedef start_client(host, port):client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client.connect((host, port))client.setblocking(False)send_size = 0data = b"a" * 100000while True:try:size = client.send(data)if size > 0:send_size += sizeprint(f"send_size: {send_size}")except BlockingIOError:time.sleep(0.1)passif __name__ == '__main__':start_client('10.0.0.3', 9527)
# vm-1$ python3 ./tcp_server.py# vm-2$ python3 tcp_client.pysend_size: 46336send_size: 118128send_size: 218128send_size: 318128send_size: 418128send_size: 429280
在新窗口上用 netstat 输出 socket 信息:
# vm-1$ sudo netstat -anpo | grep -E "Recv-Q|9527" | grep -v LISTENProto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timertcp 110720 0 10.0.0.3:9527 10.0.0.4:34756 ESTABLISHED 220953/python3 off (0.00/0/0)# vm-2sudo netstat -anpo | grep -E "Recv-Q|9527" | grep -v LISTENProto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timertcp 0 318560 10.0.0.4:34756 10.0.0.3:9527 ESTABLISHED 94429/python3 probe (13.13/0/0)
可以看到目前 vm-1(服务端)的 Recv-Q 和 vm-2 (客户端)Send-Q 都堆积了请求,此时客户端的 send 再也写不进去数据了。
# vm-1$ sudo ethtool -k enp0s5 | grep -E "generic-segmentation-offload|generic-receive-offload"generic-segmentation-offload: ongeneric-receive-offload: on# TSO 也开着$ sudo ethtool -k enp0s5 | grep tcp-segmentation-offloadtcp-segmentation-offload: on
嚯,都是开的。那还是关上吧(两个 VM 都关掉):
# vm-1 and vm-2$ sudo ethtool -K enp0s5 gso off$ sudo ethtool -K enp0s5 gro off$ sudo ethtool -K enp0s5 tso off$ sudo ethtool -k enp0s5 | grep -E "generic-segmentation-offload|generic-receive-offload|tcp-segmentation-offload"tcp-segmentation-offload: offgeneric-segmentation-offload: offgeneric-receive-offload: off
重新抓包就可以抓包原始的包了:
注意这里 Wireshark 识别到的 TCP Keep-Alive 包其实不是真的开了 Keep-Alive 的缘故(并没有打开)。而是零窗口探测包的回包因为窗口大小没变过,从而对端回复了 Last ACK 的包恰好和 Keep-Alive 的行为一样。
#!/usr/bin/env python3# -*- coding: utf-8 -*-import socketimport timedef start_server(host, port, backlog):server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server.bind((host, port))server.listen(backlog)client, _ = server.accept()client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 禁用 Nagle 算法while True:for i in range(5):client.recv(4096)time.sleep(1)if __name__ == '__main__':start_server('10.0.0.3', 9527, 8)
TCP 拥塞控制
net.ipv4.tcp_congestion_control
# vm-1$ sysctl -a | grep congestionnet.ipv4.tcp_allowed_congestion_control = reno cubicnet.ipv4.tcp_available_congestion_control = reno cubicnet.ipv4.tcp_congestion_control = cubic
之前说的 TCP 协议头里的 window size 一直是 Receiver Window Size(rwnd)。拥塞控制还有自己的一个 window,叫 Congestion Window(cwmd)。这个窗口是当前网络的限制,发送端不会发送超过这个窗口的容量(没有 ACK 的包大小总数不会超过 cwnd 的大小)。rwnd 的值在 TCP 的通信过程中一直随着协议在更新,而 cwnd 只是发送方根据当前数据发送的情况实时计算的一个值,记录在协议栈内部。
cwnd 的值可以使用 ss 命令查看:
# vm-1$ ss -tip | grep -A 1 9527ESTAB 0 0 10.0.0.3:9527 10.0.0.4:60268 users:(("nc",pid=1727,fd=4))cubic wscale:7,7 rto:204 rtt:0.327/0.163 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 segs_in:2 send 354Mbps lastsnd:6620 lastrcv:6620 lastack:6620 pacing_rate 709Mbps delivered:1 rcv_space:14600 rcv_ssthresh:64076 minrtt:0.327 snd_wnd:64256
最早的时候 cwnd 的初始值一直是 1 倍 RSS 大小的,后来随着网络设备的整体提升,RFC2581更新为 4,RFC6928 更新到 10 .Linux 内核也调整初始值为 10 倍的 RSS 大小。
别想着改了,是个宏定义。有人提出能否给不同的 socket 设置这个参数都被骂的“狗血淋头”。维护者觉得开了这个口子只会被滥用继而影响全球互联网的稳定性。
# vm-1$ nc -l 10.0.0.3 9527 > /dev/null# vm-2$ dd if=/dev/zero of=testfile bs=1M count=400$ nc 10.0.0.3 9527 < testfile
# vm-2$ sudo yum install iproute-tc # Anolis OS 8.9 默认没安装$ sudo tc qdisc replace dev enp0s5 root netem loss 5% # 注入 5% 的丢包
抓包的时候明显就感觉发送了很久很久,不等了直接结束发送,打开抓包文件:
# vm-2$ ss -i | grep -A 1 9527tcp ESTAB 0 608544 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.229/0.241 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:2 ssthresh:26 bytes_sent:222031056 bytes_retrans:10521168 bytes_acked:211457761 segs_out:153345 segs_in:28180 data_segs_out:153343 send 101Mbps lastsnd:132 lastrcv:3052 lastack:132 pacing_rate 3.63Gbps delivery_rate 55kbps delivered:146063 busy:3052ms unacked:36 retrans:2/7266 lost:17 sacked:19 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:556416 minrtt:0.042 snd_wnd:3141760$ ss -i | grep -A 1 9527tcp ESTAB 0 605024 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.466/0.327 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:14 ssthresh:13 bytes_sent:225927624 bytes_retrans:10800632 bytes_acked:215106721 segs_out:156036 segs_in:28840 data_segs_out:156034 send 348Mbps lastrcv:3420 pacing_rate 417Mbps delivery_rate 797Mbps delivered:148564 busy:3420ms unacked:14 retrans:0/7459 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:584752 minrtt:0.042 snd_wnd:3129216$ ss -i | grep -A 1 9527tcp ESTAB 0 844416 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.421/0.264 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:2 ssthresh:11 bytes_sent:293110480 bytes_retrans:14158544 bytes_acked:278928769 segs_out:202433 segs_in:36387 data_segs_out:202431 send 55Mbps lastsnd:64 lastrcv:3796 lastack:64 pacing_rate 879Mbps delivery_rate 64Mbps delivered:192641 busy:3796ms unacked:16 retrans:2/9778 lost:15 sacked:1 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:821248 minrtt:0.042 snd_wnd:3143424$ ss -i | grep -A 1 9527tcp ESTAB 0 851312 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.205/0.149 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:1 ssthresh:2 bytes_sent:293647688 bytes_retrans:14213568 bytes_acked:279429777 segs_out:202804 segs_in:36593 data_segs_out:202802 send 56.5Mbps lastsnd:100 lastrcv:4200 lastack:100 pacing_rate 203Mbps delivery_rate 106Mbps delivered:192988 busy:4200ms unacked:3 retrans:1/9816 lost:1 sacked:2 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:846968 minrtt:0.042 snd_wnd:3144576$ ss -i | grep -A 1 9527tcp ESTAB 0 850696 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.98/0.51 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:3 ssthresh:2 bytes_sent:293736016 bytes_retrans:14219360 bytes_acked:279512313 segs_out:202865 segs_in:36625 data_segs_out:202863 send 35.5Mbps lastrcv:4576 pacing_rate 42.5Mbps delivery_rate 90.5Mbps delivered:193043 busy:4576ms unacked:3 retrans:0/9820 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:846352 minrtt:0.042 snd_wnd:3144576$ ss -i | grep -A 1 9527tcp ESTAB 0 1707576 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.102/0.03 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:11 ssthresh:18 bytes_sent:327989160 bytes_retrans:16014880 bytes_acked:311939529 segs_out:226523 segs_in:40560 data_segs_out:226521 send 1.25Gbps lastsnd:124 lastrcv:4952 lastack:124 pacing_rate 3.25Gbps delivery_rate 1.45Gbps delivered:215452 busy:4952ms unacked:24 retrans:11/11060 lost:12 sacked:12 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:1672824 minrtt:0.042 snd_wnd:3143168$ ss -i | grep -A 1 9527tcp ESTAB 0 1707576 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:408 backoff:1 rtt:0.102/0.03 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:1 ssthresh:18 bytes_sent:327990608 bytes_retrans:16016328 bytes_acked:311939529 segs_out:226524 segs_in:40560 data_segs_out:226522 send 114Mbps lastsnd:288 lastrcv:5324 lastack:496 pacing_rate 3.25Gbps delivery_rate 1.45Gbps delivered:215452 busy:5324ms unacked:24 retrans:1/11061 lost:12 sacked:12 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:1672824 minrtt:0.042 snd_wnd:3143168$ ss -i | grep -A 1 9527tcp ESTAB 0 1501208 10.0.0.4:33096 10.0.0.3:9527cubic wscale:7,7 rto:204 rtt:0.109/0.024 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:24 ssthresh:7 bytes_sent:348547864 bytes_retrans:17222512 bytes_acked:331290601 segs_out:240721 segs_in:43740 data_segs_out:240719 send 2.55Gbps lastrcv:5676 pacing_rate 3.05Gbps delivery_rate 1.98Gbps delivered:228804 busy:5676ms unacked:24 retrans:0/11894 dsack_dups:2 rcv_space:14480 rcv_ssthresh:64088 notsent:1466456 minrtt:0.042 snd_wnd:3136128可以看到 cwnd 好不容量变大后又回缩,这位就是限制了当前发送的「罪魁祸首」。
我有台国外的服务器,网络丢包比较严重的。之前用的 cubic 算法时传输带宽很糟糕,换成 bbr 后有百倍的提升。如果你也有一台这样的服务器,也可以改成 bbr 算法试试,也许有惊喜。
写在后面
附录
如果想阅读 TCP 的 RFC 的话,就不用从 793 开始阅读了,直接读 9293 更好一些。虽然 9293 也没囊括当前 TCP 协议的所有边边角角,但是绝大多数细节都在里面了。其实 9293 不包括所有细节的目的是想把 TCP 协议的 RFC 分为主体和扩展算法两部分。即实现 TCP 协议的话,需要实现「强制」的主体功能部分与一些可选的算法(尤其是拥塞控制那一堆算法)。
参考链接:
[12]https://yiyibooks.cn/rfc/rfc9293/index.html
使用 GPU 共享推理一键部署
通过创建ACK集群Pro版,使用云原生AI套件提交模型微调训练任务与部署GPU共享推理服务。支持快速创建Kubernetes集群,白屏配置任务数据共享存储和下载,并通过命令行工具Arena快速提交模型训练任务、部署推理服务。
点击阅读原文查看详情。