非常多人这样说,也包括我。
Linux内核早就把HASH路由表去掉了。如今就仅仅剩下TRIE了,只是我还是希望就这两种数据结构展开一些形而上的讨论。
1.hash和trie/radix
hash和tire事实上是能够统一在一起的。具有同样hash值的多个项具有一个共同的特征,这个特征怎么提取呢?无疑这就是hash函数的工作。而trie树(或者radix树,管它呢)的一棵子树也有共同的特征,这个特征怎么提取呢?无疑这就是该子树根节点的父节点指示的某些bits在这棵子树的每个节点都具有同样的值。
实际上,trie树就是hash的一种特殊形式,其hash函数为:取某些bits
trie_hash(value, level) { return value & level.bits; }
那么。这么看来,子树的全部节点都应处在一个“冲突链表”里面了...trie树的做法就是“再次hash”,hash函数随之改变。变成取level.bits更低的某些bits了。如此看来。hash路由表解决海量路由项情况下冲突链表变长的方案就是再次hash了,hash函数变成什么呢?我们后面再谈。
2.TCAM的hash
TCAM在非常多地方被用到,它用来依据内容查索引,常被用于路由查询。CPU Cache查询等,以CPU Cache为例。输入TCAM的内容就是一个内存地址,而输出的结果是一个索引。cache匹配的过程就是取到索引指示的cache line,然后比較输入内容(地址)和该cache line指示的地址是否一致。一致就是命中。那么TCAM中最核心的过程就是依据地址得到索引的过程,一般的做法就是hash,由于硬连线实现,hash函数绝对不能有太多的计算。因此一般的做法就是“取地址某些bits”,比方取4到7位一共4位,将一个32位(32位系统,物理地址索引cache为例为例)的慢速物理内存地址映射到4位高速cache索引。形成一个金字塔存储结构。32位到4位的映射,丢失了的28位会形成非常大可能性的冲突,而这个就是时间局部性和空间局部性来尽力弥补了,了解列维飞行的应该知道局部性的伟大含义,它构建了我们整个人类文明。
最简单的hash函数就是取模,实际上也是“取某些bits”,它更加特殊,它是“取最低N bits"。
3.hash和trie树的统一
trie树实际上是从高位到低位逐步hash的过程构建的,其hash函数就是”取某些bits“。4.查字典的样例-查英文和查汉字
我们小学的时候查字典一般分为音序查法和部首查法,它就形象能体现hash和trie的不同。为了简便,我以英文单词查法和汉字部首查法为例。
英文单词是严格一维度顺序排列的,且仅有26个字母组成。因此它能够依照trie树的方式查询,比方what。who,where,前两个字符都是wh,因此说它们具有这么一个共同特征。假设将取这个共同特征作为hash函数,那么在aaa。cc,sahidad。fwfwew,what,qwert,azsx,who。eee,ooo,where中查询who,what。who,where将形成冲突链表,可是一步运算大大降低了匹配的数量,从11个减为3个,然后再进一步hash,依照字母顺序可知at,wre。o这个顺序,直接取第三个孩子节点。因此英语词典的查询方式非常简便。就是一个不断hash定位的过程。hash函数就是”取某些连续字符“。
我们再看看汉字部首查询法。它是一个典型的计算型hash函数的不断hash的过程。比方在杨。林,棵,马,牛,猪,过。皮这几个字中查”林“字,由于汉字不是一维结构而是二维结构。它的构成是笔画。不是排序的,因此”取某些字符“的方式全然失效(从哪个方向開始取?...怎么算一个字符?...),因此就须要又一次构造hash函数了。长期的历史形成的汉子具有某种象形的意义。通过观察。我们发现”木“字旁是一个特征,这个计算过程,也就是hash函数运行过程是我们的大脑来完毕的。假设说”取某些字符“更加适用于硬件实现,那么发现偏旁部首则更加适合软件实现,从中我们也能够分析出中国人和西方人的思维之差别。继续往下说。发现”木字旁“之后。杨。林,棵形成了冲突链表,但大大降低了匹配候选字的数量。不想遍历的话。须要再次hash,新华字典设计了笔画数这个再hash函数,”林“字除了偏旁之外还剩下4笔画。于是定位到了”林“。假设还冲突,那就须要遍历了,由于商务印书馆可能想不出什么hash函数了(我不知道这样的汉字部首查字法是谁发明的,就当是出版社的杰作吧...)。
反过来看英文查法。总是能够终于确定性定位,由于它的不断hash的hash函数是”取连续字符“,加之单词长度有限且一维排列顺序递进。总是能够到最后一个字符的。
看出差别了吗?看出trie树查询和hash查询的差别了吗?
5.hash路由表和trie路由表
对于hash路由表查询而言,最长前缀匹配逻辑并没有包括在hash过程中,它来自于一种冒险行为,前提是对hash函数的足够自信。hash路由表查找直接从32位前缀hash表開始。逐步回归到0位前缀hash表,期望在这个过程中能高速得到第一个结果,这第一个匹配结果就是终于结果。对于trie路由表查询而言,最长前缀匹配逻辑包括在不断再hash的逻辑中。它匹配的是最后一个结果而不是第一个,由于”顺序取某些bits“不断hash的过程。最后匹配到的显然是最精确的。这是和hash路由查询的本质差别。trie查询没有冒险行为,它不须要冒遍历超长冲突链表之险,由于老老实实地运行顺序取bits这个过程总能将查询过程引到目的地。
6.海量路由项的情况
Linux之所以用了那么久hash路由表组织,是由于它足够了。由于在大部分时间。路由表项数量是不多的。即便是遍历也不会有太大的开销,而hash的计算会大大降低遍历的开销,所谓的冒险最坏情况就是遍历整个路由项,这不是为题。可是一旦遍历整个路由表的全部路由项真的成了一个大风险的时候。或者说即使遍历一半也吃不消的时候,用hash就不明智了。
这和狮子追羚羊时的博弈相似。一个风险是一顿饭,一个风险是一条命,这是严格不正确称的。所以总是看到羚羊胜利(还真不能把这个当零和游戏,由于狮子有时真的不在乎)。
如今的问题是,怎样使用hash路由表并降低风险。我们先看一下Linux自己的hash函数:
static inline u32 fn_hash(__be32 key, struct fn_zone *fz) { u32 h = ntohl(key)>>(32 - fz->fz_order); h ^= (h>>20); h ^= (h>>10); h ^= (h>>5); h &= FZ_HASHMASK(fz); return h; }
可见它将输入的非0项散列得足够开,可是hash的本质就是大空间往小空间映射。冲突在所难免。
有人提出(比方我)在海量路由表项时将长冲突链表组织成trie树的形式,可是这有意义吗?假设是一个完整的trie路由表。最长32步(考虑压缩和回溯)就能找到结果。假设採用hash+trie的方式,每一步的最坏结果都是32步,一共进行32步...这样做没有意义。
海量路由表项时,hash小空间是严格有范围的。能够觉得它是固定的。平均情况非常easy通过地址空间和hash空间求得,最坏情况则是全然遍历。平均情况假设都不能接受,难道值得为最好情况去冒险吗?因此,千万别用hash表存储海量路由表项。
可是。还没完
7.局部性利用以及DoS
32位系统,CPU Cache相比内存而言非常小,怎么能够带来如此大的优化?全部映射到同一个cache line的地址都是冲突的啊...这是由于CPU Cache利用了程序的时间/空间局部性,而对于路由而言。则没有空间局部性。时间局部性能够用于路由cache,然而用于路由表本身则有难度。路由表和CPU Cache的差别在于它是全然的。不存在被替换和老化的问题。因此能够把好的hash函数用于单独的路由cache,而路由表仅仅用于路由cache不命中的情况下去匹配。
理想情况分析完了,剩下的仅仅是悲哀了。
网络訪问的时间局部性真的能够利用吗?尽管一个5元组的数据流通常会随着时间持续经过路由器,可是假设hash冲突的还有一个数据流也经过的话,就会造成cache抖动,在CPU Cache看来。这个问题能够通过控制task切换或者添加cache line唯一键值来解决,可是对于网络訪问,你没法阻止不论什么一个数据包的到来,仅仅要到来就要查询路由表,就有可能导致cache抖动。更严重的。路由cache非常easy受到精心构造的数据包的攻击造成不可用,频繁的替换或者无限的加长链表。平添了查询开销。
因此设计一个全然的转发表而不是利用路由cache更加能提升转发效率。
这又一次为我的DxR Pro结构作了一个广告。