协议栈处理中的conntrack HASH查找/Bloom过滤/CACHE查找/大包与小包/分层处理风格

1.路由CACHE的优势与劣势

分级存储体系已经存在好多年了,其精髓在于“将最快的存储器最小化,将最慢的存储器最大化”,这样的结果就使资源利用率的最大化,既提高了访问效率,又节省了资源。这是所有的CACHE设计的基本原则。
       对于内存访问,几乎所有的CPU都内置了一级cache,二级cache,亲和力好的几个核心甚至设计了三级cache乃至四级cache,然后才是物理内存,然后是经过精密优化的磁盘交换分区,最后是远程的存储器,这些存储空间逐级变大,访问开销也逐级变大,构成了一个金字塔型的存储体系。如此的设计要想获得最大的功效,其主要是利用了局部性原则,往大了说就是自然界普遍的列维飞行原则。这个原则不但在内存访问上有效,而且人类的文明就是这种列维飞行原则构建起来的,我不想从南方古猿说到最后的北美移民潮以及西进运动,因此本段就此打住。
       推而广之,协议栈的路由表就是为了查找而生,且不说Cisco的Express Forward(CEF体系),单说路由cache就够了。早期的Linux(迄至2.6.39以往,不谈3.X),在系统路由表至上又构建了一个路由cache,每一个cache表项都是一次成功的路由查询结果,内置一个超时过期时间。Linux路由cache设计的前提是:去往一个目标地址的数据包会连续到来。这也是局部性原理的一种推广。但是和内存访问以及单一目的地的移民浪潮不同,Linux作为一台路由器并不是为一个数据流服务的,而Linux的路由cache中保存的是SourceIP/DestinationIP值对,因此一条路由表中的项将会扩展出N条路由cache项:
1.1.1.0/24-192.168.1.254==>{(2.2.2.1,1.1.1.1,192.168.1.254),(2.2.2.2,1.1.1.1,192.168.1.254),(2.2.2.3,1.1.1.2,192.168.1.254),(2.2.2.3,1.1.1.10,192.168.1.254)...}
如果同时过境的基于源IP/目标IP的二元组数据巨大的话(特别是在骨干路由器场景下),路由cache的数目将远远超过路由表项的数目,我在想,路由cache相较于路由表的查询优势体现在哪里呢?难道会有如此高效的路由cache查询算法吗?如果有的话,为何不直接应用于最长前缀匹配的路由表查找呢?很明确的是,路由表查找远远没有路由cache查找更严格。
       路由cache的思想是好的,但是确实不适合软件实现,如果有TCAM硬件的话,或许可以实现并行的多维向量精确匹配,但对于软件实现,由于协议栈天生的多CPU核心利用的弊端,最高效的做法就是分级hash了,而即便是最高效的多级hash实现也不是免费的午餐。如果考虑到异常流量,很容易将你的路由cache表撑到爆,比如构造海量不同的源IP地址访问不同的目标IP地址即可。
       TCAM的原理就不讲了,网上资料多的是。

2.conntrack的hash查找

Netfilter中有一个conntrack模块,它可以建立任意五元组的连接跟踪。但是如果来了一个数据包,就必须进行一次查找,以便将它关联到某一个连接跟踪上。这个查找的效率高低,直接影响了这台设备的品质。查找算法很简单,首先根据五元组计算一个hash,然后在该hash值定为的冲突链表上遍历,直到找到一个精确匹配的,如果没有则新建。
       谈到hash,如果仅仅从理论上看,它是一个极其高效的时间/空间相平衡的算法,不考虑空间浪费,它可以经过常数的时间找到结果,另一个极端,它却退化成了一个单纯的链表,查找时间复杂度和遍历一致。真实的hash算法的时间复杂度介于二者之间。因此Linux在实现conntrack的时候,本应该像Trie路由树那样,动态调整hashsize的大小,即进行适当的rehash操作,以将每条冲突链表上的元素的平均数量保持在一个确定的范围内,这样就可以保持良好的伸缩性和可扩展性,而你的代价仅仅是消耗了内存。但是Linux似乎没有这么做。
       屡次想优化nf_conntrack的查找性能,但总是受制于下面一个理想,即“理想情况下,conntrack的查找仅仅需要做一次hash计算,然后遍历一个元素或者个位数个元素的链表”,如果实现了这个理想,还用费劲优化吗?我假设冲突链表的平均元素个数为10,那么只要10000个hash桶,就能容纳10*10000个连接,效率已经很高了。但是理想毕竟只是理想!hash值计算的输入是五元组,算法是固定的,因此如果这个hash算法不够好的话,计算结果的散列程度和输入是高度相关的,理论和事实均证明,涉及到查找的hash算法都无法做到理想情况,要想使得hash的输出和输入无关,就要采用对称密钥学中的操作概念:置换,替换,混淆,扩散等,可以参考一下DES/AES的操作步骤,就可以看到这依然不是一顿免费的午餐!
       hash查找需要的是高效,如果一个算法针对输入的散列程度已经足够好了,那就到此为止了。如果散列程度很不好,那么冲突链表的长度就会有的特别长有的特别短,这对于一个公平调度系统,对性能的影响是很大的。

