Linux 所使用的 slab 分配器的基础是 Jeff Bonwick 为 SunOS 操作系统首次引入的一种算法。Jeff 的分配器是围绕对象缓存进行的。在内核中,会为有限的对象集(例如文件描述符和其他常见结构)分配大量内存。Jeff 发现对内核中普通对象进行初始化所需的时间超过了对其进行分配和释放所需的时间。因此他的结论是不应该将内存释放回一个全局的内存池,而是将内存保持为针对特定目而初始化的状态。
我们来看看rust中如何实现这一算法,也借此学习rust的应用技术。
源码: carllerche/slab:Preallocate memory for values of a given type.
Slot结构(抽屉)
先来看一个基础数据结构Slot,这个数据结构对后面理解Slab至关重要。
enum Slot {
Empty(usize),
Filled(T),
Invalid,
}
可以把Slot想象为一个抽屉,有三种状态:
Empty 空抽屉,什么都没有装。Empty(usize) 一个带有编号的空抽屉。
Filled 装满的抽屉,Filled(T) 一个装了一个T类型对象的抽屉。
Invalid 无效的抽屉,这个抽屉当前处于无效状态。
一个Slot可以为空,可以无效,可以装一个T类型对象且只能装一个。
Slab结构(大柜子)
接着来看Slab数据结构,这个数据结构代表着从内存中申请的一大块内存,这一大块内存中可以存储许多相同类型的对象,就像一个包含许多抽屉的大柜子。
/// A preallocated chunk of memory for storing objects of the same type.
pub struct Slab {
// Chunk of memory
entries: Vec>,
// Number of Filled elements currently in the slab
len: usize,
// Offset of the next available slot in the slab. Set to the slab's
// capacity when the slab is full.
next: usize,
_marker: PhantomData,
}
Slab就相当于一长排抽屉。包含四个成员:
entries: 是一个列表,每个元素为Slot类型,就相当于是一长排抽屉。
len:这一排抽屉的个数。
next:从注释上看,这是下一个可用的抽屉。但什么情况才叫可用,我们现在不知道,先留着,后面了解。
_marker:这一项可以先忽略,它是用来表达一种所有关系的,目前不重要,后面再来详说。
Slab有两个类型参数:
T:表示预申请的内存用来存储什么类型的对象,即抽屉里可以装什么类型的对象。
I: 表示用来索引对象的类型。我们可以把Slab看是一个T的数组,可以通过 slab[2] 类似的语法来访问某个对象,这里的I表示2的类型。I = usize这样的写法表示,默认是usize做索引类型,但也可以为其它类型。也就是说使用者可以根据某种索引找到对应的抽屉。
通过这两个数据结构Slab内存分配模型就建立完毕了,Slab分配的核心思想是“用完不还”。一次性获取一块大的内存,以后不够了还可以再申请内存,但是申请了我就不还,用完了我也暂时不还,免得下次要用的时候还要申请(主要解决的问题是在频繁的使用过程中,申请抽屉的时间大于使用抽屉的时间)。
那使用Slab的方式就是,我预计一下今天我要装20个东西(T),那我就一次性申请一个包含20个抽屉的Slab(大柜子),来一个东西我用一个抽屉,用完了我柜子还是先留着,等我不用了,再把柜子还回去。
申请大柜子
按使用顺序,首先来看申请Slab的方法:
impl Slab {
/// Returns an empty `Slab` with the requested capacity
pub fn with_capacity(capacity: usize) -> Slab {
let entries = (1..capacity + 1)
.map(Slot::Empty)
.collect::>();
Slab {
entries: entries,
next: 0,
len: 0,
_marker: PhantomData,
}
}
......
}
根据你要的大小,返回一个大柜子,其中的每个抽屉都是空的,什么也没有装,利用Vec来申请内存。
这个方法本身没有什么难懂的,只是有一个语法细节需要特别注意:
let entries = (1..capacity + 1)
.map(Slot::Empty)
.collect::>();
这一行在创建空抽屉,(1..capacity + 1)表示创建一个Range,每个元素为usize,从1到capacity+1。接下来通过map方法创建抽屉,不过这里传给map的参数看起来怪怪的,map的定义如下:
fn map(self, f: F) -> Map where F: FnMut(Self::Item) -> B
传给map的参数应该是一个将usize映射为Slot的函数才对,而这里直接传入的是Slot::Empty,是一个枚举类型的变元!
再来看看Slot的定义:
enum Slot {
Empty(usize),
Filled(T),
Invalid,
}
Empty是一个tupe struct variant。在Rust中申明这样的变元时,编译会自动为其生成一个构造函数
fn Slot::Empty(u: usize) -> Slot {
Slot::Empty(u)
}
所以这里将Slot::Empty直接传递给map是合法的。
整个创建函数的意思是申请一个包含指定数量的抽屉的大柜子,为每个抽屉编一个号(1..n),且初始状态为空。
存储对象(往抽屉中放东西)
大柜子申请好了,现在来看看如何往其中放东西
impl + From> Slab {
......
/// Insert a value into the slab, returning the associated token
pub fn insert(&mut self, val: T) -> Result {
match self.vacant_entry() {
Some(entry) => Ok(entry.insert(val).index()),
None => Err(val),
}
}
......
/// Returns a handle to a vacant entry.
///
/// This allows optionally inserting a value that is constructed with the
/// index.
pub fn vacant_entry(&mut self) -> Option> {
let idx = self.next;
if idx >= self.entries.len() {
return None;
}
Some(VacantEntry {
slab: self,
idx: idx,
})
}
......
先来看这一句
I: Into + From
这一句要求索引类型I是可以与usize进行来回转换的。之前我们看到每个抽屉都有一个usize的编号,而I又是用来索引抽屉的,如果I可以与uszie进行映射,那么通过I找抽屉的任务就可以完成。
为什么不直接用usize来做索引,还要那么麻烦的接受一个任意类型呢?如果直接使用usize做索引,那么一个给定的索引编号就直接对应到一个抽屉,索引和抽屉之间是一一映射的关系。如果使用I做索引,多个I可以映射到同一个usize就可以做到索引和抽屉之间的多对一关系,带来更多的灵活性,而且能表达更多信息,mio库的作者就使用业务含意丰富的Token索引替换了默认的简单的usize索引。
再来看插入函数
pub fn insert(&mut self, val: T) -> Result { ... }
从函数申明看出,调用这个方法会修改Slab内部数据,传入要存储的对象val,如果存储成功,返回索引,不成功返回传入的val。这就好比我请你帮我把东西寄存在抽屉里,如果有合适的抽屉,你帮我把东西放好后告诉我放在第几个抽屉里了,如果没有找到合适的抽屉,你把东西还给我。
第一步就是找空箱子,通过vacant_entry方法完成
pub fn vacant_entry(&mut self) -> Option> { ... }
方法申明中看出,如果找到返回一个VacantEntry对象,没有返回None。
pub struct VacantEntry {
slab: &'a mut Slab,
idx: usize,
}
slab为大柜子的可变引用,idx是找到的抽屉的编号。 根据vacant_entry方法的实现可以知道找箱子的算法。下一个可用的抽屉编号保存在next中,只要不越界,就直接返回。
未完待续……