提起buddy system相信很多人不会陌生,它是一种经典的内存分配算法,大名鼎鼎的Linux底层的内存管理用的就是它。这里不探讨内核这么复杂实现,而仅仅是将该算法抽象提取出来,同时给出一份及其简洁的源码实现,以便定制扩展。
伙伴分配的实质就是一种特殊的“分离适配”,即将内存按2的幂进行划分,相当于分离出若干个块大小一致的空闲链表,搜索该链表并给出同需求最佳匹配的大小。其优点是快速搜索合并(O(logN)时间复杂度)以及低外部碎片(最佳适配best-fit);其缺点是内部碎片,因为按2的幂划分块,如果碰上66单位大小,那么必须划分128单位大小的块。但若需求本身就按2的幂分配,比如可以先分配若干个内存池,在其基础上进一步细分就很有吸引力了。
可以在维基百科上找到该算法的描述,大体如是:
分配内存:
1.寻找大小合适的内存块(大于等于所需大小并且最接近2的幂,比如需要27,实际分配32)
1.如果找到了,分配给应用程序。
2.如果没找到,分出合适的内存块。
1.对半分离出高于所需大小的空闲内存块
2.如果分到最低限度,分配这个大小。
3.回溯到步骤1(寻找合适大小的块)
4.重复该步骤直到一个合适的块
释放内存:
1.释放该内存块
1.寻找相邻的块,看其是否释放了。
2.如果相邻块也释放了,合并这两个块,重复上述步骤直到遇上未释放的相邻块,或者达到最高上限(即所有内存都释放了)。
上面这段文字对你来说可能看起来很费劲,没事,我们看个内存分配和释放的示意图你就知道了:
上图中,首先我们假设我们一个内存块有1024K,当我们需要给A分配70K内存的时候,
后面的,B,C,D都这样,而释放内存时,则会把相邻的块一步一步地合并起来(合并也必需按分裂的逆操作进行合并)。
我们可以看见,这样的算法,用二叉树这个数据结构来实现再合适不过了。
我在网上分别找到cloudwu和wuwenbin写的两份开源实现和测试用例。实际上后一份是对前一份的精简和优化,本文打算从后一份入手讲解,因为这份实现真正体现了“极简”二字,追求突破常规的,极致简单的设计。网友对其评价甚高,甚至可用作教科书标准实现,看完之后回过头来看cloudwu的代码就容易理解了。
分配器的整体思想是,通过一个数组形式的完全二叉树来监控管理内存,二叉树的节点用于标记相应内存块的使用状态,高层节点对应大的块,低层节点对应小的块,在分配和释放中我们就通过这些节点的标记属性来进行块的分离合并。如图所示,假设总大小为16单位的内存,我们就建立一个深度为5的满二叉树,根节点从数组下标[0]开始,监控大小16的块;它的左右孩子节点下标[1~2],监控大小8的块;第三层节点下标[3~6]监控大小4的块……依此类推。
在分配阶段,首先要搜索大小适配的块,假设第一次分配3,转换成2的幂是4,我们先要对整个内存进行对半切割,从16切割到4需要两步,那么从下标[0]节点开始深度搜索到下标[3]的节点并将其标记为已分配。第二次再分配3那么就标记下标[4]的节点。第三次分配6,即大小为8,那么搜索下标[2]的节点,因为下标[1]所对应的块被下标[3~4]占用了。
在释放阶段,我们依次释放上述第一次和第二次分配的块,即先释放[3]再释放[4],当释放下标[4]节点后,我们发现之前释放的[3]是相邻的,于是我们立马将这两个节点进行合并,这样一来下次分配大小8的时候,我们就可以搜索到下标[1]适配了。若进一步释放下标[2],同[1]合并后整个内存就回归到初始状态。
还是看一下源码实现吧,首先是伙伴分配器的数据结构:
1
2
3
4
|
struct
buddy2 {
unsigned size;
unsigned longest[1];
};
|
这里的成员size表明管理内存的总单元数目(测试用例中是32),成员longest就是二叉树的节点标记,表明所对应的内存块的空闲单位,在下文中会分析这是整个算法中最精妙的设计。此处数组大小为1表明这是可以向后扩展的(注:在GCC环境下你可以写成longest[0],不占用空间,这里是出于可移植性考虑),我们在分配器初始化的buddy2_new可以看到这种用法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
struct
buddy2* buddy2_new(
int
size ) {
struct
buddy2* self;
unsigned node_size;
int
i;
if
(size < 1 || !IS_POWER_OF_2(size))
return
NULL;
self = (
struct
buddy2*)ALLOC( 2 * size *
sizeof
(unsigned));
self->size = size;
node_size = size * 2;
for
(i = 0; i < 2 * size - 1; ++i) {
if
(IS_POWER_OF_2(i+1))
node_size /= 2;
self->longest[i] = node_size;
}
return
self;
}
|
整个分配器的大小就是满二叉树节点数目,即所需管理内存单元数目的2倍。一个节点对应4个字节,longest记录了节点所对应的的内存块大小。
内存分配的alloc中,入参是分配器指针和需要分配的大小,返回值是内存块索引。alloc函数首先将size调整到2的幂大小,并检查是否超过最大限度。然后进行适配搜索,深度优先遍历,当找到对应节点后,将其longest标记为0,即分离适配的块出来,并转换为内存块索引offset返回,依据二叉树排列序号,比如内存总体大小32,我们找到节点下标[8],内存块对应大小是4,则offset = (8+1)*4-32 = 4,那么分配内存块就从索引4开始往后4个单位。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
int
buddy2_alloc(
struct
buddy2* 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);
if
(self->longest[index] < size)
return
-1;
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);
}
self->longest[index] = 0;
offset = (index + 1) * node_size - self->size;
while
(index) {
index = PARENT(index);
self->longest[index] =
MAX(self->longest[LEFT_LEAF(index)], self->longest[RIGHT_LEAF(index)]);
}
return
offset;
}
|
在函数返回之前需要回溯,因为小块内存被占用,大块就不能分配了,比如下标[8]标记为0分离出来,那么其父节点下标[0]、[1]、[3]也需要相应大小的分离。将它们的longest进行折扣计算,取左右子树较大值,下标[3]取4,下标[1]取8,下标[0]取16,表明其对应的最大空闲值。
在内存释放的free接口,我们只要传入之前分配的内存地址索引,并确保它是有效值。之后就跟alloc做反向回溯,从最后的节点开始一直往上找到longest为0的节点,即当初分配块所适配的大小和位置。我们将longest恢复到原来满状态的值。继续向上回溯,检查是否存在合并的块,依据就是左右子树longest的值相加是否等于原空闲块满状态的大小,如果能够合并,就将父节点longest标记为相加的和(多么简单!)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
void
buddy2_free(
struct
buddy2* 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;
for
(; self->longest[index] ; index = PARENT(index)) {
node_size *= 2;
if
(index == 0)
return
;
}
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] = MAX(left_longest, right_longest);
}
}
|
上面两个成对alloc/free接口的时间复杂度都是O(logN),保证了程序运行性能。然而这段程序设计的独特之处就在于使用加权来标记内存空闲状态,而不是一般的有限状态机,实际上longest既可以表示权重又可以表示状态,状态机就毫无必要了,所谓“少即是多”嘛!反观cloudwu的实现,将节点标记为UNUSED/USED/SPLIT/FULL四个状态机,反而会带来额外的条件判断和管理实现,而且还不如数值那样精确。从逻辑流程上看,wuwenbin的实现简洁明了如同教科书一般,特别是左右子树的走向,内存块的分离合并,块索引到节点下标的转换都是一步到位,不像cloudwu充斥了大量二叉树的深度和长度的间接计算,让代码变得晦涩难读,这些都是longest的功劳。一个“极简”的设计往往在于你想不到的突破常规思维的地方。
这份代码唯一的缺陷就是longest的大小是4字节,内存消耗大。但cloudwu的博客上有人提议用logN来保存值,这样就能实现uint8_t大小了,看,又是一个“极简”的设计!
说实话,很难在网上找到比这更简约更优雅的buddy system实现了——至少在Google上如此。
了解 Linux 内存管理的方式
良好的操作系统性能部分依赖于操作系统有效管理资源的能力。在过去,堆内存管理器是实际的规范,但是其性能会受到内存碎片和内存回收需求的影响。现在,Linux® 内核使用了源自于 Solaris 的一种方法,但是这种方法在嵌入式系统中已经使用了很长时间了,它是将内存作为对象按照大小进行分配。本文将探索 slab 分配器背后所采用的思想,并介绍这种方法提供的接口和用法。
内存管理的目标是提供一种方法,为实现各种目的而在各个用户之间实现内存共享。内存管理方法应该实现以下两个功能:
内存管理实际上是一种关于权衡的零和游戏。您可以开发一种使用少量内存进行管理的算法,但是要花费更多时间来管理可用内存。也可以开发一个算法来有效地管理内存,但却要使用更多的内存。最终,特定应用程序的需求将促使对这种权衡作出选择。
每个内存管理器都使用了一种基于堆的分配策略。在这种方法中,大块内存(称为 堆)用来为用户定义的目的提供内存。当用户需要一块内存时,就请求给自己分配一定大小的内存。堆管理器会查看可用内存的情况(使用特定算法)并返回一块内存。搜索过程中使用的一些算法有first-fit(在堆中搜索到的第一个满足请求的内存块 )和 best-fit(使用堆中满足请求的最合适的内存块)。当用户使用完内存后,就将内存返回给堆。
这种基于堆的分配策略的根本问题是碎片(fragmentation)。当内存块被分配后,它们会以不同的顺序在不同的时间返回。这样会在堆中留下一些洞,需要花一些时间才能有效地管理空闲内存。这种算法通常具有较高的内存使用效率(分配需要的内存),但是却需要花费更多时间来对堆进行管理。
另外一种方法称为 buddy memory allocation,是一种更快的内存分配技术,它将内存划分为 2 的幂次方个分区,并使用 best-fit 方法来分配内存请求。当用户释放内存时,就会检查 buddy 块,查看其相邻的内存块是否也已经被释放。如果是的话,将合并内存块以最小化内存碎片。这个算法的时间效率更高,但是由于使用 best-fit 方法的缘故,会产生内存浪费。
本文将着重介绍 Linux 内核的内存管理,尤其是 slab 分配提供的机制。
Linux 所使用的 slab 分配器的基础是 Jeff Bonwick 为 SunOS 操作系统首次引入的一种算法。Jeff 的分配器是围绕对象缓存进行的。在内核中,会为有限的对象集(例如文件描述符和其他常见结构)分配大量内存。Jeff 发现对内核中普通对象进行初始化所需的时间超过了对其进行分配和释放所需的时间。因此他的结论是不应该将内存释放回一个全局的内存池,而是将内存保持为针对特定目而初始化的状态。例如,如果内存被分配给了一个互斥锁,那么只需在为互斥锁首次分配内存时执行一次互斥锁初始化函数(mutex_init
)即可。后续的内存分配不需要执行这个初始化函数,因为从上次释放和调用析构之后,它已经处于所需的状态中了。这里想说的主要是我们得到一块很原始的内存,必须要经过一定的初始化之后才能用于特定的目的。当我们用slab时,内存不会被释放到全局内存池中,所以还是处于特定的初始化状态的。这样就能加速我们的处理过程。
Linux slab 分配器使用了这种思想和其他一些思想来构建一个在空间和时间上都具有高效性的内存分配器。
图 1 给出了 slab 结构的高层组织结构。在最高层是 cache_chain
,这是一个 slab 缓存的链接列表。这对于 best-fit 算法非常有用,可以用来查找最适合所需要的分配大小的缓存(遍历列表)。cache_chain
的每个元素都是一个 kmem_cache
结构的引用(称为一个 cache)。它定义了一个要管理的给定大小的对象池。
每个缓存都包含了一个 slabs 列表,这是一段连续的内存块(通常都是页面)。存在 3 种 slab:
slabs_full
slabs_partial
slabs_empty
注意 slabs_empty
列表中的 slab 是进行回收(reaping)的主要备选对象。正是通过此过程,slab 所使用的内存被返回给操作系统供其他用户使用。
slab 列表中的每个 slab 都是一个连续的内存块(一个或多个连续页),它们被划分成一个个对象。这些对象是从特定缓存中进行分配和释放的基本元素。注意 slab 是 slab 分配器进行操作的最小分配单位,因此如果需要对 slab 进行扩展,这也就是所扩展的最小值。通常来说,每个 slab 被分配为多个对象。
由于对象是从 slab 中进行分配和释放的,因此单个 slab 可以在 slab 列表之间进行移动。例如,当一个 slab 中的所有对象都被使用完时,就从slabs_partial
列表中移动到 slabs_full
列表中。当一个 slab 完全被分配并且有对象被释放后,就从 slabs_full
列表中移动到slabs_partial
列表中。当所有对象都被释放之后,就从 slabs_partial
列表移动到 slabs_empty
列表中。
与传统的内存管理模式相比, slab 缓存分配器提供了很多优点。首先,内核通常依赖于对小对象的分配,它们会在系统生命周期内进行无数次分配。slab 缓存分配器通过对类似大小的对象进行缓存而提供这种功能,从而避免了常见的碎片问题。slab 分配器还支持通用对象的初始化,从而避免了为同一目而对一个对象重复进行初始化。最后,slab 分配器还可以支持硬件缓存对齐和着色,这允许不同缓存中的对象占用相同的缓存行,从而提高缓存的利用率并获得更好的性能。
回页首
现在来看一下能够创建新 slab 缓存、向缓存中增加内存、销毁缓存的应用程序接口(API)以及 slab 中对对象进行分配和释放操作的函数。
第一个步骤是创建 slab 缓存结构,您可以将其静态创建为:
struct struct kmem_cache *my_cachep;
然后其他 slab 缓存函数将使用该引用进行创建、删除、分配等操作。kmem_cache
结构包含了每个中央处理器单元(CPU)的数据、一组可调整的(可以通过 proc 文件系统访问)参数、统计信息和管理 slab 缓存所必须的元素。
内核函数 kmem_cache_create
用来创建一个新缓存。这通常是在内核初始化时执行的,或者在首次加载内核模块时执行。其原型定义如下:
struct kmem_cache *
kmem_cache_create( const char *name, size_t size, size_t align,
unsigned long flags;
void (*ctor)(void*, struct kmem_cache *, unsigned long),
void (*dtor)(void*, struct kmem_cache *, unsigned long));
name
参数定义了缓存名称,proc 文件系统(在 /proc/slabinfo 中)使用它标识这个缓存。 size
参数指定了为这个缓存创建的对象的大小,align
参数定义了每个对象必需的对齐。 flags
参数指定了为缓存启用的选项。这些标志如表 1 所示。
选项 | 说明 |
---|---|
SLAB_RED_ZONE | 在对象头、尾插入标志,用来支持对缓冲区溢出的检查。 |
SLAB_POISON | 使用一种己知模式填充 slab,允许对缓存中的对象进行监视(对象属对象所有,不过可以在外部进行修改)。 |
SLAB_HWCACHE_ALIGN | 指定缓存对象必须与硬件缓存行对齐。 |
ctor
和 dtor
参数定义了一个可选的对象构造器和析构器。构造器和析构器是用户提供的回调函数。当从缓存中分配新对象时,可以通过构造器进行初始化。
在创建缓存之后, kmem_cache_create
函数会返回对它的引用。注意这个函数并没有向缓存分配任何内存。相反,在试图从缓存(最初为空)分配对象时,refill 操作将内存分配给它。当所有对象都被使用掉时,也可以通过相同的操作向缓存添加内存。
内核函数 kmem_cache_destroy
用来销毁缓存。这个调用是由内核模块在被卸载时执行的。在调用这个函数时,缓存必须为空。
void kmem_cache_destroy( struct kmem_cache *cachep );
要从一个命名的缓存中分配一个对象,可以使用 kmem_cache_alloc
函数。调用者提供了从中分配对象的缓存以及一组标志:
void kmem_cache_alloc( struct kmem_cache *cachep, gfp_t flags );
这个函数从缓存中返回一个对象。注意如果缓存目前为空,那么这个函数就会调用 cache_alloc_refill
向缓存中增加内存。kmem_cache_alloc
的 flags 选项与 kmalloc
的 flags 选项相同。表 2 给出了标志选项的部分列表。
标志 | 说明 |
---|---|
GFP_USER | 为用户分配内存(这个调用可能会睡眠)。 |
GFP_KERNEL | 从内核 RAM 中分配内存(这个调用可能会睡眠)。 |
GFP_ATOMIC | 使该调用强制处于非睡眠状态(对中断处理程序非常有用)。 |
GFP_HIGHUSER | 从高端内存中分配内存。 |
内核函数 kmem_cache_zalloc
与 kmem_cache_alloc
类似,只不过它对对象执行 memset
操作,用来在将对象返回调用者之前对其进行清除操作。
要将一个对象释放回 slab,可以使用 kmem_cache_free
。调用者提供了缓存引用和要释放的对象。
void kmem_cache_free( struct kmem_cache *cachep, void *objp );
内核中最常用的内存管理函数是 kmalloc
和 kfree
函数。这两个函数的原型如下:
void *kmalloc( size_t size, int flags ); void kfree( const void *objp );
注意在 kmalloc
中,惟一两个参数是要分配的对象的大小和一组标志(请参看 表 2 中的部分列表)。但是 kmalloc
和 kfree
使用了类似于前面定义的函数的 slab 缓存。kmalloc
没有为要从中分配对象的某个 slab 缓存命名,而是循环遍历可用缓存来查找可以满足大小限制的缓存。找到之后,就(使用 __kmem_cache_alloc
)分配一个对象。要使用 kfree
释放对象,从中分配对象的缓存可以通过调用 virt_to_cache
确定。这个函数会返回一个缓存引用,然后在 __cache_free
调用中使用该引用释放对象。
slab 缓存 API 还提供了其他一些非常有用的函数。 kmem_cache_size
函数会返回这个缓存所管理的对象的大小。您也可以通过调用kmem_cache_name
来检索给定缓存的名称(在创建缓存时定义)。缓存可以通过释放其中的空闲 slab 进行收缩。这可以通过调用kmem_cache_shrink
实现。注意这个操作(称为回收)是由内核定期自动执行的(通过 kswapd
)。
unsigned int kmem_cache_size( struct kmem_cache *cachep ); const char *kmem_cache_name( struct kmem_cache *cachep ); int kmem_cache_shrink( struct kmem_cache *cachep );
回页首
下面的代码片断展示了创建新 slab 缓存、从缓存中分配和释放对象然后销毁缓存的过程。首先,必须要定义一个 kmem_cache
对象,然后对其进行初始化(请参看清单 1)。这个特定的缓存包含 32 字节的对象,并且是硬件缓存对齐的(由标志参数 SLAB_HWCACHE_ALIGN
定义)。
static struct kmem_cache *my_cachep;
static void init_my_cache( void )
{
my_cachep = kmem_cache_create(
"my_cache", /* Name */
32, /* Object Size */
0, /* Alignment */
SLAB_HWCACHE_ALIGN, /* Flags */
NULL, NULL ); /* Constructor/Deconstructor */
return;
}
使用所分配的 slab 缓存,您现在可以从中分配一个对象了。清单 2 给出了一个从缓存中分配和释放对象的例子。它还展示了两个其他函数的用法。
int slab_test( void ) { void *object; printk( "Cache name is %s\n", kmem_cache_name( my_cachep ) ); printk( "Cache object size is %d\n", kmem_cache_size( my_cachep ) ); object = kmem_cache_alloc( my_cachep, GFP_KERNEL ); if (object) { kmem_cache_free( my_cachep, object ); } return 0; }
最后,清单 3 演示了 slab 缓存的销毁。调用者必须确保在执行销毁操作过程中,不要从缓存中分配对象。
static void remove_my_cache( void )
{
if (my_cachep) kmem_cache_destroy( my_cachep );
return;
}
回页首
proc 文件系统提供了一种简单的方法来监视系统中所有活动的 slab 缓存。这个文件称为 /proc/slabinfo,它除了提供一些可以从用户空间访问的可调整参数之外,还提供了有关所有 slab 缓存的详细信息。当前版本的 slabinfo 提供了一个标题,这样输出结果就更具可读性。对于系统中的每个 slab 缓存来说,这个文件提供了对象数量、活动对象数量以及对象大小的信息(除了每个 slab 的对象和页面之外)。另外还提供了一组可调整的参数和 slab 数据。
要调优特定的 slab 缓存,可以简单地向 /proc/slabinfo 文件中以字符串的形式回转 slab 缓存名称和 3 个可调整的参数。下面的例子展示了如何增加 limit 和 batchcount 的值,而保留 shared factor 不变(格式为 “cache name limit batchcount shared factor”):
# echo "my_cache 128 64 8" > /proc/slabinfo
limit
字段表示每个 CPU 可以缓存的对象的最大数量。 batchcount
字段是当缓存为空时转换到每个 CPU 缓存中全局缓存对象的最大数量。shared
参数说明了对称多处理器(Symmetric MultiProcessing,SMP)系统的共享行为。
注意您必须具有超级用户的特权才能在 proc 文件系统中为 slab 缓存调优参数。
回页首
对于小型的嵌入式系统来说,存在一个 slab 模拟层,名为 SLOB。这个 slab 的替代品在小型嵌入式 Linux 系统中具有优势,但是即使它保存了 512KB 内存,依然存在碎片和难于扩展的问题。在禁用 CONFIG_SLAB
时,内核会回到这个 SLOB 分配器中。更多信息请参看 参考资料 一节。
回页首
slab 缓存分配器的源代码实际上是 Linux 内核中可读性较好的一部分。除了函数调用的间接性之外,源代码也非常直观,总的来说,具有很好的注释。如果您希望了解更多有关 slab 缓存分配器的内容,建议您从源代码开始,因为它是有关这种机制的最新文档。 下面的 参考资料 一节提供了介绍 slab 缓存分配器的参考资料,但是不幸的是就目前的 2.6 实现来说,这些文档都已经过时了。