3.10内核TCP慢启动耗时问题分析——拥塞控制算法

前置知识之TCP拥塞控制算法

From Wikipedia

TCP使用多种拥塞控制策略来避免雪崩式拥塞。TCP会为每条连接维护一个“拥塞窗口”来限制可能在端对端间传输的未确认分组总数量。这类似TCP流量控制机制中使用的滑动窗口。TCP在一个连接初始化或超时后使用一种“慢启动”机制来增加拥塞窗口的大小。它的起始值一般为最大分段大小(Maximum segment size,MSS)的两倍,虽然名为“慢启动”,初始值也相当低,但其增长极快:当每个分段得到确认时,拥塞窗口会增加一个MSS,使得在每次往返时间(round-trip time,RTT)内拥塞窗口能高效地双倍增长。

当拥塞窗口超过慢启动阈值(ssthresh)时,算法就会进入一个名为“拥塞避免”的阶段。在拥塞避免阶段,只要未收到重复确认,拥塞窗口则在每次往返时间内线性增加一个MSS大小。

直观一点的cwnd变化可以参考这张图

关于tcp拥塞控制的详细解释可参考RFC5681

问题背景

云上客户反馈业务在同样以4.14内核作为server时,比用3.10内核上作client的延时要比4.14内核明显高出25%左右。

3.10内核 client测试,平均延迟57ms。

4.14内核 client测试,平均延迟44ms。

结论先行

Server端:

内核在收包时,有一种把分片重组放到网卡里的机制叫做GRO(Generic Receive Offload),会将小包合成大包再传递给协议栈处理。

(本篇将会重点解析client行为,关于server端的详细分析,请期待下回分解)

Client端:

3.10版内核和4.14版内核在收ack时默认的拥塞控制算法cubic上,对cwnd的更新策略上有差别: 3.10是常规理解的ack收到的频率低,则窗口更新就更不积极,因此如果server开了gro,server端由于合包,ack回包频率就会变小,于是client窗口就增长的比较慢,导致表现为发包延迟;

而4.14的cubic算法修改了这个策略,让发包频率和收包频率来共同主导发送窗口的调整,尤其是针对对端有GRO开启等场景做了专门的优化,从而修复了这个问题。

解决方案

方法上来讲,可以分别从Server端和Client端的调整解决此问题。

Server端:

关闭gro虽然小包可以提升传输速率,但是大包可能会造成更多的协议栈处理负载。

如果业务大部分场景都是小包小带宽的数据传输,可以先考虑关闭gro。作为临时快速的解决方案。而长线方案是慢慢灰度升高版本内核。

命令ethtool -K eth1 gro off。

Client端:

在TencentOS(腾讯自研内核)上,/proc/sys/net/ipv4/tcp_init_cwnd这个参数可以控制初始cwnd的大小,这个参数默认设置是10,是作为慢启动防止拥塞导致的网络性能下降,而如果是内网环境,大多是没有拥塞的,cwnd调大一些一般风险不大。

因此在本case中,可以调整到100,这样无论4.14还是3.10都有优化效果。且从流量来看,不会对内网造成较大影响。

命令echo 100 > /proc/sys/net/ipv4/tcp_init_cwnd (然后写到/etc/sysctl.conf)。

在业务测试后,最终使用client端修改cwnd的方案。

前置分析

首先我们要了解的是问题现象,复现条件与一些已知的线索。

根据经验,首先尝试了server端关闭gro,再对比测试,结果发现关闭后3.10 client的延时确实有比较明显的下降,4.14 client无影响。

但是我们需要进一步搞清楚这里头的原因和逻辑,来确认最后是否能真的要靠关闭gro解决(gro本身是个优化,而且正如前面所说,所以直接关闭gro听起来也并不是一个最优解。而且gro理论上影响最终收包的大小、收包数量,进而影响回包的数量等等,我们现在也不知道具体是哪个因子导致的问题,关了gro会不会按了葫芦起了瓢?),是否会有更合理、更可控、影响范围更小的的解决方案。

