数据结构
// 字典的节点
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
// 字典类型的特定函数
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
// 哈希表
typedef struct dictht {
// 字典节点数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
// 字典结构
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表 2个哈希表 (作者注解:每个字典都会有两个dictht,这是为了实现渐进式的rehash。)
dictht ht[2];
// rehash的索引。(表示rehash正在执行到的位置。如果是-1,表示不在执行rehash。)
int rehashidx;
// 正在运行的迭代器数量
int iterators;
} dict;
整理以上结构的关系如下:
字典dict | |
---|---|
哈希表ht[0] | 哈希表ht[1] |
[dictEntry, dictEntry, ...] | [dictEntry, dictEntry, ...] |
// 字典迭代器
typedef struct dictIterator {
// 被迭代的字典
dict *d;
// table 正在被迭代的哈希表索引,(一个字典有两个哈希表,)值可以是 0 或 1 。
// index 对应哈希表中数组的索引。
// safe 迭代器是否安全0/1。safe=1,在迭代过程中可以做字典的修改操作;safe=0,只能调用dictNext进行迭代,而不能修改字典。
int table, index, safe;
// entry 当前迭代到的节点的指针
// nextEntry 当前迭代节点的下一个节点
dictEntry *entry, *nextEntry; // 下文解释 为什么要额外记录下一个节点指针
long long fingerprint; /* unsafe iterator fingerprint for misuse detection */
} dictIterator;
// 哈希表的初始大小
#define DICT_HT_INITIAL_SIZE 4
// 字典是否可以rehash
static int dict_can_resize = 1;
// 强制 rehash 的比率
static unsigned int dict_force_resize_ratio = 5;
这两个字段结合来控制dict需不需要做rehash。dict_can_resize设置为1的时候,正常扩容;dict_can_resize设置为0的时候,如果占座率不大于dict_force_resize_ratio,就不做扩容,大于dict_force_resize_ratio时,强制进行扩容。
在阅读《Redis设计与实现》“哈希表的扩展与收缩” 一节时,书中提到:
因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
什么是copy-on-write
copy-on-write fork创建出的子进程,与父进程共享内存空间(代码、数据、堆栈)。因为不用复制内存空间,创建子进程的速度很快。
直到父子进程任意一方对数据段修改时,为子进程分配对应数据段的物理空间。
Redis在存盘时,会有一个额外子进程负责读数据写磁盘。如果子进程运行期间,主进程再做写操作,不可避免的会触发copy-on-write,
这样会花费性能在内存复制上,增加了内存使用。所以在rehash阶段,才会有一个最大阈值判断,尽量减少rehash。
用到的哈希函数
dictIntHashFunction
/* Thomas Wang’s 32 bit Mix Function / 针对uint32计算hash值的方法。(查看资料后,其中使用的trick都看不懂)
dictGenHashFunction
/ MurmurHash2, by Austin Appleby / 针对字符串计算hash值的方法。
dictGenCaseHashFunction
/ And a case insensitive hash function (based on djb hash) */ 不区分大小写的DJB哈希方法。
方法列表
dictCreate
创建一个新字典。
dictResize
大小重分配,包含所有元素的最小size。
size大小计算是找第一个大于等于size的2的幂。
其中用到了dictExpand方法,创建一个新的哈希表,根据原先字典的情况去判断是初始化还是rehash。
// n是新创建的哈希表,大小是新的size。
// d是字典,先判断第一个哈希表是不是空,是空那么这次dictExpand操作是初始化。
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
// 如果第一个哈希表不是空的,那么新的hash表赋值给第二个。
// 说明这次dictExpand是rehash操作.
d->ht[1] = n;
d->rehashidx = 0; // 非-1,标记了字典正在rehash
return DICT_OK;
dictRehash 字典rehash。(接下来就可以了解为什么叫渐进式哈希)
redis是单线程模型,如果因为一个字典的负载因子超过阈值,就对整个哈希表做rehash操作,就会阻塞其他的正常操作。
所以redis采用的方案是在一定时间内只rehash部分,分多次将整个哈希表重新分配到新的哈希表。
// 查看字典是否正在 rehash
#define dictIsRehashing(ht) ((ht)->rehashidx != -1)
// 经典Redis的dict的rehash方法
int dictRehash(dict *d, int n) {
if (!dictIsRehashing(d)) return 0; // 只可以在标记打开时执行
// 限制了只做n步迁移,由调用者控制
while(n--) {
dictEntry *de, *nextde;
// 如果0号哈希表为空,说明已经整个rehash已经完成
if (d->ht[0].used == 0) {
// 释放0号哈希表
zfree(d->ht[0].table);
// 将1号哈希表设置为新的0号哈希表(默认赋值拷贝,回头看哈希表的结构,节点的数组指针相同)
d->ht[0] = d->ht[1];
// 重置1号哈希表(这里不会释放table指向的内容,而是直接赋值NULL,因为table原指针已经交给了0号)
_dictReset(&d->ht[1]);
// 关闭rehash标识
d->rehashidx = -1;
return 0;// 返回0,表示成功
}
/* Note that rehashidx can't overflow as we are sure there are more
/* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned)d->rehashidx);
// 在table数组中找到下一个不为空的位置
while(d->ht[0].table[d->rehashidx] == NULL) d->rehashidx++;
// 指向该索引的链表表头节点(别忘了数组每个位置都是一个链表,用来解决hash冲突的)
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
// 接下里就是遍历这个链表,一个一个hash到1号哈希表中
while(de) {
unsigned int h;
// 提前保存下个节点的指针
nextde = de->next;
/* Get the index in the new hash table */
// 计算新哈希表的哈希值,以及节点插入的索引位置
//具体hashFunction由应用场景决定,大多key是sds,hashFunction是dictGenHashFunction
// 这里采用sizemask,长度掩码,因为sizemask始终等于size-1,而size是2的幂,也就是sizemask始终是一个低位掩码。
// 在一定程度上,和sizemask做与操作 等价于 对长度size取模。
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 将de节点next指向1号哈希表对应位置
de->next = d->ht[1].table[h];
// 然后1号表对应位置赋值为de指针,这样de就插到h位置的链表头部
d->ht[1].table[h] = de;
// 更新计数器
d->ht[0].used--;
d->ht[1].used++;
// 下一个待处理节点
de = nextde;
}
// rehashidx位置的链表处理结束,指针置为NULL
d->ht[0].table[d->rehashidx] = NULL;
// 下一个
d->rehashidx++;
}
return 1; // 表示未完待续
}
dictRehashMilliseconds 在给定毫秒内,每次做100步rehash迁移。
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();// 开始的时间(毫秒)
int rehashes = 0;
while(dictRehash(d,100)) {// 做一次迁移,迁移100个槽(存在数据的一个数组位置是一个槽)
rehashes += 100;
if (timeInMilliseconds()-start > ms) break; // 如果超时了,停止迁移
}
return rehashes; // 返回已经成功迁移的数量
}
_dictRehashStep 单步rehash。
static void _dictRehashStep(dict *d) {
// 在安全迭代器数量为0时,做一步rehash
if (d->iterators == 0) dictRehash(d,1);
}
_dictExpandIfNeeded 私有方法,按一定规则扩展dict。
- 如果在rehash,不做。
- 如果0号哈希表还是空的,初始化为初始大小的哈希表。
- 如果已有的节点数大于等于数组长度(比率 >= 1:1)&&(
dict_can_resize
|| 比率大于dict_force_resize_ratio
) - 扩展大小规则:新的大小至少是已有节点数的两倍。 比如有3个节点,按照newsize=6去扩展空间,实际分配了8长度。
_dictKeyIndex
私有方法,判断一个新的key能不能插入。
先算出key的hash值,不管是0号还是1号哈希表,一个字典的hash方法是固定的。
查找0号哈希表,如果找到了key返回-1。如果没找到,判断下当前有没有在rehash, 没有在rehash,那么就是没有key;
正在rehash,那么需要查找1号哈希表。
dictAdd
将给定的键值对添加到字典中。调用的dictAddRaw
方法,只有在key不存在时添加成功。
dictAddRaw
作者注:Low level add. 尝试将一个键插入到字典,如果键已经存在返回NULL,否则创建一个新节点插入字典并返回这个节点。
dictEntry *dictAddRaw(dict *d, void *key)
{
int index;
dictEntry *entry;
dictht *ht;
if (dictIsRehashing(d)) _dictRehashStep(d); // 这一步思考:如果正在rehash,就做一次单步rehash。TODO 为什么这里要做单步rehash
if ((index = _dictKeyIndex(d, key)) == -1) // 键已经存在
return NULL;
// 如果正在rehash,插入到新哈希表
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
// 为新节点分配空间
entry = zmalloc(sizeof(*entry));
// 将新节点插入到链表表头
entry->next = ht->table[index];
ht->table[index] = entry;
// 更新哈希表已有节点数量
ht->used++;
dictSetKey(d, entry, key);
return entry;
}
dictReplace
更新键值对,key已存在就替换旧的value,返回0,key不存在是新添加的,返回1。
先调用dictAdd做一次Low level add,这一步key已经存在是不会插入的。
key存在的情况下,查找到对应的entry,设置新的value,释放旧的value。
dictReplaceRaw
是一个特别版的replace,如果key存在返回对应的entry,key不存在则添加key并返回新的entry。
dictGenericDelete
删除给定key的节点,可以选择是否调用k/v释放函数。 其中在执行操作前也执行了 if (dictIsRehashing(d)) _dictRehashStep(d);
TODO 为什么这里要做单步rehash
dictDelete
删除给定key的节点,并调用k/v释放函数。
dictDeleteNoFree
删除给定key的节点,但不调用k/v释放函数。
_dictClear
删除整个哈希表。
dictRelease
删除真个字典。 (分别删除两个哈希表,释放字典结构。)
dictFind
查找指定key的节点。 在查找之前执行了 if (dictIsRehashing(d)) _dictRehashStep(d);
TODO 为什么这里要做单步rehash
dictFetchValue
获取指定key的value(调用dictFind)。
dictFingerPrint
根据字典属性生成fingerprint。字典迭代器中有一个属性是fingerprint,64bit的number。
dictGetIterator
创建字典的不安全迭代器。
dictGetSafeIterator
创建字典的安全迭代器。
dictNext
返回迭代器的当前节点。
dictEntry *dictNext(dictIterator *iter)
{
while (1) {
if (iter->entry == NULL) { //当前指向NULL。可能是第一次迭代,也可能是链表的尾结点
dictht *ht = &iter->d->ht[iter->table];//ht 当前迭代的哈希表
if (iter->index == -1 && iter->table == 0) { //如果是第一次迭代,并且是第0号哈希表
if (iter->safe) // 安全迭代器
iter->d->iterators++; // 将字典的安全迭代器计数加一
else
iter->fingerprint = dictFingerprint(iter->d); // 不安全迭代器则计算字典的指纹
}
iter->index++; // 迭代器前进一步
if (iter->index >= (signed) ht->size) { // 如果已经大于等于哈希表大小,说明当前哈希表迭代结束
if (dictIsRehashing(iter->d) && iter->table == 0) { // 如果正在rehash,并且迭代的是0号,说明还有1号哈希表需要迭代
iter->table++;
iter->index = 0;
ht = &iter->d->ht[1];
} else { // 不在rehash或者迭代的已经是1号哈希表,就全部结束了
break;
}
}
// 正常迭代中(第一次迭代或者前一个链表已结束),迭代器指向的节点更新
iter->entry = ht->table[iter->index];
} else {
// 当前iter->entry不是NULL,就继续迭代下一个链表节点
iter->entry = iter->nextEntry; // 这里用到了迭代器中的nextEntry
}
if (iter->entry) { // 如果迭代到的节点不为空
iter->nextEntry = iter->entry->next; // 将下一个节点记下来
// 作者注:因为在使用迭代到的节点时,可能会删除这个返回的节点,所有要记录下下个节点。
return iter->entry;
}
}
// 迭代完毕
return NULL;
}
dictReleaseIterator
释放迭代器。
void dictReleaseIterator(dictIterator *iter)
{
// (iter->index == -1 && iter->table == 0) iter不在迭代, 取反就是iter在迭代中
if (!(iter->index == -1 && iter->table == 0)) {
if (iter->safe) // 安全迭代器 就计数器减一
iter->d->iterators--;
else // 不安全迭代器,指纹不能有变化
assert(iter->fingerprint == dictFingerprint(iter->d));
}
zfree(iter);
}
dictGetRandomKey
随机返回字典任意一个节点。在查找之前执行了 if (dictIsRehashing(d)) _dictRehashStep(d);
TODO 为什么这里要做单步rehash
dictGetRandomKeys
随机返回一组节点。 实现上,比较取巧,随机槽位(table的index),然后将该位置的链表节点全部加到结果中,接着直接下一个槽位继续。
解释前面为什么要做单步rehash
到这里,大致可以理解一个问题:为什么一些操作,会先做一次单步rehash。
dictAddRaw
dictGenericDelete
dictFind
dictGetRandomKey
这四个操作,是dict做增删改查的基本函数。每次针对dict操作时,都会检查是否在扩容,然后做一步rehash。这样就把一个字典的扩容过程平摊给了每一次操作,这样字典一次扩容的CPU压力就平摊到相当多的操作当中。(Redis不仅仅靠平摊这个手段去做扩容,还有定时器。)
dictScan 字典遍历
unsigned long dictScan(dict *d, unsigned long v, dictScanFunction *fn, void *privdata)
{
dictht *t0, *t1;
const dictEntry *de;
unsigned long m0, m1;
// 空字典 直接结束
if (dictSize(d) == 0) return 0;
// 如果字典当前没有rehash,直接根据游标v继续迭代下一个槽。
if (!dictIsRehashing(d)) {
// 指向哈希表
t0 = &(d->ht[0]);
// 记录 mask
m0 = t0->sizemask;
/* Emit entries at cursor */
// 指向哈希桶
de = t0->table[v & m0];
// 遍历桶中的所有节点
while (de) {
fn(privdata, de);
de = de->next;
}
// 迭代有两个哈希表的字典
} else {
// 指向两个哈希表
t0 = &d->ht[0];
t1 = &d->ht[1];
/* Make sure t0 is the smaller and t1 is the bigger table */
// 确保 t0 比 t1 要小
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
// 记录掩码
m0 = t0->sizemask;
m1 = t1->sizemask;
/* Emit entries at cursor */
// 指向桶,并迭代桶中的所有节点
de = t0->table[v & m0];
while (de) {
fn(privdata, de);
de = de->next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
// Iterate over indices in larger table // 迭代大表中的桶
// that are the expansion of the index pointed to // 这些桶被索引的 expansion 所指向
// by the cursor in the smaller table //
do {
/* Emit entries at cursor */
// 指向桶,并迭代桶中的所有节点
de = t1->table[v & m1];
while (de) {
fn(privdata, de);
de = de->next;
}
/* Increment bits not covered by the smaller mask */
v = (((v | m0) + 1) & ~m0) | (v & m0);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits of the smaller table */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v);
v++;
v = rev(v);
return v;
}
这里有一个特别牛逼的算法是Pieter Noordhuis设计的,Redis之父Antirez的评价是*“Hard to explain but awesome.”*
dictScan 解析
该函数用来遍历字典,迭代字典中的每一个节点。如果遍历一个稳定的字典,会很简单,但是如果两次遍历中间插入了未知次数的扩容或缩容,使得字典结构发生变化,字典中的节点的位置也发生了未知的变化,那么应该怎么遍历去同时保证所有元素都会被遍历到和尽可能少的重复遍历。
dictScan设计的非常巧妙,其中两个位运算的技巧,理解下来感觉十分精彩。
首先理解下参数v,同时返回值也是v。作者标注v is a cursor,这里翻译成游标。
迭代的方式如下:
1)初始游标v=0,来调用dictScan
2)dictScan进行一步迭代,然后返回新的游标值v,下一次迭代必须使用返回的游标v
3)当游标等于0的时候,迭代结束
接下来简化一下dictScan函数
if (!dictIsRehashing(d)) {
} else {
}
v |= ~m0;
v = rev(v);
v++;
v = rev(v);
return v;
第一块是判断字典是否在rehash,根据字典有没有两个哈希表做不同的迭代操作。
第二块是对游标v做了一些奇怪的位运算操作。
最后返回新的游标v。
那么dictScan在不额外记录迭代状态的情况下,能够迭代所有节点的秘密就在第二块代码上了。
m0 = size - 1,(长度的掩码),已知数组长度是2的幂,比如长度为8,m0=0b0111,长度为16,m0=0b1111。
v |= ~m0;
对m0取反,变成高位掩码(原低位掩码都变成0),再v取或,就把v的m0掩码上bit不变,高位上的bit都是1。
v = rev(v);
rev是一个二进制位翻转操作。 0001 => 1000, 0011 => 1100。 高位变低位,低位变高位。
v ++;
加一。 翻转后加一,相当于原来的高位加一,向低位进位。
v = rev(v);
再翻转回去。
下面演示一下:
假设v=0,长度是8,m0=0b0111
因为高位都是重复的,我们按照类型长度8位来理解。
bit位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
v | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
v |= ~m0; | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 |
v = rev(v); | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 1 |
v ++; | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
v = rev(v); | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
第一次迭代完,游标v的值变成0b0100。
继续第二次迭代:
bit位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
v | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
v |= ~m0; | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 |
v = rev(v); | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 |
v ++; | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
v = rev(v); | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
第二次迭代完,游标v的值变成0b0010。
…
多次演算可以得到游标v的变化结果:
000 -> 100 -> 010 -> 110 -> 001 -> 101 -> 011 -> 111 -> 000
(对于长度为8的掩码,结果只用关注低3位。)
同样对于长度16的哈希表,可以得到游标v的变化结果:
0000 -> 1000 -> 0100 -> 1100 -> 0010 -> 1010 -> 0110 -> 1110 -> 0001 -> 1001 -> 0101 -> 1101 -> 0011 -> 1011 -> 0111 -> 1111 -> 0000
很明显,对于这个算法,可以遍历哈希表的所有位置。
那么,它跟传统的v = v + 1; (0,1,2,3,4…) 顺序遍历方法区别在哪呢。
前面阅读源码可知,hashkey是通过固定算法得出,和哈希表长度无关,而插入哈希表的索引位置是hashkey&sizemask。
sizemask是长度掩码,哈希表长度8,sizemask是111,那么索引位置最终由hashkey的低三位决定;如果哈希表长度16,sizemask是1111,那么索引位置最终有hashkey的低四位决定。
依次类推,扩容情况下,索引位置的值也就是hashkey的低M位,都是一样的,M是较小哈希表长度掩码的长度。
假设同一个key,计算出来hashkey是一致的。
哈希表长度8, mask=111,索引值是hashkey的低三位,假设是xyz
哈希表长度16,mask=1111, 索引值是hashkey的低四位,?xyz (?是0/1)
哈比表长度32,mask=11111, 索引值是hashkey的低五位,??xyz (?是0/1)
回头观察,长度8和长度16的游标v的变化情况
000 | 100 | 010 | 110 | 001 | 101 | 011 | 111 | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0000 | 1000 | 0100 | 1100 | 0010 | 1010 | 0110 | 1110 | 0001 | 1001 | 0101 | 1101 | 0011 | 1011 | 0111 | 1111 |
哈希表长度为8时,长度第n个游标,扩展到长度为16的哈希表中,对应的游标是2n和2n+1。
对应到我们实际的迭代过程中,当我们迭代完000后,哈希表发生了扩容,从长度8扩展到长度16,
接下来拿着游标100去长度16的哈希表中继续迭代,正好找到0100,可以把剩下的全部迭代到,而且之前迭代过得000位置的节点,已经分配到了0000和1000位置,不会重复迭代这两个位置。
可以再尝试举几个游标位置思考,发现扩容时,可以完美的迭代剩下所有节点,而且不会重复。
继续看缩容的情况,哈希表长度16缩容到哈希表长度8。
当前游标0100和1100时,都会从100开始迭代,也就是说可能会重复0100位置的节点。但是接下来会把表格向右剩下的所有位置迭代完。
总结,扩容和缩容都可以迭代到所有节点,扩容不会迭代重复节点,缩容最多重复迭代一个槽位的节点。
可以再思考一下,如果不用该算法,而是直接v++;
000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
---|
0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 | 1000 | 1001 | 1010 | 1011 | 1100 | 1101 | 1110 | 1111 |
---|
扩容时,假设游标是000,从0000开始迭代没有问题;假设游标是001,从0001向右,会重复迭代1000;假设游标是010,从0010向右,会重复迭代1000和1001;
… 扩容前,游标越大,扩容后,重复迭代的位置就越多。
缩容时,假设游标是0000, 从000开始迭代没问题;假设游标是0001,从001向右,会遗漏1000映射到000中的节点;假设游标是0010,从010向右,会增加遗漏1001映射到001中的节点;
… 缩容前,游标越大,缩容后,遗漏的节点就越多。
很明显,顺序迭代完全不能满足要求。相对而言,Pieter的算法显得就特别优秀。
至此,算法的核心已经解释完。
下面再继续看下迭代细节
if (!dictIsRehashing(d)) {
} else {
t0 = &d->ht[0];
t1 = &d->ht[1];
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
de = t0->table[v & m0];
while (de) {
fn(privdata, de);
de = de->next;
}
do {
de = t1->table[v & m1];
while (de) {
fn(privdata, de);
de = de->next;
}
v = (((v | m0) + 1) & ~m0) | (v & m0);
} while (v & (m0 ^ m1));
}
如果正在rehash,记录t0为小的哈希表,t1位大的哈希表。
先针对游标v,找到小的哈希表对应的位置,迭代该位置的链表;然后再迭代大的哈希表对应的位置。
因为扩容时,小表一个位置的节点可能rehash到大表的多个位置;缩容时,大表多个位置的节点可能rehash到小表的一个位置。
所以,大表和小表都要迭代对应游标的位置。
小表逻辑很简单,游标只会对应一个位置。
大表逻辑相对复杂,游标会对应多个位置。
举个例子,长度为8的对应rehash长度为32的。
映射关系有
001 -> 00 001, 01 001, 10 001, 11 001;
010 -> 00 010, 01 010, 10 010, 11 010;
可以观察到大表的掩码比小表的掩码多2位,2位的组合有4种,那么小表一个位置会映射大表的4个位置。
do {
...
v = (((v | m0) + 1) & ~m0) | (v & m0);
} while (v & (m0 ^ m1));
关键逻辑,就在这一块。 m0是小表的长度掩码, m1是大表的长度掩码。
先看(v & m0)
,用小表的掩码取v的低位;
再看 (v | m0)
, 组合成一个 [v的高位][m0] 这样一个二进制串,假设m0是111, 游标v是10(0b1010),就得到1111,
要把这个数分两块来看,1 111,低位是掩码位,高位是除去掩码位的游标高位。
继续((v | m0) + 1)
,结合上面的二进制串的特征,掩码位+1的结果,实际上就是高位+1。
得到((v | m0) + 1)
的用处,使得游标v的高位加1。
~m0
,掩码取反,然后做&操作,相当于取高位。
(((v | m0) + 1) & ~m0)
这一块作用,将v的高位加一并取高位。
那么整个(((v | m0) + 1) & ~m0) | (v & m0)
的意思就是,将v的高位加一(这里的高位始终是指除去m0掩码的高位)。
循环条件是 v & (m0 ^ m1)
, 小表和大表的长度掩码做异或,结果是这样:
超出m1部分m1长度-m0长度部分m0长度部分00…011…100…0
用v做&操作,很明显就是取上面讲到的v的“高位”。
对照小表和大表的位置映射分析,举个例子
小表位置 010
大表位置 00 010, 01 010, 10 010, 11 010
v=010,那么上面的循环对应的“高位”就是00, 完整来看 v = 00 010,
每次将“高位”加一,依次迭代 00 010, 01 010, 10 010, 11 010 直到 00 010,“高位”是0,不满足while循环条件退出。
这样就实现了迭代扩容后所有映射位置。
遗留问题:
安全迭代器和不安全迭代器的使用场景没有研究,等到后续阅读到相关代码再做分析。
总结:
字典dict的高性能的关键有两点:
- 渐进式rehash的扩缩容。
- 精巧的迭代算法。