Redis源码初探(6)位图bitmap

位图

Redis使用字符串对象来表示位数组,因为字符串对象使用的SDS数据结构是二进制安全的,所以程序可以直接使用SDS结构来保存位数组,并使用SDS的操作函数来处理位数组。

位数组的表示

Redis通过如下方式用SDS表示一字节长的位数组:

  • redisObject.type的值为Redis_String表示这是一个字符串对象。
  • sdshdr.len的值为1,表示这个SDS保存了一个一字节长的位数组。
  • buf数组中的buf[0]字节保存了一字节长的位数组。
  • buf数组中的buf[1]字节保存了SDS程序自动追加到值的末尾的空字符“\0”。

需要注意的是,buf数组保存位数组的顺序和我们平时书写位数组的顺序是完全相反的,例如,在buf[0]字节中,各个位的值分别是1、0、1、1、0、0、1、0,这表示buf[0]字节保存的位数组为01001101。

getbit源码解析

void getbitCommand(redisClient *c) {
    robj *o;
    char llbuf[32];
    size_t bitoffset;
    size_t byte, bit;
    size_t bitval = 0;

    // 读取 offset 参数
    if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset) != REDIS_OK)
        return;

    // 查找对象,并进行类型检查
    if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
        checkType(c,o,REDIS_STRING)) return;

    // 计算出 offset 所指定的位所在的字节
    byte = bitoffset >> 3;
    // 计算出位所在的位置
    bit = 7 - (bitoffset & 0x7);

    // 取出位
    if (sdsEncodedObject(o)) {
        // 字符串编码,直接取值
        if (byte < sdslen(o->ptr))
            bitval = ((uint8_t*)o->ptr)[byte] & (1 << bit);
    } else {
        // 整数编码,先转换成字符串,再取值
        if (byte < (size_t)ll2string(llbuf,sizeof(llbuf),(long)o->ptr))
            bitval = llbuf[byte] & (1 << bit);
    }

    // 返回位
    addReply(c, bitval ? shared.cone : shared.czero);
}

GETBIT命令的执行过程如下:

  1. 计算byte = bitoffset >> 3,即bitoffset ÷ 8,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
  2. 计算bit = 7 - (bitoffset & 0x7),bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
  3. 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值。

setbit源码解析

void setbitCommand(redisClient *c) {
    robj *o;
    char *err = "bit is not an integer or out of range";
    size_t bitoffset;
    int byte, bit;
    int byteval, bitval;
    long on;

    // 获取 offset 参数
    if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset) != REDIS_OK)
        return;

    // 获取 value 参数
    if (getLongFromObjectOrReply(c,c->argv[3],&on,err) != REDIS_OK)
        return;

    /* Bits can only be set or cleared... */
    // value 参数的值只能是 0 或者 1 ,否则返回错误
    if (on & ~1) {
        addReplyError(c,err);
        return;
    }

    // 查找字符串对象
    o = lookupKeyWrite(c->db,c->argv[1]);
    if (o == NULL) {

        // 对象不存在,创建一个空字符串对象
        o = createObject(REDIS_STRING,sdsempty());

        // 并添加到数据库
        dbAdd(c->db,c->argv[1],o);

    } else {

        // 对象存在,检查类型是否字符串
        if (checkType(c,o,REDIS_STRING)) return;

        o = dbUnshareStringValue(c->db,c->argv[1],o);
    }

    /* Grow sds value to the right length if necessary */
    // 计算容纳 offset 参数所指定的偏移量所需的字节数
    // 如果 o 对象的字节不够长的话,就扩展它
    // 长度的计算公式是 bitoffset >> 3 + 1
    // 比如 30 >> 3 + 1 = 4 ,也即是为了设置 offset 30 ,
    // 我们需要创建一个 4 字节(32 位长的 SDS)
    byte = bitoffset >> 3;
    o->ptr = sdsgrowzero(o->ptr,byte+1);

    /* Get current values */
    // 将指针定位到要设置的位所在的字节上
    byteval = ((uint8_t*)o->ptr)[byte];
    // 定位到要设置的位上面
    bit = 7 - (bitoffset & 0x7);
    // 记录位现在的值
    bitval = byteval & (1 << bit);

    /* Update byte with new bit value and return original value */
    // 更新字节中的位,设置它的值为 on 参数的值
    byteval &= ~(1 << bit);
    byteval |= ((on & 0x1) << bit);
    ((uint8_t*)o->ptr)[byte] = byteval;

    // 发送数据库修改通知
    signalModifiedKey(c->db,c->argv[1]);
    notifyKeyspaceEvent(REDIS_NOTIFY_STRING,"setbit",c->argv[1],c->db->id);
    server.dirty++;

    // 向客户端返回位原来的值
    addReply(c, bitval ? shared.cone : shared.czero);
}