3.基于Bloom过滤的转发表

如果说Linux的路由cache必然下课,那么总得有个替代方案了。事实上,我并不仅针对Linux,而是在讨论一般场景。只要是软件实现的路由cache,必然要考虑cache查找的效率问题。cache仅仅在条目数目固定且数目很小的前提下,才会体现出对慢速路径的优势,否则剩下的就只是思想了。
       如果扯到硬件,那怎么折腾都是OK的,但是此时我肯定不谈硬件。我希望使用纯软件来优化转发效率。
       记住一件事是重要的,那就是:你不能将整体的每一个部分的性能都提升,正如永动机不可能一样,你必须将不重要的部分能力倾注到需要优化的部分。
       优化的基准就是,区分一个快速路经和慢速路径,首先查找快速路经,若失败则进入慢速路径查询,同时将结果加入快速路经。快速路经很快,前提是它是排它的,容量小且有限的,就像罗马共和国时期的公民权一样,是要靠奋斗而取得的,到了卡拉卡拉时期,公民权成了既得权,优势自然就没了。使用Bloom可以做到传统路由cache的高效替代方案。
       对每一个下一跳维护一个固定大小(但是运行期可以动态调整)的Bloom过滤器,数据包到来,首先遍历所有下一跳的Bloom过滤器,结果无非有三种:
a.只有一个Bloom过滤器返回值为1;
b.有多个Bloom过滤器的返回值为1;
c.没有一个Bloom过滤器的返回值为1;

对于a而言,直接发往那个下一跳即可,对于b而言,说明存在False Positive,此时说明凡是返回1的Bloom过滤器都有可能指示正确的结果,要么退回慢速路径,要么在所有可能的结果之间广播,对于c而言,此时必须退回到慢速路径。请注意,我们当然希望结果是a,此时的计算非常之快,N个hash计算即可,至于计算方式,可以在一个处理器串行,也可以多个处理器并行。
       对于Bloom过滤器而言,为了方便删除元素,故使用带有counter的int型占位而不是仅仅是一个表示0和1的bit位。为了防止导致False Positive的结果一直会False Positive,所有的Bloom过滤器在内存中都要有一个后备存储,保存元素的链表,每间隔一段时间,在后台更新hash算法,进行Bloom的update操作。我并不赞成再引入新的层次,比如再引入一个cache层,因为那会增加维护量,需要进行复杂性管理。解铃还需系铃人,既然为了避免同一IP地址对的计算结果相同,那就改变算法而不是引入新层,在同一层次,代偿是不必要的。
       总而言之,Bloom过滤器的hash算法一定要保持精巧,所占空间一定要小巧。

4.大包还是小包

OpenVPN在虚拟网卡模拟了巨型帧,为了提高加密/解密的吞吐和减少系统调用开销。但是将巨型帧用于真实的物理链路一定好吗?
       小包灵活,这也是分组交换的真谛,那么大包呢?尾大不掉,一个无限大的包就是电路交换流了,它会长期占据链路。就像集卡或者挖掘机一样。好在现实中使能巨型帧的链路它真的有能力传输巨型帧(说明它的路比较宽!),这就没有什么问题。
       但是,如果拥有很宽的路,就一定要传输巨型帧吗?如果跑小帧岂不效率更好?双向10车道对于跑集卡是没有什么问题,但是跑轿跑估计更好,如果非要运货,那么运输机的吞吐量虽然不比货轮,但是延时却小很多。事实上,在一段出发后的链路上,即便你传输了巨型帧,也不保在途中被拆分。因此基于能耗,分组交换效率,分片/重组开销考虑,巨型帧算是没有什么优势了,我之所以在OpenVPN中模拟了巨型帧,是因为在OpenVPN链路上我可以完全控制一切,虽然这需要途径物理网卡以及虚拟网卡的数据帧均是巨型帧,但是我能通过测试证明,针对这些巨型帧的分片/重组开销远远大于OpenVPN的小包加密/解密以及系统调用开销。
       那么,巨型帧的初衷是什么?仅仅为了抵消掉分组交换的好处??事实上,这是针对主机的优化,特别是运行Windows主机,这种主机一般都是处在网络的边缘,作为端到端的终点处理,因此对于到来的数据帧,如果中断过于频繁,势必会降低应用层的处理性能,毕竟总的资源是有限的!而巨型帧可以减少中断的数量。
       我曾经说,巨型帧是合时宜的,此话也不绝对,对于高性能单一链路而言,这是对的。对于途径窄带链路的数据网络而言,巨型帧平添了开销,且在最后一公里的链路上堵路。中间节点的分片/重组可能并不仅仅一次,只要需要分析协议头的地方,均需要重组分片,比如状态NAT,比如防火墙,比如数据流分类...对于中断的频度,目前的千兆卡,万兆卡均可以在网卡芯片内进行分片,重组,然后再中断CPU,同时也可以积累数据帧到一定量,然后中断CPU一次,接下来改中断为轮询,细节请参看Intel IGB的e1000e驱动程序readme。

