前段时间为了做一个分享,实现了一个简单的搜索引擎(https://github.com/imaben/ts-engine), 其中有一个标记删除的地方用到了bitmap,刚开始没有想那么复杂,真正写起来,发现需要注意的一些细节还是不少的,所以在此记录一下开发过程中的一些心得,主要是一些位运算相关的点。
代码链接:https://github.com/imaben/ts-engine/blob/master/bitmap.h
首先说一下bitmap的原理,bitmap的应用非常广泛,它通常是以bit位来标记某个id的状态,优点是占用空间小,查询效率高,1byte = 8bit
,假如一共1000万的数据量,总共算下来占用的空间也只有 10000000/8/1024 = 1220KB
, 刚刚有1MB多一点。查询时直接根据id来找到对应的byte,然后做位于运算,直接可以拿到结果,由于不涉及到复杂运算,寻址的范围也很小,所以整体的查询效率非常高。
但这种方式本身也有局限性,就是要标记的id最好是从1开始自增的情况,如果id完全没有规律,则很容易出现内存空洞的情况。
bitmap结构体的定义如下:
1 | typedef struct { |
结构体本身比较简单,一个uint8_t(char)类型的指针,指向一块保存bitmap数据的内存,一个uint64_t类型的长度,用来标记c的长度。有一些做法是采用int型指针来保存数据,但在跨平台时,可能要考虑大小端的问题,但个人感觉没有本质上的区别。
第一步要做的就是要保证每次set时c的长度是足够的,所以需要在每次set时,都需要检查一下c的长度,以防止内存访问越界的情况发生。但是为了减少内存频繁分配的开销,一般都会有一些预分配的做法,比如按照128字节做对齐,当小于128字节时,也分配128字节的大小,当是129字节时,就分配256字节,以此类推。这样做有两点好处,一是避免递增访问时,内存频繁分配,二是保证整个buffer的内存大小总是cpu cacheline的整倍数,从而提高cpu缓存的存取性能。
首先要实现上面提到的预分配逻辑:假如按照比较传统的写法应该是这样(伪代码):
1 |
|
但这样显然不太优雅,性能上开销也比较大,如果通过位运算来解决呢?
举两个例子,假如有10和129,我们需要最终转换为128和256,如下表:
10进制 | 2进制 | 目标2进制 |
---|---|---|
10 | 00001010 | 10000000 |
129 | 10000001 | 100000000 |
拿10
来举例,既然要做128字节对齐,我们不妨先把它加个128:
1 | 10 + 128 = 138 = 10001010 |
跟我们的目标值已经很像了,我们只需要把最高位1后面的所有的非0,全部置为0就达到目的了,可以使用反码加位与的操作解决,如下:
1 | 128 - 1 = 01111111 // 先将需要置为0的位数全部置为1 |
但是这个地方有一个极端情况,就是当值正好是128时,用上面的算法算出来就是256了,所以再处理一下就是
1 | (10 + 128 - 1) & (~128 - 1) |
这样就解决了上面所说的问题。
最终预分配的代码如下:
1 |
|
小细节:
在上面按位取反的时候,需要注意32位与64位的问题,假如有以下函数:
1 | uint64_t mem_align(uint64_t i, int align) |
看似很和谐,其实隐藏着一个大坑,我们将这段代码编译成汇编(GCC),生成如下代码:
1 | _mem_align: ## @mem_align |
可以看到在15行至17行在对align做-1并取反的操作中,竟然是在32位寄存器中进行的,这样就会导致当i的值超过32位时,取反后的结果总是等于0,进而得到的结果也会是0,解决办法很简单,就是把align的类型也改为64位即可。
这种情况不同编译器可能会有不同的行为,所以为了稳妥起见,故在定义128长度的时候,改成了64位的128LL。
—–分割线—–
解决完内存的问题,再看一下怎么根据id定位到id应该存储的byte上,因为1byte = 8bit,所以也就是单纯的除以8即可,用位运算操作数右移三位即可:
1 |
找到了对应的字节,再要在字节中找到每N位,直接对8取模即可:
首先8的二进制为1000,模除只需要将二进制后三位置为1,再与操作数做位与操作:
1 |
需要注意的是这种方式只有在模除数是2的n次方时有效。
—–分割线—–
下面就是操作set、unset和get了,set最简单:
1 |
|
直接通过dig找到对应byte后,将1左移取模后的第n位,做位或操作即可。
unset操作如下:
1 |
|
先判断如果unset操作数的所在的字节已经超过buffer的总长度,直接忽略。
然后为了在unset时不影响当前byte的其它位,故需要左移后做个取反,再与原数做位于操作。
get操作如下:
1 |
|
和unset一样做一个长度的基本判断,然后按照对应byte,将对应的bit右移到最后一位,与1做位于操作。
至此,bitmap的实现完成。