Linux中的Slab分配器
Linux 中的 Slab 分配器位于伙伴系统之上,其基本思想是在内核中保留常用对象(object)的缓存便于快速分配(例如 task_struct、inodes、dentries等)。
迄今为止,Slab 分配器已经有三种不同的实现方式。
- SLOB:最初在 Solaris 中实现的分配器,现在多用于嵌入式,适用于内存稀缺情况,在分配非常小的内存块是表现良好。基于 first-fit 分配算法。
- SLAB:相较于 SLOB有所改进,旨在对缓存更加友好。
- SLUB:通过减少使用队列的数量,比 SLAB 具有更快的速度。
一个 Slab Cache 管理多个 Slab(也就是page),而一个 Slab 中包含了多个 对象(object)。Slab 分配器通用的架构如下:
Slab Cache
Slab 分配器提供了两种类型的缓存:
- 专用缓存:在内核中为常用对象创建的缓存(例如task_struct_cachep就是用来管理task_struct对象的slab cache),对应的API为kmem_cache_alloc()。
- 通用缓存:一般为用户创建的缓存,缓存下管理的对象不一定是一个类型,但是对象的大小相等,对应的API为kmalloc()。Slab所管理的对象大小一般不能超过4MiB。
这种区别可以在 proc 文件系统中专门用于 Slab 的文件中看到:
另外需要注意的是,系统的 slab 队列数量可以由 slab cache 的数量进行确认。下面是三种分配器具体的队列数量计算逻辑
- SLOB - 3
- SLAB - 3 * nb(numa_node) * nb(slab_cache)
- SLUB - (2 + nb(numa_node)) * nb(slab_cache)
slab cache 的数量不固定,这取决于当前系统中加载的内核模块等因素,如左边的三台机器,输出的 slab cache 数量都不一样。
SLOB Allocator
图中的struct kmem_cache
就是前面提到的slab cache,SLOB中就只有三个全局的队列free_slob_[small/medium/large],所有kmem cache分配释放内存都要从这三个队列中申请,因此性能不会好到哪去。
详细来说,通过SLOB分配内存时,SLOB首先会根据请求的大小,从 kmem_cache 中选择合适的队列,对应不同对象大小范围,然后在选定队列中查找包含空闲对象的Page。如果页面中有空闲对象,则直接分配。再取出空闲对象后,还需要将其标记为已分配。若无空闲页面,SLOB 会分配新的页面,将其分割成多个对象,并加入缓存中供后续使用。在释放内存时,将已分配对象标记为空闲,并将其返回到对应页面的空闲对象列表。当页面中的所有对象都变为空闲时,页面可能被回收。
SLOB实现非常简单,但缺点有很多,对于大规模内存的分配效率不高、在频繁分配释放内存的场景下会产生严重的内存碎片、对于多核多线程系统不友好,在分配对象的时候连内存对齐都没有。
SLAB Allocator
SLAB 分配器引入了专用缓存的概念,针对不同类型的对象创建不同的缓存,相较于 SLOB 中缓存的对象大小不固定,这种方式能更有效地管理内存。SLAB 分配器增加了队列,预分配固定大小的对象池,相较于 SLOB 中按需动态分配和释放内存,这种方式减少了频繁分配和释放内存的开销,提高了性能。SLAB分配器在内存分配时,确保所有对象在内存中是对齐的。提升了访问效率,还减少了由于未对齐内存块导致的碎片问题。SLAB分配器引入了 per-CPU caches (array_cache)的机制,每个CPU核心都有自己的缓存,用于存储该CPU最近分配和释放的对象。
SLAB在分配内存时,首先会尝试从所在 CPU 的本地缓存(array_cache数组)中获取对象,如果有可用对象就直接分配。如果本地缓存为空,就在部分空闲队列(partial_slab_list)中查找有空闲对象的 slab,若部分空闲队列为空则从全部空闲队列(free_slab_list)补充。如果还是没有找到,那么就从伙伴系统申请内存页面新增slab,分配对象后将slab转移到部分空闲队列。
在释放内存时,如果CPU本地缓存中的可用对象数目小于限制,则直接将对象放入本地缓存,并更新对象所在的 slab 的元数据。如果超出限制,会将数组中的一部分对象放回slab中,然后将新释放的对象放到本地缓存中。
SLUB
SLUB中的cache分为了两种,一种是percpu cache,它在每个core中都会有一个副本;一种是Node-based cache,它在每个NUMA节点中都会有一个副本。走percpu缓存来分配释放内存通常被称为快速通道,而走NUMA节点的缓存被称为慢速通道。对于一种slab cache,percpu缓存中会有两个队列,而NUMA节点缓存中只有一个队列。从上往下速度变慢,缓存的page变多。
在分配缓存块的时候,要分两种路径,fast path 和 slow path,也就是快速通道和普通通道。其中 kmem_cache_cpu 就是快速通道,kmem_cache_node 是普通通道。每次分配的时候,要先从 kmem_cache_cpu 进行分配。如果 kmem_cache_cpu 里面没有空闲的块,那就到 kmem_cache_node 中进行分配;如果还是没有空闲的块,才去伙伴系统分配新的页。
我们可以看到 slab cache 内存分配的整个流程分为 fastpath 快速路径和 slowpath 慢速路径。
其中在 fastpath 路径下,内核会直接从 slab cache 的本地 cpu 缓存中获取内存块,这是最快的一种方式。
在本地 cpu 缓存没有足够的内存块可供分配的时候,内核就进入到了 slowpath 路径,而 slowpath 下又分为多种情况:
- 从本地 cpu 缓存 partial 列表中分配
- 从 NUMA 节点缓存中分配,其中涉及到了对本地 cpu 缓存的填充。
- 从伙伴系统中重新申请 slab。
如果对象属于CPU本地缓存或者缓存补充队列,将对象放回后更新slab元数据(快速路径)。如果对象不属于CPU本地缓存补充队列,并且对象所属是full_slab,将该slab插入补充队列。特殊情况:如果补充队列所包含的空闲对象总数超出规定阈值,就将补充队列中的所有slab都转移到NUMA节点缓存队列中。如果对象释放完后,所属的slab空了,检查NUMA节点缓存队列中的slab数量是否达到阈值:如果没有,将其插入NUMA节点缓存队列;如果到达阈值,将slab对应的页面释放回伙伴系统。剩余的情况就是对象的slab在NUMA节点缓存队列中,直接释放对象更新slab元数据。
可以看到,SLUB分配器将内存的管理分为快速/慢速路径,解决了 SLAB分配器队列管理复杂的问题。 并且,SLUB通过动态调整fraction和逐步放宽碎片要求的方式,尽可能找到碎片率最低的页数配置,相较于 SLAB 分配器有更加严苛的内存碎片控制逻辑。