初次抓包分析

网络问题,一开始分析肯定是要抓包,而针对云上机器,我们也要同时进行虚拟机和宿主机两个层面的抓包。因为宿主机抓包可以确认问题出在哪个层面(是网络链路慢,还是虚拟机收包慢,还是虚拟机收到包后处理慢)。

因此我们先针对server端(gro on/off的情况下)、3.10 client端进行抓包,来一步步聚焦问题:

宿主机抓包对比

这里通过wireshark分析analyze tcp的一些相关数据(此处省略截图),得到结论:

对比下来,不管开还是不开GRO, 宿主机发给虚拟机的包,最大大小都一致,两种情况下抓到的包的上限速度(见Average Throughput)是一致的。

同时这里从宿主句抓到的实际包上看到了子机是否打开GRO,包量都是一样的,说明发给虚拟机的包都是宿主机分好的。因此判断宿主机上server侧收包路径行为是一致的。那么到此基本可以判断问题出在虚拟机里。

子机抓包对比

从子机层面的tcp行为来看,两种情况下抓到的包的传输通量也是一致的。

但是在这个过程中有发现,gro on的情况下有的stream rtt有个突然增大10ms左右的过程,然后进一步测试了10+次,发现抖动不是那么稳定,也就是这只是延迟的一个表现,即使在rtt没有抖动的情况下,延迟也是依然存在的。从而也不是这里的原因。

再次抓包分析

为了方便定位,在沟通后,客户业务侧研发搞了单请求的测试程序,这样便于观察一次连接的差异,修改后看到gro on的情况下有时延时甚至能飙到100ms,而gro off的情况下,还是稳定的40ms。

后续再一起抓包分析,发现在gro on的情况下server虚拟机回包是要少很多的,而且一来一回的情况比较多。而母机没有这个情况,且通量和rtt仍然是几乎表现一致(和第一次抓包分析的结果一样)。

但是发现,client端在gro on的情况下,可以看到发送窗口大小在达到顶峰后是有掉回的:

而在gro off的情况下则是一路增长到顶峰:

因此经过这次单连接的分析来看,对比差异最明显的一次连接,数据很清晰。gro on发送窗口最大40k左右(之后有受限再掉回来),完整传输完毕62ms(server端大概接收到11个包,延迟1ms才回一次ack,拥塞窗口增长较慢)。而gro off发送窗口可以达到80k以上,完整传输完毕44ms。

而又由于,4.14的client没有问题,而3.10的client有问题,我们推测,可能是client端的拥塞控制算法的差异导致3.10在gro on时,server会 delayed ack包的情况下,有延时或者回复的ack包量太少而导致client的发送窗口上不去。

为了验证我们的推测,我们先用iperf做了一下测试,但是结果是却是在gro on/off没有任何差异,但是这并不能直接否认我们的推测,因为能影响网络表现的因素实在是太多了,比如可能是因为压测包大小本身比较大?有可能是压测情况下ack回包频率高到一定程度后对cwnd就没有影响了?有可能是gro在压测情况下的对小包重组对整个的通量影响微乎其微?所以iperf测试表现也并不能说就推翻我们对拥塞控制算法上的怀疑,因此我们还是要进一步深挖细节,找出这一系列现象的前因后果。

Client端拥塞控制分析

在静态的代码分析上,正如在开头的结论中提到的,我们最终的目标是解决问题,找到合适的优化策略,并且我们需要能有正确的理论依据支持我们的优化策略,而不是瞎猫碰到死耗子(这样给出的方案也没有说服力),因此我们也是两条腿走路:

在server端,由于gro的行为导致延迟(合包导致包量少),需要分析代码找到gro的延迟和合包逻辑是什么,进一步分析有什么办法优化;在client端,server的延迟(回包少)影响了client的cwnd增长,为什么有延迟(回包少)会对cwnd有影响,需要了解拥塞控制算法上面可能存在的差异,进而分析有什么办法优化。

