作者| dog250

编辑|郭芮
出品| CSDN博客
如今,很多人在险恶的Linux内核协议栈上接收包效率很低。 即使他们真的懂,一点也不懂也只是听别人的,总之就是一个劲儿地依赖Linux内核协议栈。 他们的武器好像只有DPDK。
但是,为什么Linux内核协议栈接收包的效率真的很低? 有尝试优化的方法吗? 不是马上DPDK,而是跟着去的想法,大部分都是低级的想法。
再说一遍,我不写技术文件。 也不分析源代码。 本文只是思考的总结。 但是,所有的思维必然是主观的,不准确,甚至不准确。
让我们从头说起。
Linux内核作为通用操作系统内核,脱胎换骨于UNIX的现代操作系统理论。 但是,一开始我不知道发生了什么,我把网络协议栈的实现塞进了内核状态,从那以后一直处于内核状态。 既然网络协议栈处理在内核状态下进行,则网络分组必须在内核状态下进行处理。
无论如何,数据包都需要进入内核状态。 这涉及如何进入内核状态。
可以通过用户状态的系统调用或通过硬件中断从外部访问内核。
这意味着,在任何时刻,系统都必须位于两个上下文之一。
流程上下文;
中断上下文(无中断的线程化系统或任何进程上下文)。
分组接收逻辑的协议栈处理明显地从网卡上传,它明显地为中断上下文,其对分组的用户进程的数据接收处理明显地为APP应用的处理上下文
套接字层的数据包传输目的地中一定存在队列缓存。 这是典型的生产者-消费者模型,中断上下文的终点作为生产者对包进行排队,流程上下文作为消费者从队列中消费包。
是非常清爽的图。 此图是两个上下文中继处理协议栈包接收逻辑的必然结果。 如果我们添加几个实际上必须考虑的问题,就会发现这张图并不那么清爽。 然后,我们来回顾一下如何优化。
由于两个上下文都尽可能地操作同一个套接字来传递包,因此需要一种同步机制来保护套接字元数据和包skb本身。
由于Linux内核的中断,软中断可能存在于任何进程上下文中。 唯一的同步方案几乎是spinlock。 因此,真正的图标应该如下所示。
现在,可以说这种保护是必要的,特别是对TCP来说。
我们知道TCP是一种基于事务的有状态传输协议,具有复杂的流控制和拥塞控制机制。 这些机制基于socket的当前状态数据,如inflight、lost和retrans。 这些状态数据在发送和接收ACK/SACK的过程中不断变化。 因此,如下所示。
在上下文完成事务传输之前,必须锁定套接字状态数据。
例如建设流程。 可以在以下两个上下文中发送包:
流程上下文:由系统调用触发的建设;
中断上下文:由ACK/SACK触发的采购订单。
一个上下文的建设过程必须通过TCP协议本身、例如拥塞控制、流控制等来中止,因为在中途不能切换到另一个上下文,所以必须锁定。
问题是上图的锁是否过于冷酷。 中断上下文的旋转时间完全取决于流程上下文的语句,不利于软中断的快速返回,大大降低了系统的响应能力。
因此,有必要细化密钥的粒度。 Linux内核没有横向划分锁定粒度,而是纵向采用了以下两个级别的锁定机制:
我们看到的在处理Linux内核接收包的逻辑时的backlog,其实抽象出来就是上面的二级锁,这不是很像Windows的IRQL机制吗? 随着APC、DPC,可以将暂时由于高等级IRQL块而无法执行的逻辑放入DPC中:
由于流程上下文占用套接字锁定,中断上下文将skb放入辅助level的后台队列,并在流程上下文解除锁定时按顺序执行包含辅助level的任务
其实这是一个非常普遍和通用的设计,不仅是Windows的IRQL,Linux中断的上半部分/下半部分也是基于这样的思想设计的。
正如前面提到的,TCP事务非常复杂,非常耗时,可能必须一次完成。 这意味着您必须保持socket low锁。 以订购逻辑tcp_write_xmit函数为例,其内部循环订购每次回复tcp_transmit_skb需要3微秒~5微秒,平均4微秒,直到受窗口限制而终止为止
backlog队列机制有效降低了中断上下文的spin时延,提高提高了系统的响应能力,非常好。 但问题是,UDP有必要这样做吗?
首先,UDP是无状态的,接收包或发送包都不需要事务。 协议栈处理UDP总是单个消息粒度,因此只需保护唯一的套接字接收队列,即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_buffdequeue(sk ) ) spin_lock ) sk_receive_queue-lock ); skb=skb _ dequeue ( sk-sk _ receive _ queue; spin _ unlock ( sk-sk _ receive _ queue-lock; 返回skb; }需要保护的所有接收队列操作区间都是指令级的等待时间,采用单一的sk_receive_queue-lock就足够了。
确实,在Linux2.6. 25版内核之前,我们就是这样做的。 自从2.2版内核以来,TCP已经采用了辅助锁backlog队列。
但是,在2.6.25版内核中,Linux协议栈的UDP包路径与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 ) weup_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套接字,则会面临此热点,但实际上很难遇到这种情况。 如果只有一个必须说的话,DNS服务器可能是第一个。
考虑到为了避免UDP耗尽系统存储器,UDP需要统计存储器的全局记账,在版本2.6.25的内核中引入了辅助锁定备份队列,使用了sk_rmem_schedule函数的在2.6.25版内核之前,没有对UDP的内存使用进行计费。 由于UDP本身没有流量控制、拥塞控制等限制机制,所以恶意软件容易耗尽系统内存。
因此,除了sk_receive_queue外,还必须保护内存计费逻辑,例如将当前skb的内存使用累积到全局数据结构中。 尽管如此,将所有这些统计数据的更新塞进spinlock的保护区域,比两个阶段的lock更好。
为了保护内存计费逻辑,引入了l2锁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_buffdequeue(sk ) ) spin_lock ) sk_receive_queue-lock ); skb=skb _ dequeue ( sk-sk _ receive _ queue; update_statis(sk; spin _ unlock ( sk-sk _ receive _ queue-lock; 返回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版中也确实移除了2级lock,恢复为2.6.25版之前的逻辑。
上述优化带来了巨大的性能优势,但并不是值得夸耀的。 因为上述优化就像解决了一个bug。 虽然引入该错误是为了参考TCP的backlog实现,但实际上UDP不需要这种华丽的backlog逻辑。 因此,上述效果不是优化带来的效果,而是解决了一个bug带来的效果。
但是,为什么晚于4.10版发现并解决了这个问题呢?
我觉得这件事可能和QUIC有关。 用较少的逻辑当然不容易发现问题。 这就像David Miller在2.6版内核中引入了IPv6实现中的一些错误。 由于IPv6使用的人很少,所以一直在较慢的4.23版内核中被发现并解决了。 对于UDP,2.6.24版之前的实现很好,是合乎逻辑的。 2.6.25中引入的二次锁定错误也同样由于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实现。
PDK的假粉丝拿UDP比拿TCP成本低很多。 那么,为什么DPDK处理UDP数据包那么高效呢? 答案很简单。 DPDK在流程上下文中轮询并接收UDP包! 也就是说,摆脱了两个问题。
使用流程和中断上下文锁定共享数据问题;
流程上下文和中断上下文之间的切换导致的cache miss问题。
这两点实际上也是“为什么内核协议栈的性能比不上用户状态协议栈”的问题。 当然,Linux内核协议栈无法摆脱这两个问题,也回答了本文主题中的第一个问题:“Linux内核UDP为什么接收包效率低下?”
要在不同的上下文中异步操作相同的数据,锁定是必不可少的。 关于钥匙的话题已经烂透了。 虽然这里只讨论cache,但中断上下文和进程上下文之间的切换也有明显的case。
在中断上下文中,套接字的meta统计信息将更改并反映在cache中。但是,如果套接字的处理进程wakeup,则会切换到进程上下文中的recv系统调用,以读取或写入统计信息
如果这些操作在进程上下文中统一,则缓存利用率将相当高效。 当然,回到UDP接收包不合理的backlog队列机制,其实backlog本身存在的目的之一是处理进程上下文,以提高cache的利用率,减少不必要的闪存但是,初衷并不一定能取得效果。 在传输层通过后台将skb推到流程上下文来处理已经太晚了。 为什么不使用卡传递到流程上下文? 就像DPDK一样。
其实Linux内核社区早就意识到了这两点,3.11版内核引入的busy poll机制就是为了解决锁定和切换问题。 busy poll的思想非常简单,那就是。
将进程上下文中的包更改为自己“拉拽”网卡中的包,而不是在软中断上下文中将包“推送到”接收队列。
在代码中实现时,将直接在流程上下文的recvmsg函数中调用napi数据包函数,从ring buffer中获取数据,并自己调用netif_receive_skb。
如果busy poll始终运行,并始终能够提取自己下一个需要的包,那么这基本上就是DPDK的效率。 但是,与DPDK一样,这不是统一的解决方案。 轮询有利于接收包,但不能丢失中断。 交换CPU旋转轮询和数据包接收效率,买卖成本太大了。 说起来,Linux内核并不是接收包的专家。
当然,内核状态实现协议栈本身可能是个错误,但这个话题有点偏差。 毕竟,我们是优化内核协议栈,而不是放弃它。
关于这个话题,我推荐一个好句子:
千万级别并发的秘密:内核不是解决方案,而是问题! 3358 high scalability.com/blog/2013/5/13/the-secret-to-10-million-concurrent-connections-the-kernel
目前,我们不能指望busy poll负责所有的性能问题,仍然依赖中断。 既然依赖中断,锁定问题是优化的重点。
以双核CPU为例,假设CPU0的专职处理被中断,接收数据包的进程被绑定在CPU1上,则CPU0和CPU1对于各自的skb的enqueue和dequeue分别设定socket的sk _ recee
优化措施是明显的,收集多个skb,一次放入接收队列。 很明显,这需要两个队列。
聚合队列维护:通过中断上下文将skb推入队列;
维护接收队列:流程上下文从此队列中抽取skb。
如果接收队列为空,则交换聚集队列和接收队列。
因此,上述双核CPU也仅在上述第三点操作中需要锁定保护。
考虑到机器的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_buffrecv_dequeue(sk ) spin_lock ) sk_receive_queue-lock ); skb=skb _ dequeue ( sk-sk _ receive _ queue; update_statis(sk; spin _ unlock ( sk-sk _ receive _ queue-lock; 返回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 _ 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可能接收同一套接字的skb,CPU仍然在集合队列的密钥上跳动;
不同CPU上的进程可能也会处理相同的套接字,并希望进行协作,但要序列化操作,必须接收队列锁。
没办法。 通用的操作系统内核只能到此为止。 要解决以上问题,必须根据角色明确绑定CPU核心。 但是,这已经不是通用的内核了。 最终,内核中会有DPDK的腐烂气味,让人非常不舒服。
顺便问一下,我先把双队列区分为集合队列和接收队列。 更好的名字可能是备份队列和接收队列。 中断上下文始终操作后台队列,流程上下文在接收队列为空时将交换后台队列作为接收队列。 但是,backlog队列这个名字对我来说很臭名昭著,所以暂时别用它。
我想这篇文章会结束的。 确实没有源代码分析。 实际上,我觉得我写的这个比下面的有趣多了。 但是,在互联网上能找到的可能基本上是这个非常详细的源代码分析:
. BH_lock_sock(sk ); 确定用户进程是否拥有锁定skif ( sock _ owned _ by _ user ( sk )//sk ),如果没有。 RC=__UDP_queue_rcv_skb(sk,skb ); //直接_ UDP _ queue _ rcv _ skb else if ( sk _ add _ backlog ) sk、skb、sk-sk_rcvbuf ) /否则sk _ add _ backk }BH_unlock_sock(sk; 解锁skreturn rc; 返回//RC .哈哈…
为什么不谈论UDP的GRO、LRO机制? 因为太没用了。 另一方面,如果稍微支持APP应用,UDP的GRO、LRO会带来非常大的利益。 请记住,既然内核只是UDP消息的一个路径,而且是路径,它就不包含处理逻辑,而且越快通过越好。 如果你在意高通量的话,GRO就可以了。 如下所示。
UDP的通用L4 GRO相当于非常简单的第5层协议,APP应用根据len字段稍加解析和分割即可,大大减少了系统调用次数,减少了上下文切换带来的cache miss丢失。
为什么不写源代码分析?
我觉得写很多源代码分析都是吹吹拍拍等着价格,所以现在出一本源代码分析的书成本太低了,大家都去写这个源代码分析。 说明为了写源代码分析需要做什么。 你什么都不需要做。 要是知道编程语言语法,能理解代码语法就好了。 然后,在代码中写注释,就完成了源代码分析。 你甚至不需要理解代码的逻辑。
只要是好名字的源代码只需要把代码翻译成中文。 国内绝大多数技术书籍可以冠以“深刻理解”、“深刻剖析”之名,实际上就像上述的源代码翻译……
那么,为什么不向Linux内核社区提交patch呢? 就像写源代码分析价格卖很开心没办法一样,我不认为写patch提交给社区会更幸福。 我只是想。 仅此而已,不需要被承认。 所以,你没必要想让别人知道。
在专业领域,以IT互联网行业为例,真正的牛几乎不写书,不写文章,也不会分析源代码。 真正的牛留下的是代码而不是别人……我不是牛,所以有时间写文章,但我也不是欺骗社会等待价格的人,所以也不写没有钱的源代码分析,只是一个在路上思考的人,所以我写的东西都是思考的
作者: dog250,本文精选自CSDN博客,原文https://blog.csdn.net/dog 250/article/details/98061338。
【End】
光遇1.23每日任务该怎么完成呢?在光遇世界有着各种精彩有趣的任务内容,玩家可以完成后获取大量的游戏奖励,小编
《云顶之弈》这游戏中卢安娜的飓风这件武器最近版本更新中被强化了,分裂攻击的伤害效果大大提升。有些小伙伴
迷你世界激活码2023是哪些呢?在精彩有趣的全新挑战活动中,玩家可以体验到更多丰富的游戏奖励。小编今天准备了
很多传奇的老玩家关心关于贪玩蓝月祝福油怎么用的相关问题,今天柠檬友玩小编给大家搜集整理了如下内容,希望对
《魔兽世界怀旧服》中存在着多样的公会制度,DKPROLL团就是其中之一,关于这个制度的意思好优劣势,本文将为你解
斗罗大陆魂师对决阵容最佳搭配2023,新版本上线了很多魂师,我们应该如何搭配呢?以下为大家分享新版本顶级阵容大
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10
时间:2022-12-10