伙伴系统的一种简单实现
什么是伙伴系统
伙伴系统是一种将资源分配出去,再回收回来的一种方法,典型的使用场景是内存池。
伙伴系统的基本原理
当要实现内存管理时,通常会面对分配粒度的问题。要对内存进行分配,如果固定粒度的话,一定会造成内部碎片,而且根据放置算法的不同,还表现出不同的特点。
伙伴分配器可以实现这样的作用:将内存将2的幂进行划分:
- 需要分配内存时如果没有合适的内存块,会对半切分内存块直到分离出合适大小的内存块为止,最后再将其返回。
- 需要释放内存时,会寻找相邻的块,如果其已经释放了,就将这俩合并,再递归这个过程,直到无法再合并为止
伙伴系统的实现
伙伴分配器的数据结构在逻辑上的表示就像是一个完全二叉树。大概像这样:
当然实际编码过程中并不会使用一个struct TreeNode的形式去把二叉树的各个节点用指针连起来,因为是这个树一定是完全二叉树,所以可以使用一个数组来表示树的结构。这里分析一个COOLSHELL网站上提出极简实现。
首先数据结构长这样:
struct buddy {
unsigned size;
unsigned longest[0];
};
在C中,这就是一个变长数组的定义。这个实现中,默认要管理的内存是2的幂次。这里的size是指buffer的长度,longest数组的长度是2*size-1,因为这是满二叉树的结点数,所以整个buddy的内存占用大小就是2*size-1,longest数组每个元素都对应一个二叉树的结点,元素的值代表对应内存块的空闲容量。据此,初始化的代码如下:
inline bool is_power_of_2(unsigned x) {
return !(x & (x - 1));
}
struct buddy* buddy_new(int size) {
struct buddy* self;
unsigned node_size;
int i;
if (size < 1 || !is_power_of_2(size)) {
return NULL;
}
self = (struct buddy*)malloc(2 * size * sizeof(unsigned));
self->size = size;
node_size = size * 2;
// 遍历每个二叉树结点,为其赋值
for (i = 0; i < 2 * size - 1; ++i) {
// 注意:1也是2的幂
if (is_power_of_2(i + 1)) {
node_size /= 2;
}
// 第一层的结点的值是2*size,第二层是size,第三次是size/2,以此类推
// 每个值代表的是空闲内存块的数量
self->longest[i] = node_size;
}
return self;
}
可以看到,整个管理其所占的内存块大小是需要管理内存大小的两倍。
在完成了初始化后,下面实现从伙伴分配器中分配内存的过程。基本思路是:
- 首先需要将要分配的size向上调整为2的幂次,比如33只能调整到64,并检测是否超过最大限度
- 进行深度优先搜索,搜到一个最合适的块,将longest的元素值标记为0
- 最后将这个内存块的偏移返回
unsigned fixsize(unsigned size) {
size |= size >> 1;
size |= size >> 2;
size |= size >> 4;
size |= size >> 8;
size |= size >> 16;
return size + 1;
}
inline unsigned left_leaf(unsigned index) {
return index * 2 + 1;
}
inline unsigned right_leaf(unsigned index) {
return index * 2 + 2;
}
inline unsigned parent(unsigned index) {
return (index + 1) / 2 - 1;
}
int buddy_alloc(struct buddy *self, int size) {
unsigned index = 0;
unsigned node_size;
unsigned offset = 0;
if(self == NULL) {
return -1;
}
if(size <= 0) {
size = 1;
} else if(!IS_POWER_OF_2(size)) {
size = fixsize(size); // 向上调整到2的幂次
}
// 如果根节点下挂的size都不够分配,就返回失败
if(self->longest[index] < size) {
return -1;
}
// 由大到小搜索最符合size的结点
// 并在搜索的过程中,更新index,优先使用左孩子
for(node_size = self->size; node_size != size; node_size /= 2) {
if(self->longest[left_leaf(index)] >= size) {
index = left_leaf(index);
} else {
index = right_leaf(index);
}
}
// 找到对应的结点了,就将其管理的空闲内存块数量标记为0
self->longest[index] = 0;
// 这里的node_size是对应层的结点所管理的内存的大小,而index是结点的编
// 根据这个算法,offset恰好是分配内存的起始/索引,从offset往后数size个字节的内存都是可用的
offset = (index + 1) * node_size - self->size;
// 因为更新了longest[index]的标记,所以需要更新它上层所有父节点的标记
// 其中的原理是,如果小块内存被占用,那么大块内存就不满足原来的可用状态了
while(index) {
index = parent(index);
self->longest[index] = std::max(self->longest[left_leaf(index)],
self->longest[right_leaf(index)]);
}
return offset;
}
释放的接口实现的基本思路是:
- 函数传入之前buddy_alloc返回的offset,也就是地址索引,确保是有效值
- 和buddy_alloc的方向相反,做回溯,恢复结点的值
- 检查需要合并的内存块
void buddy_free(struct buddy *self, int offset) {
unsigned node_size, index = 0;
unsigned left_longest, right_longest;
assert(self && offset >= 0 && offset < size);
node_size = 1;
index = offset + self->size - 1;
// 找到被占用的那个内存块,并更新node_size,即那层的结点所管理的内存块的大小
for(; self->longest[index] != 0; index = parent(index)) {
node_size *= 2;
// 如果根节点的都被占用了,则直接返回
if(index == 0) {
return;
}
}
// 归还内存,将longest恢复到原来结点的值
self->longest[index] = node_size;
// 接下来恢复被占用结点的所有父节点
while(index) {
index = parent(index);
node_size *= 2;
// 在向上回溯的过程中,如果发现左右孩子的元素加起来等于自己,则说明需要合并
// 否则取他们中大的那个
left_longest = self->longest[left_leaf(index)];
right_longest = self->longest[right_leaf(index)];
if(left_longest + right_longest == node_size) {
self->longest[index] = node_size;
} else {
self->longest[index] = std::max(left_longest, right_longest);
}
}
}
怎么用?
说到底的是伙伴系统只是个辅助系统,buddy的longest数组并不能当作内存池来用,而是要自己额外分配一段和buddy的size大小符合的内存与buddy搭配使用。
比如拿到了buddy_alloc返回的offset,就取实际内存池的[offset, offset + size]这段内存使用。