redis源码分析与思考(四)——字典遍历与reverse binary iteration算法

    在进行字典的遍历时,有着许多的问题去解决。最难处理的点就是如何在rehash的途中有效率的、不遗漏的遍历全部的哈希节点,因为在此时每个哈希节点的地址与索引都是不确定的。在Redis中,其作者基于reverse binary iteration算法来实现的,直译过来就是反转二进制迭代算法,而且实现的非常精妙,Redis之父Salvatore Sanfilippo对其的评价是” Hard to explain but awesome.”

reverse binary iteration算法

    一句话简单的描述该算法:一个二进制的数字,每次从最高位加一,向低位方向进位。
    不同于平常的进位,reverse binary iteration则是刚好相反着来的。比较下两个例子:

平常进位
000-->001-->010-->011-->100-->101-->110-->111-->000
reverse binary iteration进位
000-->100-->010-->110-->001-->101-->011-->111-->000

    现在来谈谈如何实现该算法:该算法的思路是利用二进制的两次反转来实现进制方向的不同,从而实现从最高位加一,向低位方向进位。 在Redis中,假设mask代表着哈希表大小的掩码,hashcode代表着键的哈希值,因为哈希表的大小总是是2的n次方,所以它的掩码mask总是如此,即掩码的二进制后n位全为1:

//掩码等于哈希表的大小减一
.....000001.....1
//例如:哈希表的大小为8,则其掩码为7
//二进制表示为:....00111

    假设mask=111,hashcode=101,步骤如下:

1. hashcode |= ~mask:保留与mask长度相同的低n位数,其余为全为1,hashcode=1…101
2. rev(hashcoded):二进制反转,hashcode=101…1
3. hashcode++:hashcode=1100…
4. rev(hashcoded):二进制再次反转,hashcode=011

    可见,经过两次反转后,hashcode成功的向低位进了1:

101-->011

    反转二进制的代码如下,有着大量的移位操作:

//v为二进制
static unsigned long rev(unsigned long v) {
    unsigned long s = 8 * sizeof(v); // bit size; must be power of 2
    unsigned long mask = ~0;
    while ((s >>= 1) > 0) {
        mask ^= (mask << s);
        v = ((v >> s) & mask) | ((v << s) & ~mask);
    }
    return v;
}

选择的理由

    谈理由之前,先来说明下在Redis中的一个很秒的操作,就是获取键的索引值操作。

// 计算新哈希表的哈希值,以及节点插入的索引位置
h = dictHashKey(d, de->key) & d->ht[0].sizemask;

    之所以说它妙,是因为它通过此操作可以将不同大小的哈希表之间的相同哈希节点给关联起来。 假如,一个大小为8的哈希表,它的mask(掩码)为111,那么键的索引值就取决于键的哈希值(hashcode)的后三位,如果hashcode&mask=101(索引值),而现在它要rehash到一个大小为16的哈希表中,掩码为1111,掩码后三位是完全相同的,且键值是不会改变的,所以在大小为16的哈希表中,这个键的索引值要么是0101,要么就是1101。同理,关联到大小为32中,就有四种情况:00101,01101,10101,11101。

    也就是说,只要索引值后n位是相同的,那么它们就是相互关联的。这样就可以带来一个好处:假如在大小为4的哈希表里遍历了01这个索引值,那么在大小为8的表里,只需要遍历索引值为001与101两个位置即可找到对应的哈希节点,而不用全部遍历。相反也是如此。

    谈到遍历,或许我们首先想到的是顺序遍历,因为这个方式最为简单直接。但是在遍历Redis中的字典却行不通,我们分析下为什么行不通。假如Redis正在进行rehsah,ht[0]的大小为4,而ht[1]的大小为8,两者如果都按顺序遍历的话(为方便比较都用二进制表示):

//大小为8
000-->001-->010-->011-->100-->101-->110-->111-->1000
//大小为4
00-->01-->10-->11-->100

    在顺序遍历中,假如是大小为8的哈希表rehash为大小为4的哈希表,假如在遍历010这个点时,其余的数据rehash到了大小为4的哈希表中,010在大小为4的哈希表中就为10,也就是从10开始遍历大小为4的哈希表,但是010后面的100,101会被rehash到00与01这两个位置,也就是说原来的这两个节点没有被遍历到,造成了哈希节点的遗漏。

    再来看reverse binary iteration算法的遍历:

//大小为16
0000-->1000-->0100-->1100-->0010-->1010-->0110-->1110-->0001-->1001-->0101-->1101-->0011-->1011-->0111-->1111-->0000
//大小为8
000-->100-->010-->110-->001-->101-->011-->111-->000
//大小为4
00-->10-->01-->11-->00

    在这里,你会发现一个很奇妙的规律,大小为n的哈希表第i个索引值与大小为k×n的哈希表第n个索引值之间的关系是:

n=k*i+j;(j>=0&&j<=k-1,k为整数)

    这就保证了字典缩小时不会产生遗漏哈希节点的产生,因为相同关联的索引值都是相邻的。且在小表中的索引值是对应在大表里相关联的索引值第一位 即在小表中为10的索引值,在大表中索引值010是与之相关联的索引值的第一位。两种方法都会产生遍历重复的节点,但是后一种方法遍历的重复节点的概率较少,而且遍历到重复节点容易在应用层得到解决。

    最后来看看,Redis具体是怎么做的吧:

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;
    // 迭代只有一个哈希表的字典
    if (!dictIsRehashing(d)) {
        // 指向哈希表
        t0 = &(d->ht[0]);
        // 记录 mask
        m0 = t0->sizemask;
        // 指向哈希桶
        de = t0->table[v & m0];
        // 遍历桶中的所有节点
        while (de) {
            fn(privdata, de);
            de = de->next;
        }
    // 迭代有两个哈希表的字典
    } else {
        // 指向两个哈希表
        t0 = &d->ht[0];
        t1 = &d->ht[1];
        // 确保 t0 比 t1 要小,为了从小的哈希表直接得到大的哈希表的关联索引值
        //比如:大小为4的哈希表索引值01关联到大小为8的哈希表:001与101
        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));
    }
//用reverse binary iteration计算下一个索引值,并返回(到0结束)
    v |= ~m0;
    v = rev(v);
    v++;
    v = rev(v);
    return v;
}

    代码中,需注意到几个地方:

v = (((v | m0) + 1) & ~m0) | (v & m0);  1

    这句话的意思是找到与v相关联的大哈希表里的所有索引值。

while (v & (m0 ^ m1));  2

    这个意思是判断是否还有与v相关联的索引值。

    假如有二进制k=110,m0=3,m1=7。

  1. k=110
  2. 1式运算后:k=1110,k & (m0 ^ m1)=1000,符合条件
  3. 1式运算后:k=10110,k & (m0 ^ m1)=0,不符合符合条件,循环结束

    可见确实如此,上述,找出了与小哈希表中相关联的索引值1110,另外一个则是其本身0110。

参考:https://blog.csdn.net/gqtcgq/article/details/50533336
           https://blog.csdn.net/gqtcgq/article/details/50533336

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值