无锁哈希表(Lock-Free Hash Table)是多线程编程中的理想数据结构,但是带有并发插入和删除的实现很少见于开源项目。博主对此做了一个巧妙的设计和实现,本文于此首次阐述设计思想。
通过考察各种基于CAS原子操作的无锁数据结构实现,目前公认可实现无锁安全的数据结构是数组和单向队列。其他实现都一定程度上受到复杂度和ABA问题的威胁。数组的实现相对于单向队列要简单,所以无锁hash table理想的选择是数组,对于冲突不拉链。但是如何解决hash冲突呢?基本思想依然是开放寻址探测法。为了同时支持增加/查找/删除三种操作,业界各种开放寻址探测的算法,都为一次探测做了优化且照顾了其他探测次数以及最坏情况,但是这个照顾动作对于无锁设计实在是不太完美。为了达到O(1)的操作性能,开放寻址应该限制探测的次数为固定有限次,超过这个数目的探测应该通过算法降低为0,但同时用一个很小的数组(遍历模式)收容最坏情况(这里称之为保险数组)。由此可以实现,所有的操作都是对数组做无锁设计。
为了保证固定有限的探测次数极为有效(也就是够用),应该通过算法保证冲突率够低。降低冲突,一般不外乎两种措施:
一,增加hash表长度。此措施受限于内存,所以对每个bucket要尽量节省内存。一般用指针作为bucket单元去指向hash node就是很节省内存了。但64位Linux的指针是8个字节,在hash表很大而装载率较小的时候仍然是过于浪费。如果用4个字节作为bucket单元(最大可支持40亿buckets),同样的内存使用可以创造双倍hash表长度,对降低hash冲突率有很大的改进。但是4个字节是不能任意寻址的,需要把hash node有限度集中以省去最高位地址段。通过创建固定大小的内存池(一个静态数组存贮按需申请的内存块的指针,内存块按定长划分成hash node),将4个字节按位切割为两段作为hash node在内存池的两个维度的下标。请注意这里的hash node默认是固定32字节大小,一般不直接包含而是用指针去指向真正的使用者数据。为了性能提高也可以定制hash node 的大小,比如64字节(现代CPU的cache基本单元大小),这时候hash node可以直接存贮小于40字节的字符串而在cache命中。
二,选择高性能高分散性能的hash函数,以使得输出的hash值尽量接近理论上的完全随机。业界对于hash函数的讨论和实现已经有很多了,高计算性能分散性极佳的已有很多,比如murmur hash,city hash。hash函数的输出,即使达到理论上的完全随机,如果在hash表内仅仅寻找一个位置,也不能够降低冲突率。一般还需要多次探测其他位置,常用的办法就是以某个(或某些,用多个hash函数得到)位置为起点,线性或者非线性地计算出其他位置,直到找到目标位置或者循环一圈为止。但是这有两个缺陷:线性或者非线性计算都有一定的卷积(即后继位置有相关性导致随机分散性降低);后继位置数量不限制造成最坏情况过于复杂(也降低了攻击保护);
上面两种措施也是业界常用的降低冲突率的方案。这里增加了第三种措施去改善上面两个缺陷:如果从多个独立的随机位置开始探测有限数目的bucket,将会从理论上降低hash冲突概率。那么得到多个彼此独立的起始位置呢?作者的设计就是利用hash函数的输出 -- 选用输出128位结果的hash函数,分割成四个32位整数(128位是假定随机的因而四个整数是独立的),每个32位整数对hash table长度求模,可以得到4个独立的bucket位置,这样将大大减少冲突率。
但是,这样一般还不够好。原因是我们想尽量提hash table的装载率(load factor)且仍然能够处理冲突增加的倾向。为此,增加第二个数组,用上面四个32位整数同样地映射出第二个数组内的4个bucket位置,这样对一个key我们就有128位hash值映射到8个独立的bucket了。8个位置都探测过了仍然冲突的怎么办?扔到保险数组里面去。保险数组的长度虽然非常小,但通过概率可以保证它是够用的,除非hash函数输出不够随机(也包含key太短的原因)或者受到hash值重复攻击。对于后者当保险数组满了之后简单丢弃就达到了防卫hash攻击的后果。
至此我们有三个优化目标:1,进入保险数组的概率应该极低;2,总的bucket利用率(性能墙)尽量高;3,两个hash table数组占用的内存之和应该最小。通过计算(此略),可以限定1,得到2和3的最佳配置。在作者的实现中创建了三个数组:一个高load factor的大数组作为主hash表,一个低load factor的小数组作为辅hash表,和一个极其微小(64或128足够)的数组作为保险表。
这个优化目标需要一个前提,就是基于概率计算的前提是可靠的,也就是对于任何key,选用hash函数输出要足够随机。这个前提,对于key足够长,city hash和murmurhash函数已经做得足够好。但是对于key小于12字节或者key之间差异不够,就变差了。这个也是理论上没办法的事情。使用者应该明白并避让设计较差的key。
另外,主动或者随压力被动删除的计时器(已实现),无卡顿扩容(rehash),内存池内存归还,都可以是丰富附带功能,在此不再描述。
需要源码请致divfor@gmail.com