位图
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
命令的执行过程如下:
- 计算
byte = bitoffset >> 3
,即bitoffset ÷ 8,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。 - 计算
bit = 7 - (bitoffset & 0x7)
,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。 - 根据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
命令的执行过程:
- 计算
len = offset ÷ 8 + 1
,len值记录了保存offset偏移量指定的二进制位至少需要多少字节。 - 检查
bitarray
键保存的位数组的长度是否小于len,如果是的话,将SDS的长度扩展至len字节,并将所有新扩展空间的二进制位的值设置为0。 - 计算
byte = bitoffset >> 3
,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。 - 计算bit = 7 - (bitoffset & 0x7),bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
- 根据byte值和bit值,在
bitarray
键保存的位数组中定位offset偏移量指定的二进制位,首先将指定二进制位现在值保存在oldvalue变量,然后将新值value设置为这个二进制位的值。 - 向客户端返回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算法来计算二进制位的汉明重量。