在本篇中,将讲述我们在client端针对拥塞控制方法所进行的一系列分析,而server端的分析将会留到下回分解,下面正片中的正片即将开始。

cwnd内核数据结构

在分析代码前,需要先了解,内核对于每一个socket buffer,都是由一个sk_buff结构体来表示的,而对于各个协议栈,则会有相应的数据结构直接对sk_buff进行转化。

对于TCP协议,对应的sk_buff就是tcp_sock,而收ack包的入口是tcp_ack函数,这里我们也不详细分析ack收包流程,而是聚焦于可能修改cwnd的地方。

分析内核代码知道,cwnd在内核数据结构里的表示是体现在tcp_sock->snd_cwnd(注释Sending congestion window写的明明白白):

动态追踪

内核分析,如果只靠走读代码,好比大海捞针,不仅容易抓不到点,不知道自己看到哪了,更无法知道自己看到的东西和自己的理解是否正确。因为client这里比较明确的推测大概率是cwnd的问题,我们先用动态的方法来验证我们的推测是否正确,然后再进一步走读代码。

我们先通过stap脚本,抓取3.10内核 client端在收ack时的cwnd变化,来验证之前观察到的server回ack少时,是不是导致client的窗口大小上不去。由于这里是直接抓取内核skb结构体的内存,粒度也是比wireshark分析来的要细也更直接。

附上stap脚本:

gro on时抓取结果:

gro off时抓取结果:

可以看到,在几乎同样的timestamp时,gro on时cwnd确实是要小的,这也是符合正常逻辑处理的,回包慢(频率小)导致对端认为通量不够,进而增大窗口不积极、上限低。

到这里,其实从解决方案上来讲,已经能给出开篇时的结论,调大cwnd来测试,观察是否可行。

但是原理上我们还没弄清楚,还没有足够的理论支撑来支持我们的解决方案,所以还需要继续深挖,现在问题的疑点就在,为什么4.14在这种情况下,依然能保持窗口的正常增长,而3.10不行。

因此我们来聚焦分析两个不同内核版本tcp_ack的细节差异,分析为什么4.14拥塞控制算法在开gro导致收ack少时也能调整cwnd到一个比较大的水平。

tcp_ack差异分析

由于我们需要重点关注拥塞控制部分,先简单介绍一下内核里tcp拥塞控制算法是如何调用的。

机器支持的拥塞控制算法和当前使用的拥塞控制算法,可以通过这些内核参数查看:

一般来讲,默认使用的算法都是cubic算法。

而这个cubic算法,是在内核代码的net/ipv4/tcp_cubic.c里实现的,通过内核模块的方式插入到内核中, 并通过内核协议栈提供的注册拥塞控制算法的方式将自己注册:

可以看到,注册的就是一组cubic调用接口,这样在tcp协议栈需要调用时就通过指针的方式调用(题外话:Linux内核里很多地方都是通过这种编程方式进行解耦!)。

而我们需要关注的主要就是cong_avoid方法,对应的是bictcp_cong_avoid(关于这个函数的部分相关细节,后面也会分别分析)。

清楚了以上知识,我们先看下3.10,重点关注拥塞控制的部分:

而对于4.14,这里整个代码的逻辑都不一样了:

注意到,4.14这里的代码逻辑和3.10完全不同,甚至tcp_cong_avoid都被封装到了tcp_cong_control里。

插曲:其实还有注意到,上面有个函数叫tcp_rate_gen也是新内核引入的,打开发现有这么一段内容,是根据ack和snd间隔的最大值,来调整下一次发送的interval_us,这个乍一看好像跟我们的case特别有关系,但实际上,这里的逻辑是说不通的。因为我们是开GRO导致ack delayed导致ack接收频率小,也就是在这里体现是ack接收间隔大,照理说interval_us应该会更大,这岂不是跟我们观察到的结论相反?是怎么回事呢。

