本文能够做为《Linux转发性能评估与优化(转发瓶颈分析与解决方案)》 的姊妹篇,这两篇文章结合在一块儿,刚好就是整个Linux内核协议栈的一个优化方案。事实上Linux协议栈原本就是面向两个方向的,一个是转发,更多的 是本地接收。目前大量的服务器采用Linux做为其载体,更加体现了协议栈本地处理相对于转发的重要性,所以本文就这个问题扯两句,欢迎拍砖!
算法
昨天就答应皮鞋厂老板了,只是昨晚心情太复杂,本文没有赶出来,今天在飞机上写下了剩余的...
编程
本文假设读者已经对Linux的TCP实现源码有了足够清晰的理解,所以不会大量篇幅分析Linux内核关于TCP的源代码,好比tcp_v4_rcv的流程之类的。缓存
因为涉及到不少复杂的因素,本文不提供完整的可编译的优化源码(整个源码并不是我一人完成,在未经同伙赞成以前,我不敢擅做主张,可是想法是个人,因此我能展现的只是原理以及我负责的那部分代码)。性能优化
本文不谈TCP协议自己以及其细节(细节请参考RFC以及各种论文,好比各类流控,拥控算法之类的),仅包含的内容是TCP协议以外的框架实现的优化。服务器
TCP首先会经过三次握手创建一个链接,而后就能够传输数据了。TCP规范并无指定任何的实现方式,当前的socket规范只是其中一种而已。Linux实现了BSD socket规范。
在Linux中,TCP的链接处理和数据传输处理在代码层面是合并在一块儿的。
数据结构
这个比较简单,略。
架构
Linux内核在软中断环境中进行协议栈的处理,在这个处理流程的最上方,会有3个分支:直接将skb复制到用户缓冲区,简单将skb排入到prequeue队列,简单将skb排入backlog队列。
并发
Linux的socket API的处理是在用户进程上下文中进行的。经过1.1节,咱们知道因为代码层面上这些都是合并在一块儿的,所以一个socket会被各类执行流操做,直观的考虑,这须要大量锁的开销。
负载均衡
我给出一个链接处理整体框图,其中红线表示发生竞争的地方,而正是这些地方阻止了TCP链接的并行处理,图示以下:
框架
我来一一解释这些红线的意义:
因为用户进程和协议栈操做的是同一个socket,若是用户进程正在copy数据包数据,那么协议栈就要中止一样的操做,反过来也同样,所以须要暂时锁定该socket,然而这种大锁的开销过于大,所以Linux内核协议栈的实现采用了一个更加优雅的方式。
协 议栈锁定socket:因为软中断处理协议栈,它可能运行在硬中断以后的任意上下文,所以不能睡眠,故而必须是一把自旋锁slock,由socket自己 保有,该锁不只仅保护和用户进程之间的竞态,也保护不一样CPU上对同一个socket协议栈操做之间的竞态(很常见,一个侦听socket上能够同时到达 不少链接请求[可悲的是,这些请求不能同时被处理!!])。
用户进程锁定socket:用户进程是能够随时睡眠的,所以能够采用非 自旋锁xlock来保护多个进程之间的竞态,然而同时又为了和内核协议栈操做同一个socket的软中断互斥,所以在获取xlock以前,首先要获取该 socket的slock,当获取xlock以后或者暂时没有得到xlock要睡眠的时候,将slock释放掉。相关的逻辑以下:
stack_process { ... spin_lock(socket->slock); //1 process(skb); spin_unlock(socket->slock); ... } user_process { ... spin_lock(socket->slock); //2 while(true) { ... spin_unlock(socket->slock); 睡眠; spin_lock(socket->slock); //2 if (占据xlock成功) { break; } } spin_unlock(socket->slock); ... }
可见,Linux采用了以上的方式很完美的解决了两类问题,第一类问题是操做socket的执行流之间的同步与互斥,第二类问题时软中断上下文和进程上下文之间的锁的不一样。 在理解了socket锁定以后,咱们来看下backlog这个队列是干什么的。其实很简单,就是将skb推到当前正占据socket的那个进程的一个队列 里面,等到进程完成任务,准备释放socket占有权的时候,若是发现该队列里面有skb,那么在其上下文中处理它们。这其实是一种职责转移,这个转移 也能够带来一些优化效果,那就是直接在socket所属的用户进程上下文处理skb,这样就避免了一部分cache刷新。
这 条线在1号红线的解释中已经涉及了,主要就是上述代码逻辑中的1和2之间的竞争。这个竞争不是最激烈的,本质上它们属于纵向的竞争,一个内核态软中断和一 个进程上下文之间的竞争,在同一个CPU上,通常而言,这类竞争的几率很低,由于同一个CPU同时只能执行一个执行流,假设此时它在内核态执行软中断,那 么用户态的进程,它必定在睡眠或者被抢占,好比在accept中睡眠。
用户态处理和内核态处理,这种纵向的竞争在单CPU上几乎不会发生,而用户态的xlock根本就是为了解决用户进程之间的竞争,内核经过一个 backlog在面对这种竞争时转移了数据包处理职责,事实上在xlock上并不存在竞争,backlog的存在反而带来了一点优化效果。
该 红线为了解决多个用户进程之间的竞争。之因此画出的是TCP链接处理图而不是数据传输处理图,是由于链接图更能体现问题,在服务器端,一个主进程fork 出来N多的子进程或者建立多个线程同时在一个继承下来的socket上accept,这几乎成了服务器设计的准则,那么这多个进程/线程同时到达这个 slock的时候,争抢就会很激烈。再看3'号红线,咱们发现针对同一个socket的accept必须排队进行。
若是你看Linux TCP的实现,你会发现大量的数据结构操做都没有用锁,事实上,进入到那里的,只有你一人,早在入口就排队了。
在单CPU上,这不会产生什么后果,由于单CPU即使在最早进的分时抢占调度器的协调下,本质上也是一个排队模型,充其量能够插队罢了,TCP实现的slock和xlock只是规范了这个排队规则而已。然而在多CPU上,仔细想一想这有必要吗?
说 完了用户态多个进程/线程同时在同一个socket上accept时的排队,如今看看内核协议栈的处理,也好不到哪里去。若是多个CPU同时被链接请求中 断,大量的请求针对同一个侦听socket,那么你们就要在4号红线处排队!而这个状况在单CPU时几乎并不存在,根本不可能同时有多个CPU执行到同一 个地点...
经过以上针对几条红线的描述,到此为止,我已经 展现了几个瓶颈,这些点都是要排队的点。以上的那个框图事实上很是好,这些排队点也无可厚非,由于设计一个系统,就是要自包容解决全部问题,竞争只要存 在,就必定要经过排队来解决它,所以上述的框架根本不用更改,锁仍是留在原处。问题的根本不是锁的存在,问题的根本在于锁的不易获取!
TCP的处理运行在各个CPU上,CPU被看成了一种资源,这是操做系统的核心概念,可是反过来呢?若是把CPU当成服务者自己,而socket当成资 源,问题就迎刃而解了。这个思路很重要,我在nf-HiPAC中第一次接触到了它,原来能够把match和rule颠倒过来玩,而后以这个思想作指导我设 计了DxR Pro++,如今仍是这个思想,TCP的优化依然能够这么作。
总体考虑一种链接处理被优化后的TCP实现,我只关心从accept API中会返回什么。只要我能快速获取一个客户socket,而且不破坏listen,bind这些API自己,修改其实现是必须的。
我将一个listen socket拆分红了两个部分,上半部和下半部,上半部对应用户进程,下半部对应内核协议栈。原始的socket是下图的样子:
个人socket被改为了下面的样子:
一个socket的上半部和下半部只经过一个accept队列关联,事实上即便在上半部正在占有socket的时候,下半部依然能够继续处理。
然而,事实证实,在执行accept的进程绑定CPU的状况下,1号红线的消解并无预期的性能提高,而2号红线的消解带来的性能提高影响也不大。若是是不绑定CPU,性能提高反而比绑定更好,看来横向和纵向并非独立的两块,它们会相互影响。
横 向拆分的思路就是将一个socket下半部拆成多个,每个CPU上强制绑定一个,相似softirqd每个CPU上一个同样,这样能够消解4号红线。 可是为什么不拆解socket的上半部呢?我一开始的想法是让进程自行决定,后来以为一样也要强行绑定一个,至于说什么用户进程能够自行解除绑定之类的,加 一个层次隐藏掉Per CPU socket便可。此时的用户进程中的文件描述符只是指示一个socket描述符,而该描述符真正指向的是nr_cpus个Per CPU socket,以下图所示:
这样3号红线,3'号红线,4号红线所有消解。事实上,看到这里好像一切都大功告成了,可是测试的时候,发现还有两个问题,接下来我会在描述数据结构的拆解中描述这两个问题。
因为Listener会复制nr_cpus份到每个CPU上,故而全部的Listeners会被加入到每个CPU的本地哈希表中,这种以空间换无锁并行是值得的,由于服务器上并不会出现海量的侦听服务。
如 果系统中每个CPU上都绑定有accept进程,那么能够保证全部的链接请求都会被特定的进程处理,然而若是有一个CPU上没有绑定任何accept进 程,那么被排队到该CPU的Accept队列的客户socket将不会返回给任何进程,从而形成客户socket饿死。
所以引入一个全局Accept队列。相关的代码逻辑以下:
stack_enqueue_socket { if (本CPU上没有绑定任何与该Listener相关的用户进程) { spin_lock(g_table->slock); enqueue_global(g_table, cli_socket); spin_unlock(g_table->slock); } else { local_irq_save enqueue_local(per_cpu(table), cli_socket); local_irq_restore } } user_dequeue_socket_for_accept { if (当前进程没有绑定到当前CPU) { spin_lock(g_table->slock); cli_socket = dequeue_global(g_table); spin_unlock(g_table->slock); } else { local_irq_save cli_socket = dequeue_local(per_cpu(table)); local_irq_restore } return cli_socket; }
事实上,能够看到,全局的Accept队列是专门为那些顽固不化的进程设置的,可是仍是能够以一把小锁的代价换来性能提高,由于锁的粒度小多了。
由 于每个CPU上绑定了一个Listener socket的下半部,而且几乎全部的数据结构都是本地维护的,CPU正式成了TCP的一部分。所以必须保证一件事,那就是3次握手必须由一个CPU处 理,不然就会出错。而因为现在不少启动了irqbalance的系统,中断可能会分发到不一样的CPU,好比来自特定客户端的SYN被CPU0处理,而3次 握手中的ACK则被CPU1处理,这就形成了错误。为了不这个局面,底层必须维护数据流和CPU之间的映射。这个能够经过RFS的技术来解决。
当第一个SYN包到达之时,处理这个链接握手过程的CPU就肯定了,就是当前的CPU,这个和用户态的进程“跳”到哪一个CPU上没有任何关系。所以实现比RFS要简单得多。代码逻辑以下:
netif_receive_skb { ... hash = myhash(skb) cpu = get_hash_cpu(hash); if (cpu == -1) { record_cpu_hash(hash, cpu); } else if (current_cpu != cpu) { enqueue_to_backlog(skb, cpu); 此后由IPI触发中断,被别的CPU处理 } else { 正常的接收逻辑 } ... }
示意图以下:
我 的这个优化版本和REUSEPORT没有任何关系,REUSEPORT是google的一个patch,很是好用,在咱们的一个产品中就用到了这个技术, 它能够作到在多个侦听相同IP地址和端口的不一样socket之间作到负载均衡,然而这个负载均衡的效果则彻底取决于hash算法。事实上Sina的 fastsocket就是在REUSEPORT之上作的优化,其主要作了CPU Affinity方面的优化,效果很不错。类似的优化还有RPS/RFS patch,然而fastsocket更进一步,它不光延续了RPS/RFS的成果,并且把链接性能瓶颈也解决了,其主要的作法就是采用CPU绑定技术对 一个Listen socket在REUSEPORT的基础上作了横向拆分,复制了多个Listen socket来reuseport。
fastsocket的作法是用户进程本身决定是否要绑定CPU,所以哪一个CPU处理哪些数据包是经过用户进程设置的,而个人作法则正好相反,个人优化从 下到上,我是由第一个SYN包刚好中断的那个CPU做为其握手过程后续被处理的CPU,和用户进程的设置无关,即使用户进程没有绑定CPU,它也充其量只 是从全局Accept队列小代价取客户socket而已,而若是绑定了CPU,那几乎是全线无锁操做。结合3.5小节,咱们看下fastsocket是怎 么得到对应处理该数据包的CPU的:
netif_receive_skb { ... socket = inet_lookup(skb); cpu = socket->sk_affinity; if (current_cpu != cpu) { enqueue_to_backlog(skb, cpu); 此后由IPI触发中断,被别的CPU处理 } else { 正常的接收逻辑 } ... }
fastsocket有一个查socket表的操做,若是是链接处理,对于绑定CPU的socket,能够在本地表 中获取到。若是你以为fastsocket在这里平添了一次查找,那你就错了,事实上这是fastsocket的另外一个优化点,即Direct TCP,也就是说在这个位置就查找具体的socket,连路由之类的都放进去,后续的全部查找结果均可以放入,也就能够实现一次查找,屡次使用了。
Direct TCP的处理看似不符合协议栈分层处理的规则,在如此的底层处理四层协议,事实上在某些状况也会所以而付出性能代价:
1).若是有大量的包是转发的呢?
2).若是有大量的包是UDP的呢?
3).若是是***包呢?
4).若是这些包是接下来要被Netfilter刷掉的呢?
...
这有点像另外一个相似的Direct技术,即socket的busy poll。想得到甚高的性能,你必须对你的系统的行为足够理解,好比你知道这台服务器就是专门处理TCP代理请求的,那么开启Direct TCP或者busy poll就是有好处的。
而我认为个人这个优化方案是一个更加通用的方案,实际上只有单点微调,且不会涉及别的状况,只要你能保证数据包到达的中断行为是优雅的,后面的处理都是水 到渠成的,彻底自动化。就算是面对了上述4种非正常数据包,一个hash计算的代价也很小,且不须要面临RCU锁的内存屏障致使的刷流水问题。现现在,随 着多队列网卡以及PCI-E MSI的出现和流行,有不少技术能够保证:
1).同一个元组标识的流的数据包到达后老是中断同一个CPU;
2).处理不一样的流的CPU上的负载很是均衡。
只要作到以上2点,其它的事情就不用管了,对于TCP的链接包,数据包会在同一个CPU一路向上一路无锁到达本地Accept队列或者全局Accept队列,接下来会转向另一条一样宽拥有一样多车道的高速公路,这条新的公路由进程操纵。
最终,优化过的TCP链接处理框图以下:
PS:个人这个方案不须要修改应用程序,也无须连接任何库,彻底替换内核便可。目前对fastopen并不支持。
对于TCP的非握手包的处理,应该怎么优化呢?
以 前,咱们觉得TCP典型的C/S处理的服务端是一个一对多的模型,所以listen+accept+fork这种流行的socket编程模型几乎一致持续 到如今。期间衍生出各类MPM技术。这种模型自己就让人以为TCP的链接处理就是那个样子,自己就有一个瓶颈在那里!事实上,无论是 fastsocket,仍是个人这个方案,都解决了一对多问题,模型成了多对多的。
那么对于TCP的数据传输处理,它该怎么优化呢?要知道,它可不是一对多的模型,它是一个自然的一对一的模型,而TCP又是一个严格按序的协议,为了保序,采用并行化处理是一个愚蠢的想法。为了优化它,咱们先给出可能的瓶颈在哪里。
因为TCP链接中会有大量的数据传输,所以内存拷贝是一个瓶颈,然而本文不关注这个,这个能够经过各类技术解决,好比DMA,零拷贝,分散/汇集IO等,本文关注的是CPU亲和力的优化。另外,考虑到这种ESTABLISHED状态的一对一链接的数量还有对其的操做。
考虑持续到来的短链接,大量的并发,咱们来看下内核中ESTABLISHED哈希表的压力
1).持续的新建链接(握手完成,客户socket会插入表中),锁表,排队;
2).持续的链接释放,锁表,排队;
3).大量的链接同时存在,大量的TIME_WAIT,查询延迟且连累正常链接;
4).TIME_WAIT socket也面临持续新建,释放的问题。
...
若是是长链接,可能会缓解,但病根不除,不绝后患。
因为这个是一对一的对称模型,TCP的按序性很差作并行化本地操做,所以我也就没有对其进行横向拆分,没了横向拆分,对于纵向拆分也就没有了意义,由于势必会出现多个CPU对队列的争抢问题。所以与链接处理的优化原则彻底不一样。
如何优化?目标已经很明确,那就是本地化查询,本地化插入/删除,尽可能减小RCU的内存屏障的影响。
和链接处理不一样,咱们无法把CPU当成主角,由于一条链接的处理同一个时刻只能在一个CPU上。所以为了加速处理而且维护cache,创建两条原则:
1).增长一个ESTABLISHED表的本地cache,该cache能够无锁操做;
2).在用户进程等待数据期间,禁止进程迁移。
个人逻辑以下,事实证实真的不错:
tcp_user_receive/poll { 0.例行RPS操做 1.设置进程为不可迁移 disable中断 2.将socket加入当前CPU的本地ESTABLISHED表 (优化点1:若是本地表没有才加入) enable中断 3.sleep_wait_for_data_or_event 4.读取数据 disable中断 5.将socket从本地ESTABLISHED表中摘除 (优化点1:不须要摘除) enable中断 6.清除不可迁移位 } tcp_stack_receive { 1.例行tcp_v4_rcv的操做 2.查找当前CPU的本地ESTABLISHED表 没有找到的状况下,查找系统全局的ESTABLISHED表 3.例行tcp_v4_rcv的操做 }
很显然,上述的逻辑基于如下的判断:既然进程已经在一个CPU上等待数据了,最好的方式就是将这个进程暂时钉死在这个 CPU上,而且告知内核协议栈,将软中断上来的数据包交给这个CPU。钉死这个进程是暂时的,只为接收一轮数据,这个显然要在task_struct中增 加一个flag或者借一个bit,而通知内核协议栈的软中断例程则是经过将该socket加入到本地的ESTABLISHED缓存哈希表中实现的。
在进程得到了一轮数据以后,为了避免影响Linux系统全局的域调度行为(Linux的域调度能够很好的作到进程的负载均衡同时又不破坏cache的热 度),须要将钉死的进程释放,同时注意优化点1,到底需不须要将socket从本地表摘除呢?鉴于进程迁移通常都是小几率事件,因此进程在至关大的几率下 是不会迁移的,所以不摘除socket能够省下一些mips。
一对一的TCP socket数据传输处理和本文第一部分描述的链接处理是没有关系的,accept返回以后,由哪一个进程/线程处理这个客户socket,是否绑定它到特定的CPU,这些都不会影响链接处理的过程。
事 实上,因为涉及到数据拷贝,内核协议栈一直都是倾向于让用户进程本身来解析,处理一个skb,而内核要作的只是将skb挂在某个队列中,这是一个很是常规 的优化。Linux协议栈中存在两个队列,prequeue和backlog,前者是当协议栈发现没有任何一个用户进程占据某个socket的时候,其尝 试将skb挂入一个用户prequeue队列,当用户进程调用recv的时候,自行dequeue并处理之,后者则是当协议栈发现当前socket被某个 用户进程占据的时候,将skb挂入backlog队列,待用户进程释放该socket的时候,自行dequeue并处理之。
这其实是软件模拟的RSS相似的东西,资料已经很多,其宗旨就是尽可能让协议栈处理的CPU和用户进程处理的CPU是同一个CPU,咱们知道,对于链接处理,因为是一对多的关系,这样并不可行,然而对于一对一的客户socket,这样的效果很是好。
Linux协议栈指望若是在确认一个数据流的第一个数据包是本地接收的前提下,免除后续路由查找,只实现socket查找便可。这是一种典型的一次查找,短路使用的优化方案,相似的还有一次查找,多方屡次使用,相似于我对nf_conntrack所作的那样。
顾名思义,就是不等协议栈往上送skb,而是本身直接去底层拿,有点像快递自提那种。然而因为在底层没有通过协议栈处理,根本就不肯定这个数据包是否是发给本身的,所以这会有必定的风险,可是若是在统计意义上你能肯定本身系统的行为,那么也是能够起到优化效果的。
如 果你把一个Listener仅仅看做是一个被内核协议栈处理的socket,那么你很容易找到不少值得优化的点,好比这里把锁拆分红粒度更细的,那里判断 一下抢占什么的,而后代码就会愈来愈复杂和庞大。可是反过来,若是你把一个Listener看做是一个基础设施,那事情就简单多了。基础设施是服务于客户 的,它有两个基本的性质:
1).它永远在那里
2).它不受客户的控制
对于一个TCP Listener,难道它不应是一个基础设施吗?它应该和CPU是一伙的,做为资源而后为链接请求服务,它本不该该是抢CPU的家伙,不该该抱有“它如何才能抢到更多的CPU时间,如何才能拽取其它Listener的资源....”这样的想法去优化它。
Linux内核中有不少这样的例子,好比ksoftirqd/0,ksoftirqd/0/1,像这种kxxxx/n这类内核线程均可以看做是基础设施, 在每个CPU上提供相同的服务,不干涉别的CPU,永远都是取Per CPU的管理数据。按照这样的想法,当须要提供一个TCP服务的时候,很容易想法创建一个相似的基础设施,它将永远在那里,每个CPU上一个,为新到来 的链接提供链接服务,它服务的产品就是一个客户socket,至于说怎么交给进程,经过两类Accept队列提供一个服务窗口!咱们假设一个侦听进程或者 线程挂掉了,这丝绝不会影响TCP Listener基础设施,它的进程无关性继续为其它的进程提供客户socket,直到全部的侦听进程所有挂掉或者主动退出,而这很容易用引用计数来跟 踪。
在这个优化版本中,你能够这样想,从两类Accept队列往下,TCP Listener基础设施与用户的侦听进程无关了,不会受到其影响,即使用户侦听进程不断在CPU间跳跃,绑定,解除绑定,它们影响不到Accept队列 下面的TCP Listener基础设施,受影响的也只是它们本身从哪一个Accept队列里获取客户socket的问题。下面是一个简图:
一样,因为TCP Listener已经基础设施化了,向上用Accept队列隔离了用户进程socket,向下用中断调度系统隔离了网卡或者网卡队列,所以也就没有了不少优化版本中面临的四种状况:队列比CPU多,队列与CPU相等,队列比CPU少,根本就没有队列。
本 文针对这个方面说的不是不少,很大一部分缘由在于这部分并非核心瓶颈,Linux自己的调度系统已经能够很好的应对进程切换,迁移对cache的影响, 所以,你会发现,我作的仅有的优化也是针对这两方面的,同时,增长一个本地ESTABLISHED哈希缓存会带来小量的性能提高,这个想法来自于我增长的 nf_conntrack的cache以及slab分级cache。
前段时间搞过一个Linux转 发性能优化,当时引入了一个虚拟输出队列(VOQ),解决加速比的问题,为了实现线速pps,转发的状况存在网卡带宽的N加速比问题,所以当时建立转发基 础设施的时候是把网卡拉过来搭伙合做,而不是CPU,事实上,若是在多队列网卡上,这两者是彻底一致的,网卡队列能够绑定到CPU,而且还能够一个CPU 专门处理输入,一个专门处理输出。
配合Per CPU的skb pool(我举的是卡车运货的例子),每个Listener在每个CPU上都有一个skb pool,这样对于本地出发的数据包,能够作到无锁化的分配skb。
如今针对TCP的状况咱们能够把转发和本地接收统一在一块儿了,对于转发而言,出口是另外一块网卡,对于本地接收而言,出口是Accept队列(握手包)或者 用户缓冲区(传输包)。若是出口处没有用户进程,假设不是放入队列,而是直接丢弃,那么Per CPU的Accept队列真的就是一块网卡了。
本文受朋友之托,答应了就写了,决不藏着掖着,今天在飞机上装X了一路,15A是个大妈在看杂志,15C是一个少女,跟我搭了几句话我就没再理她。写着写着又想到一些优化,然则总不能飞机上debug吧,只能loopback啦...温州老板欠我一首《卡农》!!!