5.协议栈的分层处理风格

协议栈的设计是分层的,这并不意味着协议栈的实现也必须是分层的。
       早期的实现,或者受早期实现影响的实现,比如UNIX的流模块,比如Windows的NDIS框架,都是严格按照分层原则实现的,这种实现的优势是调用者可以在任何既有的点插入任何处理逻辑,不用显式的设置HOOK点,对于Windows的NDIS框架,只要你调用的API复合NDIS框架的约定,过滤驱动可以任意实现。不仅仅在内核态处理,即便是socket层次,Windows也提供了SPI的LSP机制,除了大家都认可的BSD socket接口,TCP实现,UDP实现,IP实现等是内置的外,其它的你都是可以任意外接的,即便是TCP/IP栈,也不是必须的,如果你看网卡的属性,你会发现TCP/IP协议是可以单独安装卸载的,如果你安装了VMWare,那么就会自动安装VMWare桥接协议。总之,一切都是可以灵活安装的,没有内置的HOOK点,你可以任意在层间插入自己的处理模块。
       这种设计旨在方便开发者实现自己的逻辑,开发者只需要了解相关接口就可以在任意位置插入自己的逻辑,开发者甚至可以根本就不懂网络协议栈处理逻辑。当然,这种设计的缺点也是显而易见的,由于只开放了相关的API而并未开放细节,那么你便无法对未开放但是你真的需要的接口进行调用,因此开发者很难实现协议栈层次之间的任意组合。
       Linux的协议栈设计就完全不是分层的,而是完全基于回调的,它并不区分协议处理,小端口驱动等,由于所有的回调都是注册好排好序的,因此如果你想在两个回调之间插入一个HOOK,你就必须先解除之前的链接关系。Linux采用了另外一种方式来处理这种情形,它允许开发者自行组织各个层次之间的相互调用。比如你可以在一个hard_xmit回调函数内部调用另一个hard_xmit回调,你也可以在一个recv里面调用xmit或者任意的xmit..这种任意组合是递归的,看似混乱,实则灵活。你会发现,Linux的bridge,bonding,vlan都是这种方式实现的,这些机制并没有像NDIS那样插入一个过滤层NDIS驱动,而是直接组合各种xmit。Linux的这种实现风格要求开发者对网络协议栈处理逻辑细节十分熟悉,他们在一个函数中拿到的是一个拥有网络协议栈语义的数据单元而不仅仅是一段buffer。
       除了协议层的边界,在协议层处理的内部,Linux定义了若干个HOOK点,Netfilter是一个很重要的框架,基于它可以实现非常棒的防火墙。注意Netfilter并非仅仅用于防火墙,而是协议栈内置的一套框架,它可以实现任意的数据包QUEUE,STOLEN等,在此基础上可以实现IPSec VPN而无需插入任何层次。美中不足的是,Linux对于socket的HOOK机制比较少。如果你想HOOK住connect操作,就必须在OUTPUT chain上去match TCP的syn标志...
       以负载均衡的实现为例子,对于NDIS,需要实现一个中间层过滤驱动,而对于Linux,除了IPVS,使用bonding的lb模式也是很方便的。

6.大便骑沟便沟内

针对早期的水冲式公共厕所,此处从略...几种答案,几多理由。

你可能感兴趣的:(协议栈处理中的conntrack HASH查找/Bloom过滤/CACHE查找/大包与小包/分层处理风格)