继续分析代码,注意到,这里是更新的一个叫rs的数据结构,而这个rs是新内核引入的一个叫rate_sample的数据结构(不展开了)。

而这个rs被作为最后一个参数传给了tcp_cong_control。

而他传进来,可以看到,只有注册了cong_control函数的才的拥塞控制算法会用到,而我们前面看到,cubic是没有注册这个函数的!也就是说rs对于cubic在收ack这条路径上对cwnd是没有影响的!

回到正轨,继续看tcp_cong_control这个函数, 从名字就能看出,慢启动在需要增大cwnd时,会进入tcp_cong_avoid,进而调用对应拥塞控制算法的cong_avoid方法(这里就是cubic)。

3.10的bictcp_cong_avoid:

4.14的bictcp_cong_avoid:

可以看到,最显著的差距,就是引入了这个acked参数,接下来来分析看看这个acked是干什么的,先看下tcp_slow_start:

注意到注释里写的“handle stretch ACKs properly”以及可以防御ack攻击,3.10里头是没有这个功能的,而ack攻击和stretch ack正是和ack的频率有关,好像又抓到线索了?

从这里的代码来看,在慢启动的大部分情况(没到ssthresh之前),这个acked进来什么值出去什么值,而snd_cwnd是会被调大。

然后是bictcp_update,这个函数是用来进一步计算cwnd的,具体的算法比较复杂,有兴趣的可以参考cubic的论文:

https://www.cs.princeton.edu/courses/archive/fall16/cos561/papers/Cubic08.pdf

我们只关注到里面对于这个acked参数,最大的调整是:

这样无疑会对cwnd的算法造成一些改动。

继续来看tcp_cong_avoid_ai:

可以看到acked影响snd_cwnd_cnt(进入下面那个if的情况就是指数增长)。

那么分析了这么半天,传进来的这个acked是什么? 再回到tcp_ack中:

再看这个delivered代表什么:

delivered代表着送达的包,包括重传的。但是这里是计算差值,在慢启动的情况下,绝大多数情况应该都为1或以上,对cwnd的计算是有积极影响,且不会受ack频率影响。

而3.10的对应参数,则是计算了还有多少个发送中未收到的包:

但是这个in_flight,在进入cubic后并不会影响cong_avoid和tcp_slow_start(之前的差异里已经分析过):

因此无论如何,可以确认新内核引入的这个改动,对我们当前的场景是有优化作用的。

根据以上的改动,按图索骥从主线里找到了对应的commit。

看注释,已经很明确的说明了,这个提交就是为了修复GRO等带来的delayed ack会导致对每个包ack一来一回,而导致的性能下降的问题。

贴一下里面bictcp_update的改动(就不全贴了),正是我们刚才分析过的,加入了acked参数:

基本到这里,已经八九不离十的确定,就是这块改动修复的这个问题。

最后一步,就是再针对这块代码,做动态的实践验证,来确认我们的结论是正确的。

最终实践验证

这个实验目的是为了证明3.10和4.14的差异确实在我们刚才分析到的地方。

首先我们配置三台机器,一台server内核是4.14,两台client内核是3.10。

然后我们编写了server端和client端的测试程序,就是socket连接收发包,client端发送本地文件,通过定制本地文件来模拟不同的发包情况,而server端则做本次连接的总计耗时、每次read收包平均耗时统计(不直接用iperf的原因想必可以通过前文的分析找到答案)。

附上测试程序,该程序原版出自zorro大佬之手:

server.c:

	#include <sys/types.h>
	#include <sys/socket.h>
	#include <stdio.h>
	#include <stdlib.h>
	#include <string.h>
	#include <netinet/in.h>
	#include <netinet/ip.h>
	#include <arpa/inet.h>
	
	#define BUFSIZE 4096 
	
	int main(void)
	{
	        int sfd, afd, clilen, size;
	        struct sockaddr_in addr, cliaddr;
	        char buf[BUFSIZE];
	        char addrstr[INET_ADDRSTRLEN];
	        struct timeval start, end, st, ed;
	        long delta, count, total;
	
	        sfd = socket(AF_INET, SOCK_STREAM, 0);
	        if (sfd < 0) {
	                perror("socket()");
	                exit(1);
	        }
	
	        static int val = 1;
	        if (setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)) < 0) {
	                perror("setsockopt()");
	                exit(1);
	        }
	
	        bzero(&addr, sizeof(addr));
	        addr.sin_family = AF_INET;
	        addr.sin_port = htons(8888);
	        if (inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr) <= 0) {
	                perror("inet_pton()");
	                exit(1);
	        }
	
	
	        if (bind(sfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
	                perror("bind()");
	                exit(1);
	        }
	
	        if (listen(sfd, 10) < 0) {
	                perror("listen()");
	                exit(1);
	        }
	
	        clilen = sizeof(cliaddr);
	        while (1) {
	                count = 0;
	                total = 0;
	                afd = accept(sfd, (struct sockaddr *)&cliaddr, &clilen);
	                if (afd < 0) {
	                        perror("accept()");
	                        exit(1);
	                }
	                gettimeofday(&st, NULL);
	                bzero(buf, BUFSIZE);
	                if (inet_ntop(AF_INET, &cliaddr.sin_addr, addrstr, INET_ADDRSTRLEN) == NULL) {
	                        perror("inet_ntop()");
	                        exit(1);
	                }
	                while (1) {
	                        gettimeofday(&start, NULL);
	                        size = read(afd, buf, BUFSIZE);
	                        if (size <= 0) {
	                                break;
	                        }
	
	                        gettimeofday(&end, NULL);
	                        delta = (end.tv_sec - start.tv_sec) * 1000000 + (end.tv_usec - start.tv_usec);
	                        //printf("%ld\\n", delta);
	                        count ++;
	                        total += delta;
	                        /*
	                           if (write(1, buf, size) < 0) {
	                           perror("write()");
	                           exit(1);
	                           }
	                           */
	                        //printf("delta:%ld, size:%d\\n", delta, size);
	                }
	                gettimeofday(&ed, NULL);
	
	                delta = (ed.tv_sec - st.tv_sec) * 1000000 + (ed.tv_usec - st.tv_usec);
	                printf("avg:%ld us, count: %ld, total: %ld us\\n\\n", total/count, count, delta);
	                /*
	                   sprintf(buf, "IPaddr: %s, Port: %d, clilen: %d\\n", addrstr, ntohs(cliaddr.sin_port), clilen);
	
	                   if (write(afd, buf, strlen(buf)) < 0) {
	                   perror("write()");
	                   exit(1);
	                   }
	                   */
	                close(afd);
	        }
	        close(sfd);
	        exit(0);
	}