SETBIT命令的执行过程:

  1. 计算len = offset ÷ 8 + 1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节。
  2. 检查bitarray键保存的位数组的长度是否小于len,如果是的话,将SDS的长度扩展至len字节,并将所有新扩展空间的二进制位的值设置为0。
  3. 计算byte = bitoffset >> 3,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
  4. 计算bit = 7 - (bitoffset & 0x7),bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
  5. 根据byte值和bit值,在bitarray键保存的位数组中定位offset偏移量指定的二进制位,首先将指定二进制位现在值保存在oldvalue变量,然后将新值value设置为这个二进制位的值。
  6. 向客户端返回oldvalue变量的值。

因为setbit命令执行的所有操作都可以在常数时间内完成,所以该命令的时间复杂度位O(1)。

bitcount命令的实现

bitcount命令用于统计给定数组中,值为1的二进制位的数量。bitcount命令要做的工作初看上去并不复杂,但实际上要高效地实现这个命令并不容易,需要用到一些精巧的算法。

二进制位统计算法之遍历算法

实现bitcount命令最简单直接的方法,就是遍历位数组中的每个二进制位,并在遇到值为1的二进制位时,将计数器的值加一。

尽管遍历算法对单个二进制位的检查可以在很短的时间内完成,但重复执行这种检查肯定不是一个高效程序应有的表现,为了让bitcount命令的实现尽可能地高效,程序必须尽可能地增加每次检查所能处理地二进制位地数量,从而减少检查操作执行的次数。

二进制位统计算法之查表算法

优化检查操作的一个办法是使用查表算法:

  • 对于一个有限集合来说,集合元素的排列方式是有限的。
  • 而对于一个有限长度的位数组来说,它能表示的二进制位排列也是有限的。

比如说:
在这里插入图片描述

通过查表,我们只需要执行一次查表操作,就可以检查8个二进制位,和之前介绍的遍历算法相比,查表法的效率提升了8倍。

如果我们创建一个更大的表的话,那么每次查表所能处理的位就会更多,从而减少查表操作执行的次数。

但是因为查表示是典型的空间换时间策略,算法在计算方面节约的时间是通过花费额外的内存换取而来的,节约的时间越多,花费的内存就越大。除了内存大小的问题之外,查表法的效果还会受到CPU缓存的限制:对于固定大小的CPU缓存来说,创建的表格越大,CPU缓存所保存的内容相比整个表格的比例就越少,查表时出现缓存不命中的情况就会越高,缓存的换入和换出操作就会越频繁,最终影响查表法的实际效率。

二进制位统计算法之SWAR算法

bitcount命令要解决的问题——统计一个位数组中非0二进制位的数量,在数学上被称为“计算汉明重量”。

以下是一个处理32位长度位数组的SWAR算法的实现:

uint32_t swar(uint32_t i){
 //步骤1
 i = (i & 0x55555555) + ((i >> 1) & 0x55555555);
 //步骤2
 i = (i & 0x33333333) + ((i >> 2) & 0x33333333);
 //步骤3
 i = (i & 0x0f0f0f0f) + ((i >> 4) & 0x0f0f0f0f);
 //步骤4
 it = (i * (0x01010101) >> 24);
 return i
}

因为swar函数是单纯的计算操作,所以它无需像查表法那样,使用额外的内存。而且swar函数是一个常数复杂度的操作,所以我们可以按照自己的需要,在一次循环中多次执行swar,从而按倍数提升计算汉明重量的效率。

我们可以按照自己的需要,再一次循环中多次执行swar,从而按倍数提升计算汉明重量的效率:例如,我们在一次循环中调用两次swar函数,那么计算汉明重量的效率就从之前的一次循环计算32位提升到了一次循环计算64位。如果在一次循环中调用四次swar函数,那么一次循环级就可以计算128个二进制的汉明重量,这笔每次循环只调用一次swar函数块4倍。

当然,一次循环执行多个swar调用这种优化方式是有极限的:一旦循环中处理数组的大小超过了缓存大小,这种优化的效果就会降低并最终消失。

二进制位统计算法之Redis实现

bitcount命令的实现用到了查表和variable-precision SWAR两种算法:

  • 查表法使用键长为8位的表,表中记录了从0000 0000 到1111 1111在内的所有二进制位的汉明重量。
  • variable-precisionSWAR算法,BITCOUNT命令在每次循环中载入128个二进制位,然后调用四次32位variable-precision算法来计算这128个二进制位的汉明重量。

在执行bitcount命令时,程序会根据未处理的二进制位的数量来决定使用哪种算法:如果未处理的二进制位数量小于128位,那么程序使用查表法来计算二进制位的汉明重量,否则使用variable-precisionSWAR算法来计算二进制位的汉明重量。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

kinron_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值