责编 | 郭芮
出品 | CSDN 博客
现在很多人都在诟病Linux内核协议栈收包效率低,不管他们是真的懂还是一点都不懂只是听别人说的,反正便是在一味地怼Linux内核协议栈,他们的武器貌似只有DPDK。

但是,即便Linux内核协议栈收包效率真的很低,这是为什么?有没有办法去考试测验着优化?而不是动不动就DPDK,一窝蜂跟上去的想法,大部分都是很low的想法。
再次重申,我不写技能文档,我也不剖析源码,本文只是一个思考的总结,但凡是思考,那一定是主不雅观的,不图精确,乃至不图精确。
我们从最开始提及。
Linux内核作为一个通用操作系统内核,脱胎于UNIX那一套当代操作系统理论。但一开始不知道怎么回事将网络协议栈的实现塞进了内核态,从此它就一贯在内核态了。既然网络协议栈的处理在内核态进行,那么网络数据包一定是在内核态被处理的。
无论如何,数据包要前辈入内核态,这就涉及到了进入内核态的办法:
外部可以从两个方向进入内核-从用户态系统调用进入或者从硬件中断进入。
也便是说,系统在任意时候,一定处在两个高下文中的一个:
进程高下文;
中断高下文(在非中断线程化的系统,也便是任意进程高下文)。
收包逻辑的协议栈处理显然是自网卡而上的,它显然是在中断高下文中,而数据包往用户进程的数据吸收处理,显然是在运用程序的进程高下文中, 数据包通过socket在两个高下文中被转接。
在socket层的数据包转接处,一定存在着一个行列步队缓存,这是一个范例的生产者-消费者模型,中断高下文的终点作为生产者将数据包入队,而进程高下文作为消费者从行列步队消费数据包:
非常清爽的一个图,这个图是两个高下文接力处理协议栈收包逻辑的一定结果,让我们加入一些实际必须要考虑的问题后,我们会创造这幅图并不是那么清爽,然后再回过分看如何来优化。
既然两个高下文都要在任意可能的时候操作同一个socket进行数据包的转交,那么必须有一个同步机制保护socket元数据以及数据包skb本身。
由于Linux内核中断,软中断可能处在任意进程高下文,唯一的同步方案险些便是spinlock了,于是,真正的图示该当是下面的样子:
现在可以说,类似上面的这种这种保护是非常必要的,特殊是对付TCP而言。
我们知道,TCP是基于事务的有状态传输协议,而且携带繁芜的流控和拥塞掌握机制,这些机制所依托的便是socket当前的一些状态数据,比如inflight、lost、retrans等等,这些状态数据在发包和吸收ACK/SACK期间会不断变革,以是说:
在一个高下文完成一次事务传输之前,必须锁定socket状态数据。
比方说发包流程。数据包的发送可以涌如今两个高下文中:
进程高下文:系统调用触发的发包;
中断高下文:ACK/SACK触发的发包。
任何一个高下文的发包过程必须被TCP协议本身比如拥塞掌握,流量掌握这些所终止,而不能被中途切换到另一个高下文中,以是必须锁定。
问题是,上图中的锁定是不是太狠了些,中断高下文自旋韶光完备取决于进程高下文的行文,这不利于软中断的快速返回,极大地降落了系统的相应度。
于是,须要把锁的粒度进行细分。Linux内核并没有在横向年夜将锁的粒度做划分,而是在纵向上,采取两个层次的锁机制:
我们看到的Linux内核在处理收包逻辑时的backlog,实在抽象出来便是上面的二级锁,它是不是很像Windows的IRQL机制呢?伴随着APC、DPC,你可以把暂时由于高level的IRQL阻滞而无法实行的逻辑放入DPC:
由于进程高下文对socket的low锁霸占,中断高下文将skb排入次level的backlog行列步队,当进程高下文开释low锁的时候,顺序实行次level被排入的任务,即处理backlog中的skb。
事实上这是一种非常常见且通用的设计,除了Windows的IRQL,Linux中断的上半部/下半部也是这种基于思想设计的。
前面说了,TCP的一次事务可能非常繁芜耗时,并且必须一次完成,这意味着期间必须持有socket low锁。以发包逻辑 tcp_write_xmit 函数为例,其内部循环发包,直到受到窗口限定而终止,每一次tcp_transmit_skb返回耗时3微秒~5微秒,均匀4微秒,以每次发送4个包为例,在这期间,若利用spinlock,那么中断高下文的收包路径将自旋16微秒,16微秒对付spinlock而言有点久了,于是采取两级的lock机制,非常有效!
backlog行列步队机制有效降落了中断高下文的spin时延,提高了系统的相应度,非常不错。但问题是,UDP有必要这样吗?
首先,UDP是无状态的,收包和发包都无需事务,协议栈对UDP的处理,从来都是单个报文粒度的,因此只须要保护唯一的socket吸收行列步队即可,即 sk_receive_queue 。
enqueue(skb, sk){spin_lock(sk->sk_receive_queue->lock);skb_queue_tail(sk->sk_receive_queue, skb);spin_unlock(sk->sk_receive_queue->lock);}sk_buff dequeue(sk){spin_lock(sk->sk_receive_queue->lock);skb = skb_dequeue(sk->sk_receive_queue);spin_unlock(sk->sk_receive_queue->lock);return skb;}
须要保护的吸收行列步队操作区间都是指令级别的时延,采取一把单一的 sk_receive_queue->lock 足矣。
确实,在Linux 2.6.25版本内核之前,便是这么干的。而自从2.2版本内核,TCP就已经采取二级锁backlog行列步队了。
然而,在2.6.25版本内核中,Linux协议栈的UDP收包路径,转而采取了两层锁的backlog行列步队机制,和TCP一样的逻辑:
low_lock_lock(sk){spin_lock(sk->higher_level_spin_lock); // 热点!
sk->low_lock_owned_by_process = 1;spin_unlock(sk->higher_level_spin_lock);}low_lock_unlock(sk){spin_lock(sk->higher_level_spin_lock);sk->low_lock_owned_by_process = 0;spin_unlock(sk->higher_level_spin_lock);}udp_rcv(skb) // 中断高下文{sk = lookup(...);spin_lock(sk->higher_level_spin_lock); // 热点!
if (sk->low_lock_owned_by_process) {enqueue_to_backlog(skb, sk);} else {enqueue(skb, sk);// 见上面的伪代码update_statis(sk);wakeup_process(sk);}spin_unlock(sk->higher_level_spin_lock);}udp_recv(sk, buff) // 进程高下文{skb = dequeue(sk); // 见上面的伪代码if (skb) {copy_skb_to_buff(skb, buff);low_lock_lock(sk);update_statis(sk);low_lock_unlock(sk);dequeue_backlog_to_receive_queue(sk);}}
显然这非常没有必要。如果你有多个线程同时操作一个UDP socket,将会直面这个热点,但事实上,你很难遭遇这样的场景,如果非要说一个,那么DNS做事器可能首当个中。
之以是在2.6.25版本内核引入了二级锁backlog行列步队,大致是考虑到UDP须要统计内存全局记账,以防UDP吃尽系统内存,可以review一下 sk_rmem_schedule 函数的逻辑。而在2.6.25版本内核之前,UDP的内存利用是不记账的,由于UDP本身没有任何类似流控,拥塞掌握之类的约束机制,很随意马虎被恶意程序将系统内存吃尽。
因此,除了sk_receive_queue须要保护,内存记账逻辑也是须要保护的,比如累加当前skb对内存的占用到全局数据构造。但即便如此,把这些统计数据的更新都塞入到spinlock的保护区域,也还是要比两级lock要好。
在我看来,之以是引入二级锁backlog机制来保护内存记账逻辑,这是在 借鉴 TCP的代码,或者说 抄代码 更直接些。这个携带backlog行列步队机制的UDP收包代码存在了好多年,一贯在4.9内核才闭幕。
事实上,仅仅下面的逻辑就可以了:
enqueue(skb, sk){spin_lock(sk->sk_receive_queue->lock);skb_queue_tail(sk->sk_receive_queue, skb);update_statis(sk);spin_unlock(sk->sk_receive_queue->lock);}sk_buff dequeue(sk){spin_lock(sk->sk_receive_queue->lock);skb = skb_dequeue(sk->sk_receive_queue);update_statis(sk);spin_unlock(sk->sk_receive_queue->lock);return skb;}udp_rcv(skb) // 中断高下文{sk = lookup(...);spin_lock(sk->higher_level_spin_lock);enqueue(skb, sk);// 见上面的伪代码spin_unlock(sk->higher_level_spin_lock);}udp_recv(sk, buff) // 进程高下文{skb = dequeue(sk); // 见上面的伪代码if (skb) {copy_skb_to_buff(skb, buff);}}
大略直接!
Linux内核的UDP处理逻辑在4.10版本也确实去掉了两级的lock,规复到了2.6.25内核版本之前的逻辑。
上面的优化带来了可不雅观的性能收益,但是却并不值得炫耀。由于上面的优化更像是办理了一个bug,这个bug是在2.6.25版本内核由于借鉴TCP的backlog实现而引入的,而事实上,UDP并不须要这种花哨的backlog逻辑。以是说,上面的效果并非优化而带来的效果,而是解了一个bug带来的效果。
但是为什么迟至4.10版本才创造并办理这个问题的呢?
我想这件事可能跟QUIC有关。用得少的逻辑自然就不随意马虎创造问题,这就好比David Miller在2.6版本内核引入IPv6实现的那几个bug,便是由于IPv6用的人少,以是一贯在很晚的4.23+版本内核才被创造被办理。对付UDP,一贯到2.6.24版本实在现都挺好,符合逻辑,2.6.25引入的二级锁bug同样是由于UDP本身用的少而没有被创造。
在QUIC之前,很少有那种有来有回的持续全双工UDP长连接,基本都是request/response的oneshot类型的连接。然而QUIC却是类似TCP的全双工协议,在数据发送端持续发送大块数据的同时,伴随着的是吸收大量的ACK报文,这显然和TCP一样,也是一种反馈掌握的办法来驱动数据的发送。
QUIC是有确认机制的,但是处理确认却不是在内核进行的,内核只是一个快速将确认包收到用户态QUIC处理进程的一个通路,这个通路越快越好!
也便是说,QUIC的ACK报文的吸收效率会影响其数据的发送效率。
随着QUIC的大规模支配,人们才开始逐渐关注其背后UDP的收包效率问题。摆脱了二级锁的backlog行列步队之后,仅仅是为UDP后续的优化扫清了障碍,这才是真正刚刚开始。摆在UDP的内核协议栈收包效率面前的,有一个现成的靶子,那便是DPDK。
挺烦DPDK的,说实话,被人每天说的东西都挺烦。不过你得先把内核协议栈的UDP性能优化到靠近DPDK,再把这种鄙视当后话来讲才更酷。
由于UDP的处理非常大略,因此实现一个能和DPDK对接的UDP用户态协议栈则并不是一件难事。而TCP则相反,它非常繁芜,以是DPDK很少有完全处理TCP端到端逻辑的,大多数都只是做类似中间节点DPI这种事。目前都没有几个好用的基于DPDK的TCP实现,但是UDP实现却很多。
DPDK的伪粉丝拿UDP说事的,比拿TCP说事,本钱要低很多。好吧,那为什么DPDK处理UDP收包效率那么高?答案很大略, DPDK是在进程高下文轮询吸收UDP数据包的!
也便是说,它摆脱了两个问题:
进程高下文和中断高下文操作共享数据的锁问题;
进程高下文和中断高下文切换导致的cache miss问题。
这两点实在也便是 “为什么内核协议栈性能干不过用户态协议栈” 的要点。当然,Linux内核协议栈无法摆脱这两点问题,也就回答了本文的题目中的第一个问题, “Linux内核UDP收包为什么效率低” ?
不同的高下文异步操作同一份数据,锁是必不可少的。关于锁的话题已经烂大街了。现在仅就cache来谈论,中断高下文和进程高下文之间的切换,也有一个明显的case:
中断高下文中修正了socket的元统计数据,该修正会表现在cache中,然而当其wakeup该socket的处理进程后,切换到进程高下文的recv系统调用,其或读或写这个统计数据,伴随着cache的flush以及cache的同等性同步。
如果这些操作统一在进程高下文中进行,cache的利用率将会高效很多。当然,回到UDP收包不合理的backlog行列步队机制,实在backlog本身存在的目的之一,便是为了让进程高下文去处理,以提高cache的利用率,减少不必要的flush。然而,初衷未必能达到效果,在传输层用backlog将skb推给进程高下文去处理,已经太晚了,何必不再网卡就给进程高下文呢?就像DPDK那样。
实在Linux内核社区早就意识到了这两点,早在3.11版本内核中引入的busy poll机制便是为理解决锁和切换问题的。busy poll的思想非常大略,那便是:
不再须要软中断高下文往吸收行列步队里“推”数据包,而改本钱身在进程高下文里主动从网卡上“拉”数据包。
落实到代码上,那便是在进程高下文的recvmsg函数中直接调用napi的收包函数,从ring buffer里拿数据,自己调用netif_receive_skb。
如果busy poll总能实行,它总是能拉取到自己下一个须要的数据包,那么这基本便是DPDK的效率了,然而和DPDK一样,这并不是一个统一的办理方案,轮询固然对付收包有收益,但中断是不能丢的,用CPU的自旋轮询换取收包效率,这买卖代价太大,毕竟Linux内核并非专职收包的。
当然了,大概内核态实现协议栈本身便是一种缺点,但这个话题有点跑偏,毕竟我们便是要优化内核协议栈的,而不是放弃它。
关于这个话题,推举一篇好文:
千万级并发实现的秘密:内核不是办理方案,而是问题所在!
http://highscalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel-i.html
现在,我们不能指望busy poll担当所有的性能问题,仍旧要依赖中断。既然依赖中断,锁的问题便是优化的重点。
以双核CPU为例,假设CPU0专职处理中断,而收包进程则绑定在CPU1上,我们很快能意识到, CPU0和CPU1对付每一个skb的enqueue和dequeue均在争抢socket的sk_receive_queue的spinlock 。
优化方法显而易见, 将多个skb聚拢起来,一次性入吸收行列步队 。显然,这须要两个行列步队:
掩护聚拢行列步队:由中断高下文将skb推入该行列步队;
掩护吸收行列步队:进程高下文从该行列步队拉取skb;
吸收行列步队为空时,交流聚拢行列步队和吸收行列步队。
这样,同样在上述双核CPU的情形下,只有在上面的第3点的操作中,才须要锁保护。
考虑到机器的CPU并非双核,可能是任意核,收包进程也未必绑定任何CPU,因此上述每一个行列步队均须要一把锁保护,无论如何, 和单行列步队比较,双行列步队情形下锁的竞争减少了一半!
collect_enqueue(skb, sk){spin_lock(sk->sk_collect_queue->lock);skb_queue_tail(sk->sk_collect_queue, skb);update_statis(sk);spin_unlock(sk->sk_collect_queue->lock);}sk_buff recv_dequeue(sk){spin_lock(sk->sk_receive_queue->lock);skb = skb_dequeue(sk->sk_receive_queue);update_statis(sk);spin_unlock(sk->sk_receive_queue->lock);return skb;}udp_rcv(skb) // 中断高下文{sk = lookup(...);spin_lock(sk->higher_level_spin_lock);collect_enqueue(skb, sk);// 仅仅往聚拢行列步队里推入。spin_unlock(sk->higher_level_spin_lock);}udp_recv(sk, buff) // 进程高下文{if (empty(sk->sk_receive_queue)) {spin_lock(sk->queues_lock);swap(sk->sk_receive_queue, sk->sk_collect_queue);spin_unlock(sk->queues_lock)}skb = recv_dequeue(sk); // 仅仅从吸收行列步队里拉取if (skb) {copy_skb_to_buff(skb, buff);}}
如此一来,双行列步队解除了中断高下文和进程高下文之间的锁竞争。
来看一下比拟图示:
引入双行列步队后:
即便已经很不错了,但是:
中断高下文中不同CPU可能会收到同一个socket的skb,CPU依然会在聚拢行列步队的锁上蹦跶;
不同的CPU上的进程也可能会处理同一个socket,本意是互助,却须要吸收行列步队的锁来将其操作串行化。
没办法,通用的操作系统内核只能做到这里了,如果要办理以上的问题,就须要按照任何和角色明确绑CPU核心了,然而这也就不再是通用的内核了。终极,你会在内核里闻到DPDK的腐臭味,超级糟心。
对了,我暂且将双行列步队区分为了聚拢行列步队和吸收行列步队 ,更好的名字可能是backlog行列步队和吸收行列步队 。中断高下文总是操作backlog行列步队,而进程高下文在吸收行列步队为空时,交流backlog行列步队为吸收行列步队。然而,backlog行列步队这个名字在我看来非常臭名昭著,以是,暂且不用它了。
我想本文该当就要结束了,确实没有源码剖析,事实上,我以为我写的这篇要比下面的这种故意思的多,然而可能在网上能找到的基本都是这种非常详细的源码剖析:
...bh_lock_sock(sk); // 锁定住skif (!sock_owned_by_user(sk)) // 判断sk是不是被用户进程所拥有,如果没有被拥有的话。rc = __udp_queue_rcv_skb(sk, skb); // 直接调用__udp_queue_rcv_skbelse if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) { //否则调用sk_add_backlog将skb放入backlogbh_unlock_sock(sk); // 如果失落败,解锁sk,直接丢包goto drop;}bh_unlock_sock(sk); // 解锁skreturn rc; // 返回rc...
哈哈…
我为什么没有谈UDP的GRO、LRO机制?由于太不通用了。但是另一方面,如果运用程序加以轻微支持,UDP的GRO、LRO将会带来非常可不雅观的收益,别忘了,内核只是UDP报文的一个通路即可,既然是通路,它便不包含处理逻辑,越快通过,越好。如果你在乎高吞吐,那么就GRO呗,如下:
UDP的通用L4 GRO相称于一个非常大略的5层协议,运用程序按照len字段稍加解析拆分即可,这将极大减少系统调用的次数,减少高下文切换带来的cache miss损耗。
我为什么不写源码剖析?
由于我觉得很多写源码剖析的都是吹水待价而沽的,当前出一本源码剖析的书本钱太低了,以是大家都去写这种源码剖析。我来说下写源码剖析须要做什么:什么都不须要做,只要懂编程措辞语法,能看懂代码的语法即可,然后给代码写注释,源码剖析就完成了,你乃至都不须要懂代码的逻辑。
只假如命名良好的源代码,只须要把代码翻译成中文即可。而海内险些所有的技能书本都可以冠以 “深入理解”、 “深度解析” 之名,实则便是类似上面的 源码翻译......
那么,我为什么不给Linux内核社区提交patch呢?和写源码剖析待价而沽使我不得愉快颜一样,我并不认为写patch提交给社区能让我更快乐。我只是思考,仅此而已,无需被承认,以是不必费尽心机为外人知。
专业领域内,以IT互联网行业为例,真正的牛人险些不写书,不写文章,更不会写什么源码剖析,真正的牛人留下的是代码而不是别的......我不是牛人,以是我有韶光写文章,但我也不是欺世盗名待价而沽之人,以是我不写毫无含金量的源码剖析,我只是一个在路上思考的人,以是我写的东西都是思考的总结,禁绝确,乃至禁绝确,但这便是了。
作者:dog250,本文精选自CSDN博客,原文https://blog.csdn.net/dog250/article/details/98061338。
【End】