client.c:

	#include <sys/types.h>
	#include <sys/socket.h>
	#include <stdio.h>
	#include <stdlib.h>
	#include <string.h>
	#include <netinet/in.h>
	#include <netinet/ip.h>
	#include <arpa/inet.h>
	#include <sys/types.h>
	#include <sys/stat.h>
	#include <fcntl.h>
	
	#define PATH "./file"
	#define BUFSIZE 4096
	
	int main(void)
	{
	        int sfd, afd, clilen, ret, fd, size, count;
	        struct sockaddr_in addr, cliaddr;
	        char buf[BUFSIZE];
	        char addrstr[INET_ADDRSTRLEN];
	
	        fd = open(PATH, O_RDONLY);
	        if (fd < 0) {
	                perror("open()");
	                exit(1);
	        }
	
	        sfd = socket(AF_INET, SOCK_STREAM, 0);
	        if (sfd < 0) {
	                perror("socket()");
	                exit(1);
	        }
	
	        bzero(&addr, sizeof(addr));
	        addr.sin_family = AF_INET;
	        addr.sin_port = htons(8888);
	        if (inet_pton(AF_INET, "42.193.204.131", &addr.sin_addr) <= 0) {
	                perror("inet_pton()");
	                exit(1);
	        }
	
	        if (connect(sfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
	                perror("connect()");
	                exit(1);
	        }
	
	        while ((size = read(fd, buf, BUFSIZE)) > 0) {
	                ret = write(sfd, buf, size);
	                if (ret < 0) {
	                        perror("write()");
	                        exit(1);
	                }
	        }
	
	/*
	        ret = read(sfd, buf, BUFSIZ);
	        if (ret < 0) {
	                perror("read()");
	                exit(1);
	        }
	
	        ret = write(1, buf, ret);
	        if (ret < 0) {
	                perror("write()");
	                exit(1);
	        }
	*/
	        close(sfd);
	        close(fd);
	        exit(0);
	}

我们分别以128和32大小的包,发送4096次(因为业务场景也是小包多发,因此我们选了这种可能比较典型的场景),对应server端gro on/off的情况。

先看3.10作为client时:

可以看到,虽然没有业务表现的那么明显,但就我们自己的测试程序测出来来看,on确实也是比off耗时平均要稍多一丢丢。

而换做4.14 作为client:

可以看到gro on的耗时明显比off要低很多了。

下面开始最关键的表演,想必前面的伎俩通过stap抓取内核函数参数各位客官也看腻了(虽然根据之前的分析,抓取acked和in_flight的值也可以印证我们之前的分析,但还是没办法做到直接的数据对比来实锤),想看点新花样,因此我们现在来点更直接的操作,通过修改4.14的内核代码,把之前分析过的acked参数去掉,重新编译内核,来看看问题表现会不会跟3.10一样或者接近(因为两个版本的tcp协议栈差异确实也比较大,很难说是就完全回归3.10的表现,我们只是要确认,这个修复确实针对此情此景是有优化作用即可)。

根据commit修改代码,为了保险起见如果改动太大也无法验证问题,我们只修改upstream的这两个commit(前面也贴图了):

9cd981dcf174d26805a032aefa791436da709bee

e73ebb0881ea5534ce606c1d71b4ac44db5c6930

改动(commit里的图,我们的修改+-是相反的):

delayed ack相关:

acked变为原来的+1;

slow_start改回原来的模样;

acked改为原来的+1

注意这里我们并没有像3.10一样把delivered改成in_flight,因为正如前面分析的in_flight即便传进来也对后面这几个函数没有影响,我们要把改动的影响面降到最小,仅仅确定acked这个参数的引入对算法的影响即可。

然后编译、安装内核。

现在万事具备,就来最后一把测试:

包大小为128时:

果不其然,这里出现了gro off更快的现象。而且关键点是,对比修改之前,on是要慢了很多(之前是11左右),这也可以直接说明了该修改确实针对gro on的情况是有显著优化效果的,而不完全算是一个通用的优化。

这里在插一个抓包看到的窗口对比,更直观些:

修改后gro on慢下来的抓包窗口:

至于off也比之前快了一些,这个原因尚不明确,可能是我们的改动影响了其他什么机制,但他毕竟还是没有改之前的on快(不然这随手一改就实现了一个tcp的优化岂不是太离谱儿了),所以也同时说明了这个commit的优化对整体tcp的性能是有改善的(毕竟我们默认都是gro on,gro本身就是一个优化机制,通过gro off才能优化这种现象本来也说不过去)。

再看下包大小为32时:

可以看到,虽然两者差异并不明显,但也没有之前gro on明显要快的现象了。

综合上述实验结果,我们的理论也得到了完美的支撑。到这里,client侧的分析,也终于是理论结合实践全方面分析到位了。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
3.10内核TCP慢启动耗时问题分析——拥塞控制算法
TCP使用多种拥塞控制策略来避免雪崩式拥塞。TCP会为每条连接维护一个“拥塞窗口”来限制可能在端对端间传输的未确认分组总数量。这类似TCP流量控制机制中使用的滑...
<<上一篇
下一篇>>