在高速网络时代,tcp 协议为了适应需求,扩展了 tcp 选项。下面重点介绍其中的两个。更详细的信息可以参考 RFC7323。
系统版本为 centos 7.5, 源码来自于内核版本 3.10。
net.ipv4.tcp_window_scaling
看下 tcp header
window size 用于拥塞控制,接受方会把这个 size 通告给发送方,这样发送方就可以一直发送 size 字节而不用等收到接受方的ACK。window size 在 tcp header 中占用 16 字节, 最大为 2^16 = 65536(64KB)。在现代网络下, 64KB 太小了,会严重降低网络的传输速率。
tcp 选项加入了一个扩展因子(scale), 2^scale * window size 就是实际的窗口大小。scale 使用1字节的偏移量表示,比如 scale 值为7, 扩展因子实际值为 2^7 = 128。scale 最大值为 14, 最大的窗口大小为 2^16 * 2^14 = 2^30(1GB) 。用 wireshark 看数据包,
实际用的值是 Calulated window size。 上图的扩展因子值为 128 。
net.ipv4.tcp_timestamps
引入这个选项有两个功能,精确的测试 RTT 以及 PAWS 机制。
RTTM
之前的计算 RTT 机制是有问题的,尤其是在丢包的情况下。 发送者发生了包 A 时间点 为 send_time1, 没有得到 ACK 又重复发送了,时间点为 send_time2, 之后收到了 ACK recv_time。 recv_time – send_time1 还是 recv_time – send_time2 都是不准确的。因为根本分不清楚 recv_time 是响应的最早的包,还是重复的包。
之前的计算方式,只能根据不重传的包进行测量,如果出现大量重传现象,RTT 是不准确的。tcp_timestamps 解决了这个问题,发送者会记录时间点,接受者会把这个时间点在回包时带上,当前的时间点 – 记录的时间点就是RTT。 这里的时间点不是真实的时间戳,而是虚拟时钟,当然时间点间隔是和实际的时间有比例关系的。
tcp_timestamps 实际长度为 10 字节, 但是在包处理时为 12字节。
从抓包上看,tcp header 长度为 32字节,不开启 tcp_timestamps 情况下为 20字节。
从内核代码上看, net/ipv4/tcp_output.c 里有说明。其中, TCPOLEN_TSTAMP_ALIGNED 定义为 12。
1 2 3 4 5 6 7 8 9 10 11 12 |
2783 void tcp_connect_init(struct sock *sk) 2784 { 2785 const struct dst_entry *dst = __sk_dst_get(sk); 2786 struct tcp_sock *tp = tcp_sk(sk); 2787 __u8 rcv_wscale; 2788 2789 /* We'll fix this up when we get a response from the other end. 2790 * See tcp_input.c:tcp_rcv_state_process case TCP_SYN_SENT. 2791 */ 2792 tp->tcp_header_len = sizeof(struct tcphdr) + 2793 (sysctl_tcp_timestamps ? TCPOLEN_TSTAMP_ALIGNED : 0); 2794 |
在 tcp_timestamps 开启的情况下, header 长度都为 32 字节,会损失一部分带宽。
PAWS
在有丢包的情况下,怎么甄别是非法的包呢?非法的包产生有多种情况
- 假如网络包 A ,在发送过程中丢失了, 发送者重传了包 A,记为 A1,接受者收到了包 A1, 但之后又收到原始的包 A
- 假设链接断开,重新打开了,可能又会收到打开之前的包
之前是用 sequence number 判断包是否合法,从 tcp header 上看,sequence 为32字节。 但是在高速网络下,32 字节的 sequence 很快就会用完,导致 sequence 轮转,导致无法根据单一的 sequence 判断包的合法性。
上图显示的在不同的网络速度上,sequence 轮转需要的时间。MSL 定义了包的最大生存时间,很多系统设置为 2分钟(120秒)。这样在 FDDI 网络上,轮转时间与 MSL 相差不大了。
引入 tcp_timestamps,可以根据包的时间点来判断包的有效性。接受者会记录最新的接受包的时间点,当接受到旧包时,根据时间点判断,就可以把旧包舍弃。
tcp_timestamps 的时间间隔是根据主机频率来的,间隔一般为 1ms。那么是够用的,轮转需要的时间为 2^31 / (86400 * 1000) = 24.8 day。未来如果主机频率提速的话,轮转时间变短,用 tcp_timestamps也是不可靠的。
TCP Window Size
关注下 7484 行,9028 端口号是接收者,通告了自己的窗口大小为 1152。11414 行,发送者只发送了 1152 字节数据,之后接收者没有了缓存区空间,只有等到上层应用处理后,13902行,接收者通告窗口大小变更,之后发送者可以再次发送数据。
我们来看下 window size 是如何计算的。首先看下初始化窗口大小。在6630行的回包中,可以看到窗口大小为 13480, MSS 为 1360。先来看下 MSS 是如何计算的。
一般地, MSS = MTU – 20(IP Header) – 20(TCP Header), 很多情况下, MTU 为 1500,但在我们系统中,使用 ifconfig 查看
MTU 为 1400, 那么 MSS 为 1360 是正常的。在 SYN 阶段,双方需要协商 MSS 大小,是为了避免分包重组。
查看net/ipv4/tcp_output.c 中 tcp_select_initial_window ,先来看下 窗口因子是如何计算的。
1 2 3 4 5 6 7 8 9 10 11 12 |
220 (*rcv_wscale) = 0; 221 if (wscale_ok) { 222 /* Set window scaling on max possible window 223 * See RFC1323 for an explanation of the limit to 14 224 */ 225 space = max_t(u32, sysctl_tcp_rmem[2], sysctl_rmem_max); 226 space = min_t(u32, space, *window_clamp); 227 while (space > 65535 && (*rcv_wscale) < 14) { 228 space >>= 1; 229 (*rcv_wscale)++; 230 } 231 } |
window_clamp 这里值为最大值,基本取决于这两个 net.core.rmem_max , net.ipv4.tcp_rmem 这两个值,取最大的值然后进行计算,而且因子的最大值为14。
sysctl_tcp_default_init_rwnd 值为 10。初始窗口取决于 rcv_wnd = min(rcv_wnd, init_cwnd * mss),也就是 10 * mss。从包上看,初始窗口大小为 13480。 那么 这里 mss 是 1348。上面我们计算出 mss = 1360,结合 tcp_timestamps 讲解,这里 mss 需要减去 12 字节,也就是 1360 -12 = 1348。
1 2 3 4 5 6 7 8 9 10 11 12 |
237 if (mss > (1 << *rcv_wscale)) { 238 int init_cwnd = sysctl_tcp_default_init_rwnd; 239 if (mss > 1460) 240 init_cwnd = max_t(u32, (1460 * init_cwnd) / mss, 2); 241 /* when initializing use the value from init_rcv_wnd 242 * rather than the default from above 243 */ 244 if (init_rcv_wnd) 245 *rcv_wnd = min(*rcv_wnd, init_rcv_wnd * mss); 246 else 247 *rcv_wnd = min(*rcv_wnd, init_cwnd * mss); 248 } |
这里我们借助 Systemtap 再分析下。 首先, 列出探测点
1 2 3 |
stap -L 'kernel.function("tcp_select_initial_window")' kernel.function("tcp_select_initial_window@net/ipv4/tcp_output.c:193") $__space:int $mss:__u32 $rcv_wnd:__u32* $window_clamp:__u32* $wscale_ok:int $rcv_wscale:__u8* $init_rcv_wnd:__u32 |
我们把输入参数打印出来,并把调用堆栈打印下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
[root@ocr1.bj-bd.sm ~]# stap -e 'probe kernel.function("tcp_select_initial_window@net/ipv4/tcp_output.c").return {printf("%s\n", $$vars$$); printf("%d\t\n",kernel_char($rcv_wscale));print_backtrace();exit()}' __space=43690 mss=1348 rcv_wnd=18446612148096119424 window_clamp=18446612148096119420 wscale_ok=1 rcv_wscale="8:\220\336'\210\377\377" init_rcv_wnd=0 space=? 7 Returning from: 0xffffffff815515f0 : tcp_select_initial_window+0x0/0x130 [kernel] Returning to : 0xffffffff815524f9 : tcp_make_synack+0x3b9/0x4d0 [kernel] 0xffffffff8155b615 : tcp_v4_conn_request+0x645/0xce0 [kernel] 0xffffffff815d05b5 : tcp_v6_conn_request+0x415/0x620 [kernel] 0xffffffff81550ae0 : tcp_rcv_state_process+0x1a0/0xcb0 [kernel] 0xffffffff8155a372 : tcp_v4_do_rcv+0x282/0x470 [kernel] 0xffffffff8155c5f6 : tcp_v4_rcv+0x6e6/0x7d0 [kernel] 0xffffffff81536784 : ip_local_deliver_finish+0xb4/0x1f0 [kernel] 0xffffffff81536a58 : ip_local_deliver+0x48/0x80 [kernel] 0xffffffff815363fd : ip_rcv_finish+0x7d/0x350 [kernel] 0xffffffff81536cc4 : ip_rcv+0x234/0x380 [kernel] 0xffffffff814fc0e6 : __netif_receive_skb_core+0x676/0x870 [kernel] 0xffffffff814fc2f8 : __netif_receive_skb+0x18/0x60 [kernel] 0xffffffff814fc380 : netif_receive_skb+0x40/0xd0 [kernel] 0xffffffffa00cdaa0 [virtio_net] |
这里看到 mss 就是 1348, 最终计算的窗口因子 rcv_wscale 值为 7。直接调用函数为 tcp_make_synack。
再来关注下窗口增长情况。接收者通告了窗口大小,发送者也不会一直发送数据直到用完窗口大小。发送者有慢启动和拥塞避免机制来防止大量的丢包重传。基于这两个机制,发送端实现了自己的流控机制,接收端依靠窗口大小实现了自己的流控。类似与慢启动和拥塞避免,接收窗口大小也不是一上来就设置的最大值。而是一个初始值,然后根据接收的情况,逐步扩大接收窗口,具体大小与系统的内存情况,设置的 buffer 大小,上层应用的处理情况有关。
net.core.rmem_max , net.ipv4.tcp_rmem 这两个值决定了接收缓冲区大小。net.core.rmem_max 适用于所有的 socket 链接,net.ipv4.tcp_rmem 这个只是针对 tcp 协议的。如果 socket 显式的设置了缓冲区大小,那么优先级最高,都会覆盖这两个值。
net.ipv4.tcp_rmem 有三个值,分别是最小,默认,最大的字节数。只有修改默认值才需要重启应用,其余的两个值不需要。net.ipv4.tcp_rmem 最大值可以计算出来。窗口数据大小 = 带宽 * RTT。 假设网络带宽为 1000Mb/s, RTT 为 1ms, 窗口大小 = 1000/8 * (1/1000) = 0.125M, 但这不是最终的大小。这只是存储的数据大小,还要考虑额外的内存开销。net.ipv4.tcp_adv_win_scale 这个值决定了内存开销的占比。如果值为1, 要有一半的大小用于内存开销,如果值为2,要有 1/4 的大小用于内存开销。具体关联函数如下
1 2 3 4 5 6 |
1059 static inline int tcp_win_from_space(int space) 1060 { 1061 return sysctl_tcp_adv_win_scale<=0 ? 1062 (space>>(-sysctl_tcp_adv_win_scale)) : 1063 space - (space>>sysctl_tcp_adv_win_scale); 1064 } |
net.ipv4.tcp_adv_win_scale = 1 的话,rmem 的最大值至少要大于 0.25M, 这是理论值,实际情况要比这要大一些。如果上层应用慢的话,缓冲区的数据会处理的慢,导致缓存区满了,有额外的数据的话,有可能被丢弃。可以通过 netstat -s 查看。
1 2 3 4 |
# netstat -s ... 4012651 packets pruned from receive queue because of socket buffer overrun ... |
多调用 netstat 几次,如果数值一直增大,要考虑增加 rmem 数值,并且也要关注应用的处理速度。
总结
- 介绍了 tcp 的两个扩展选项,都是为了适应高速的网络传输
- tcp window size 大小如何设置的,以及系统参数如果影响的
- 借助 SystemTap 工具理解代码,可以追踪堆栈,方便的理清代码关系
- 借助 wireshark 工具,分析网络包的具体情况