Slab分配器:一种对象缓存内核内存分配器
Jeff bonwick
Sun
译者:陈盛荣(wengermail@gmail.com)
摘要:
本文提出了SunOS 5.4内核内存分配器的设计概要。这个分配器是基于一套对象缓存primitive机制。这种机制通过保留复杂内存对象的状态来减少分配他们的时间。对于无状态的内存(例如:数据页面和临时缓冲区),这些相同的primitives也被证明是有效的,因为这些内存的空间效率很高、(分配)速度很快。分配器的缓存对象根据全局的内存压力动态响应,实施对象着色方案改进系统整体的缓存利用率和总线平衡。分配器同时也提供一些统计和调试的函数,能够检测系统大范围的问题。
1. 介绍
分配和释放对象是内核操作中最常见的。因此一种快速的内核内存分配器显得很重要。然而很多情况下,初始化和销毁对象的时间大于分配和释放内存对象的时间。因此,改进分配器很有用,甚至通过缓存经常用的内存对象(用的过程中得以保存基本结构)可以获得更多的好处。
本文首先讨论对象缓存,因为所需要的接口将对剩下的分配器整形(??)。接下来描述实现细节。第4部分描述缓冲区(buffer)地址分布在系统整体缓存(cache)利用和总线平衡上的作用,以及表明一种简单的着色模式能改进这两种情况。第5部分跟几个著名的内核内存分配器做性能比较,发现本分配器在空间和时间上普遍占优势。最后,第6部分描述了分配器的调试特性,该特性能在检查出整个系统各种各样的问题。
2 对象缓存
对象缓存是一种处理经常被分配和释放的对象的技术。这种思想是:在对象被利用过程中,保存对象的初始化状态(刚创建时的状态)即对象的不变部分,从而不必要销毁对象以至于每次用到对象的时候,都重新创建一次。例如:一个包含互斥变量的对象,只在对象被分配的时候执行一次mutex_init()。对象能够被释放和多次重新创建的时候,避免每次都执行mutex_destroy ()和mutex_init()的开销。通常适合作为创建状态的有:对象的嵌套锁,条件变量、引用计数、只读对象和数据的列表等。
由于创建对象的开销可能远远大于内存分配的开销,因此缓存很重要。例如,在SPARCstation-2上跑SunOS5.4内核,分配器在分配和释放流头部中能减少5.7到33微秒的时间。大部分的减少时间都是由于对象缓存,如下表所示:
缓存对于多线程的环境特别有利,由于许多最常见的被分配对象包含一个或者多个嵌套锁、条件变量和其他可创建(contructible)变量。
设计对象缓存的直接做法:
分配一个对象:
if(缓存中有该对象)
直接利用(不必再创建了)
else
{
分配内存;
创建对象;
}
释放一个对象:
返回到缓存中(没有调用析构函数)
向缓存要内存(译注:内存不够用的情况):
从缓存中取出一些对象;(译注: 可以用FIFO、LRU等)
销毁对象
释放一些额外的内存
当对象第一次进入缓存的时候,必须保证对象的创建状态只初始化一次。一旦缓存(索引)是稠密的,分配和释放对象将是快速的操作。
2.1 一个例子
考虑下面的数据结构
kmutex_t foo_lock;
kcondvar_t foo_cv;
struct bar * foo_barlist;
int foo_refcnt;
};
假设foo结构必须在没有引用计数(foot_refcnt)和没有悬挂bar 事件(不管是什么)即(foo_barlist == NULL)时,才能释放foo。那么动态分配foo结构的生命周期将是这样:
KM_SLEEP);
mutex_init( & foo -> foo_lock, ...);
cv_init( & foo -> foo_cv, ...);
foo -> foo_refcnt = 0 ;
foo -> foo_barlist = NULL;
use foo;
ASSERT(foo -> foo_barlist == NULL);
ASSERT(foo -> foo_refcnt == 0 );
cv_destroy( & foo -> foo_cv);
mutex_destroy( & foo -> foo_lock);
kmem_free(foo);
注意到每次用到foo结构的时候,我们执行了一系列没有建树但是时间开销昂贵的操作。所有这些额外的工作(在这个例子中:除了use foo之外)都能通过对象缓存砍掉(eliminated)。
2.2 中央分配器的对象缓存案例
当然,任何子系统都可以根据上面的描述自己实现对象缓存的算法,而不用借助中央分配器。然而,这种方法有几个不好的地方:
1) 在对象缓存(object cache)和其他系统之间有一种微妙(natural tension)的关系。对象缓存需要长久持有内存,而其他系统需要这些内存。私自管理缓存不能很好处理这种关系。他们有限的看到系统整体的内存需求和看不到其它的内存需求。类似的,其它系统不知道对象缓存的存在因此没有办法从他们那“pull”内存。
2) 由于私有缓存实现可能绕过中央分配器,也绕过分配器要处理的统计机制和调试特性。这使得操作系统更难监测和调试(内存问题)。
3) 对一个普通问题有很多一种解决方案的不同实例,将增加内核代码的大小和维护开销。
分配器与客户端之间的协作,对象缓存相对于标准的kmem_alloc( 9F )/kmem_free( 9F )接口允许的还要高。接下来的部分提供一个支持在中央分配器中缓存创建对象的接口。
2.3 缓存对象接口
接口必须遵循下面两点:
(A)对象的描述(名字,大小,对齐, 构造函数以及析构函数)属于客户端,而不在中央分配器。分配器不应该知道sizeof (struct inode)是一个有效的池大小。例如:这种假想是不可靠的,因为不可预测第三方驱动设备、流模块以及文件系统的需求。
(B) 内存管理策略属于中央分配器,而不是客户端。客户端只需要快速的分配和释放对象。他们不必关心怎样有效的管理潜在的内存。
按照规则A,对象缓存创建必须是客户端驱动的,同时包括对象的详细描述。
(1)
创建一系列对象的高速缓存,包括每个规格大小和所对齐的对齐边界。对齐边界根据最小的允许值四舍五入,所以对于没有特定的对齐要求,aligin可以等于0。 名字唯一区别缓存中统计和调试信息。构造函数构造对象(对象初始化的时候执行一次)并放入缓存;如果适用,析构函数不一定执行。构造函数和析构函数带有参数size_t,以支持相似高速缓存族,例如:流消息。kmem_cache_create返回一个访问缓存的指针(不透明的描述符)。
接着,按照规则B,客户端只需要分配和释放对象两个函数
(2)该函数从缓存中获取一个对象。但对象必须处于已构造(初始)状态。参数flags可以是KM_SLEEP或者KM_NOSLEEP,表明在当前没有可用缓存对象的情况下,是否等待。
(3)
该函数返回一个对象进入缓存。对象必须仍处于已构造(初始)状态。
最后,如果不再需要缓存,客户端可以销毁对象。
(4)
销毁缓存和回收相关的资源。所有已分配的对象必须已经在缓存中。
这个接口允许我们实现最适合客户端需求的灵活分配器。从这个意义上来说,是个定制的分配器。然而,这并不一定都像大部分定制分配器做的那样,建立编译期知识[Bozman 84A , Grunwald 93A , Margolin71]或者猜测自适应接口方法[Bozman84B,
Leverett82, Oldehoeft85]。而是对象缓存接口允许客户端根据自己的需要快速指定分配服务。
2.4 一个例子
下面的例子示范了2.1所介绍的foo对象在对象缓存中的应用。构造函数和析构函数如下:
{
struct foo * foo = buf;
mutex_init( & fo cv_init( & foo -> foo_cv, ...);
foo -> foo_refcnt = 0 ;
foo -> foo_barlist = NULL; o -> foo_lock, …);
}
void
foo_destructor( void * buf, int size)
{
struct foo * foo = buf;
ASSERT(foo -> foo_barlist == NULL);
ASSERT(foo -> foo_refcnt == 0 );
cv_destroy( & foo -> foo_cv);
mutex_destroy( & foo -> foo_lock);
}
创建foo对象的缓存
分配、利用和释放foo对象
foo对象的速度很快,因为分配器经常只需要从缓存中取出已构造的对象。foo_contructor 和 foo_destructor将被分别调用从缓存中取对象和存放对象。
use foo;
kmem_cache_free(foo_cache, foo);
这使得分配foo对象的速度很快,因为分配器经常只需要从缓存中取出已构造的对象。foo_contructor 和 foo_destructor将被分别调用从缓存中取对象和存放对象。
上面的例子示范了对象缓存的一个有用的副作用:通过从热路径??(经常执行的指令)中移除极少执行的构造函数和析构函数,来利用缓存对象,达到减少代码的指令缓存路径。