linux slub分配器浅析

在《linux内存管理浅析》中提到内核管理自己使用的内存时,使用了SLAB对象池。SLAB确实是比较复杂,所以一直以来都没有深入看一看。
不过现在,linux内核中,SLAB已经被它的简化版--SLUB所代替。最近抽时间看了一下SLUB的代码,略记一些自己的理解。
尽管SLUB是在内核里面实现的,用户态的对象池其实也可以借鉴这样的做法。

SLUB的总体思想还是跟SLAB类似,对象池里面的内存都是以“大块”为单位来进行分配与回收的。然后每个“大块”又按对象的大小被分割成“小块”,使用者对于对象的分配与回收都是以“小块”为单位来进行的。
SLUB的结构如下图:

另外,kmem_cache还有以下一些参数(后面会解释到):
int size; /* 每个对象占用的空间 */
int objsize; /* 对象的大小 */
int offset; /* 对象所占用的空间中,存放next指针的偏移 */
int refcount; /* 引用计数 */
int inuse; /* 对象除next指针外所占用的大小 */
int align; /* 对齐字节数 */
void (*ctor)(void *); /* 构造函数 */
unsigned long min_partial; /* kmem_cache_node中保存的最小page数 */
struct kmem_cache_order_objects oo; /* 首选的page分配策略(分配多少个连续页面,能划分成多少个对象) */
struct kmem_cache_order_objects min; /* 次选的page分配策略 */
(另外还有一些成员,或支持了一些选项、或支持了DEBUG、或是为周期性的内存回收服务。这里就不列举了。)

大体结构

kmem_cache是对象池管理器,每一个kmem_cache管理一种类型的对象。所有的管理器通过双向链表由表头slab_caches串连起来。
这一点跟之前的SLAB是一样的。

kmem_cache的内存管理工作是通过成员kmem_cache_node来完成的。在NUMA环境下(非均质存储结构),一个kmem_cache维护了一组kmem_cache_node,分别对应每一个内存节点。kmem_cache_node只负责管理对应内存节点下的内存。(如果不是 NUMA环境,那么kmem_cache_node只有一个。)

而实际的内存还是靠page结构来管理的,kmem_cache_node通过partial指针串连起一组page(nr_partial代表链表长度),它们就代表了对象池里面的内存。page结构不仅代表了内存,结构里面还有一些union变量用来记录其对应内存的对象分配情况(仅当page被加入到SLUB分配器后有效,其他情况下这些变量有另外的解释)。
原先的SLAB则要复杂一些,SLAB里面的page仅仅是管理内存,不维护“对象”的概念。而是由额外的SLAB控制结构(slab)来管理对象,并通过slab结构的一些指针数组来划定对象的边界。

前面说过,对象池里面的内存是以“大块”为单位来进行分配与回收的,page就是这样的大块。page内部被划分成若干个小块,每一块用于容纳一个对象。这些对象是以链表的形式来存储的,page->freelist就是链表头,只有未被分配的对象才会放在链表中。对象的next指针存放在其偏移量为kmem_cache->offset的位置。(见上面的图)
而在SLAB中,“大块”则是提供控制信息的slab结构。page结构只表示内存,它仅是slab所引用的资源。

每一个page并不只代表一个页面,而是2^order个连续的页面,这里的order值是由kmem_cache里面的oo或min来确定的。分配页面时,首先尝试使用oo里面的order值,分配较合适大小的连续页面(这个值是在kmem_cache创建的时候计算出来的,使用这个值时需要分配一定的连续页面,以使得内存分割成“小块”后剩余的边角废料较少)。如果分配不成功(运行时间长了,内存碎片多了,分配大量连续页面就不容易了),则使用min里面的order值,分配满足对象大小的最少量的连续页面(这个值也是创建kmem_cache时计算出来的)。

kmem_cache_node通过partial指针串连的一组page,这些page必须是没被占满的(一个page被划分成page->objects个对象大小的空间,这些空间中有page->inuse个已经被使用。如果page->objects==page->inuse,则page为full)。如果一个page为full,则它会被从链表中移除。而如果page是free的(page->inuse==0),一般情况下它也会被释放,除非这里的nr_partial(链表长度)小于kmem_cache里面的min_partial。(既然是池,就应该有一定的存量,min_partial就代表最低存量。这个值也是在创建kmem_cache时计算出来的,对象的size较大时,会得到较大的min_partial值。因为较大的size值在分配page时会需要更多连续页面,而分配连续页面不如单个的页面容易,所以应该多缓存一些。)
而原先的SLAB则有三个链表,分别维护“full”、“partial”、“free”的slab。“free”和“partial”在SLUB里面合而为一,成了前面的partial链表。而“full”的page就不维护了。其实也不需要维护,因为page已经full了,不能再满足对象的分配,只能响应对象的回收。而在对象回收时,通过对象的地址就能得到对应的page结构(page结构的地址是与内存地址相对应的,见《 linux内存管理浅析》)。维护full的page可以便于查看分配器的状态,所以在DEBUG模式下,kmem_cache_node里面还是会提供一个full链表。

分配与释放

对象的分配与释放并不是直接在kmem_cache_node上面操作的,而是在kmem_cache_cpu上。一个kmem_cache维护了一组kmem_cache_cpu,分别对应系统中的每一个CPU。kmem_cache_cpu相当于为每一个CPU提供了一个分配缓存,以避免CPU总是去kmem_cache_node上面做操作,而产生竞争。并且kmem_cache_cpu能让被它缓存的对象固定在一个CPU上,从而提高CPU的cache命中率。kmem_cache_cpu只提供了一个page的缓存。

原先的SLAB是为每个CPU提供了一个array_cache结构来缓存对象。对象在array_cache结构中的组织形式跟它在slab中的组织形式是不一样的,这也就增加了复杂性。而SLUB则都是通过page结构来组织对象的,组织形式都一样。

进行对象分配的时候,首先尝试在kmem_cache_cpu上去分配。如果分配不成功,再去kmem_cache_node上move一个page到kmem_cache_cpu上面来。分配不成功的原因有两个:kmem_cache_cpu上的page已经full了、或者现在需要分配的node跟kmem_cache_cpu上缓存page对应的node不相同。对于page已full的情况,page被从kmem_cache_cpu上移除掉(或者DEBUG模式下,被移动到对应kmem_cache_node的full链表上);而如果是node不匹配的情况,则kmem_cache_cpu上缓存page会先被move回到其对应kmem_cache_node的partial链表上(再进一步,如果page是free的,且partial链表的长度已经不小于min_partial了,则page被释放)。
反过来,释放对象的时候,通过对象的地址能找到它所对应的page的地址,将对象放归该page即可。但是里面也有一些特殊逻辑,如果page正被kmem_cache_cpu缓存,就没有什么需要额外处理的了;否则,在将对象放归page时,需要对page加锁(因为其他CPU也可能正在该page上分配或释放对象)。另外,如果对象在回收之前该page是full的,则对象释放后该page就成partial的了,它还应该被添加到对应的kmem_cache_node的partial链表中。而如果对象回收之后该page成了free的,则它应该被释放掉。
对象的释放还有一个细节,既然对象会放回到对应的page上去,那如果这个page正在被其他的CPU cache呢(其他CPU的kmem_cache_cpu正指使用这个page)?其实没关系,kmem_cache_cpu和page各自有一个freelist指针,当page被一个CPU cache时,page的freelist上的所有对象全部移动到kmem_cache_cpu的freelist上面去(其实就是一个指针赋值),page的freelist变成NULL。而释放的时候是释放到page的freelist上去。两个freelist互不影响。但是这个地方貌似有个问题,如果一个被cache的page的freelist由于对象的释放而变成非NULL,那么这个page就可能再被cache到其他CPU的kmem_cache_cpu上面去,于是多个kmem_cache_cpu可能cache同一个page。这将导致一个CPU内部的缓存可能cache到其他CPU上的对象(因为CPU缓存跟对象并不是对齐的),从而一个CPU上的对象写操作可能引起另一个CPU的缓存失效。

在kmem_cache被创建的时候,SLUB会根据各种各样的信息,计算出对象池的合理布局(见上面的图)。objsize是对象本身的大小;这个大小经过对齐处理以后就成了inuse;紧贴inuse的后面可能会存放对象的next指针(由offset来标记),从而将对象实际占用的大小扩大到size。
其实,这里的offset并不总是指向inuse后面的位置(否则offset就可以用inuse来代替了)。offset有两种可能的取值,一是 inuse、一是0。这里的offset指定了next指针的位置,而next是为了将对象串连在空闲链表中。那么,需要用到next指针的时候,对象必定是空闲的,对象里面的空间是未被使用的。于是正好,对象里的第一个字长的空间就拿来当next指针好了,此时offset就是0。但是在一些特殊情况下,对象里面的空间不能被复用作next指针,比如对象提供了构造函数ctor,那么对象的空间是被构造过的。此时,offset就等于inuse,next指针只好存放在对象的空间之后。

关于kmem_cache_cpu

前面在讲对象分配与释放的时候,着重讲的是过程。下面再细细分析一下kmem_cache_cpu的作用。

如果没有kmem_cache_cpu,那么分配对象的过程就应该是:
1、从对应的kmem_cache_node的partial链表里面选择一个page;
2、从选中的page的freelist链表里面选择一个对象;
这就是最基本的分配过程。

但是这个过程有个不好的地方,kmem_cache_node的partial链表是全局的、page的freelist链表也是全局的:
1、第一步访问partial链表的时候,需要上锁;
2、第二步访问page->freelist链表的时候,也需要上锁;
3、对象可能刚在CPU1上被释放,又马上被CPU2分配走。不利于CPU cache;

引入kmem_cache_cpu就是对这一问题的优化。每个CPU各自对应一个kmem_cache_cpu实例,用于缓存第一步中选中的那个page。这样一来,第一步就不需要上锁了;而page中的对象在一段时间内也将趋于在同一个CPU上使用,有利于CPU cache。
而kmem_cache_cpu中的freelist则是为了避免第二步的上锁。

假设没有kmem_cache_cpu->freelist,而page->freelist初始时有1、2、3、4,四个对象。考虑如下事件序列:
1、page被CPU1所cache,然后1、2被分配;
2、由于在CPU1上请求的node id与该page不匹配,page被放回kmem_cache_node的partial链表,那么此时page->freelist还剩3和4两个对象;
3、page又被CPU2所cache(page在上一步已经被放回partial链表了)。
此时,page->freelist就有可能被CPU1和CPU2两个CPU所访问,当对象1或2被释放时(这两个对象已经分配给了CPU1),CPU1会访问page->freelist;而显然CPU2分配对象时也要会访问page->freelist。

所以为了避免上锁,kmem_cache_cpu要维护自己的freelist,把page->freelist下的对象都接管过来。
这样一来,CPU1就只跟page->freelist打交道,CPU2跟kmem_cache_cpu的freelist打交道,就不需要上锁了。

关于page同时被多CPU使用

前面还提到,从属于同一个page的对象可能cache到不同CPU上,从而可能对CPU的缓存造成一定的影响。不过这似乎也是没办法的事情。

首先,初始状态下,page加入到slub之后,从属于该page的对象都是空闲的,都存在于page->freelist中;
然后,这个page可能被某个CPU的kmem_cache_cpu所cache,假设是CPU-0,那么这个kmem_cache_cpu将得到属于该page的所有对象。 page->freelist将为空;
接下来,这个page下的一部分对象可能在CPU-0上被分配出去;
再接着,可能由于NUMA的node不匹配,这个page从CPU-0的kmem_cache_cpu上面脱离下来。这时page->freelist将保存着那些未被分配出去的对象(而其他的对象已经在CPU-0上被分配出去了);

这时,从属于该page的一部分对象正在CPU-0上被使用着,另一部分对象存在于page->freelist中。
那么,现在就有两个选择:
1、不将这个page放回partial list,阻止其他CPU使用这个page;
2、将这个page放回partial list,允许其他CPU使用这个page;

对于第一种做法,可以避免属于同一个page的对象被cache到不同CPU。但是这个page必须等到CPU-0再次cache它以后才能被继续使用;或者等待CPU-0所使用的从属于这个page的对象全都被释放,然后这个page才能被放回partial list或者直接被释放掉。
这样一来,一个page尽管拥有空闲的对象,却可能在一定时间内处于不可用状态(极端情况是永远不可用)。这样实现的系统似乎不太可控……

而现在的slub选择了第二种做法,将page放回partial list,于是page马上就能被其他CPU使用起来。那么,由此引发的从属于同一个page的对象被cache到不同CPU的问题,也就是没办法的事情……

vs slab

相比SLAB,SLUB还有一个比较有意思的特性。当创建新的对象池时,如果发现原先已经创建的某个kmem_cache的size刚好等于或略大于新的size,则新的kmem_cache不会被创建,而是复用这个大小差不多kmem_cache。所以kmem_cache里面还维护了一个refcount(引用计数),表示它被复用的次数。

另外,SLUB也去掉了SLAB中很有意思的一个特性,Coloring(着色)。
什么是着色呢?一个内存“大块”,在按对象大小划分成“小块”的时候,可能并不是那么刚好,还会空余一些边边角角。着色就是利用这些边边角角来做文章,使得“小块”的起始地址并不总是等于“大块”内的0地址,而是在0地址与空余大小之间浮动。这样就使得同一种类型的各个对象,其地址的低几位存在更多的变化。
为什么要这样做呢?这是考虑到了CPU的cache。在学习操作系统原理的时候我们都听说过,为提高CPU对内存的访存效率,CPU提供了cache。于是就有了从内存到cache之间的映射。当CPU指令要求访问一个内存地址的时候,CPU会先看看这个地址是否已经被缓存了。
内存到cache的映射是怎么实现的呢?或者说CPU怎么知道某个内存地址有没有被缓存呢?
一种极端的设计是“全相连映射”,任何内存地址都可以映射到任何的cache位置上。那么CPU拿到一个地址时,它可能被缓存的cache位置就太多了,需要维护一个庞大的映射关系表,并且花费大量的查询时间,才能确定一个地址是否被缓存。这是不太可取的。
于是,cache的映射总是会有这样的限制,一个内存地址只可以被映射到某些个cache位置上。而一般情况下,内存地址的低几位又决定了内存被cache的位置(如:cache_location = address % cache_size)。
好了,回到SLAB的着色,着色可以使同一类型的对象其低几位地址相同的概率减小,从而使得这些对象在cache中映射冲突的概率降低。
这有什么用呢?其实同一种类型的很多对象被放在一起使用的情况是很多的,比如数组、链表、vector、等等情况。当我们在遍历这些对象集合的时候,如果每一个对象都能被CPU缓存住,那么这段遍历代码的处理效率势必会得到提升。这就是着色的意义所在。
SLUB把着色给去掉了,是因为对内存使用更加抠门了,尽可能的把边边角角减少到最小,也就干脆不要着色了。还有就是,既然kmem_cache可以被size差不多的多种对象所复用,复用得越多,着色也就越没意义了。

时间: 2024-10-27 23:13:30

linux slub分配器浅析的相关文章

linux页面回收浅析

关于页面的使用 在之前的一些文章中,我们了解到linux内核会在很多情况下分配页面. 1.内核代码可能调用alloc_pages之类的函数,从管理物理页面的伙伴系统(管理区zone上的free_area空闲链表)上直接分配页面(见<linux内核内存管理浅析>).比如:驱动程序可能用这种方式来分配缓存:创建进程时,内核也是通过这种方式分配连续的两个页面,作为进程的thread_info结构和内核栈:等等.从伙伴系统分配页面是最基本的页面分配方式,其他的内存分配都是基于这种方式的: 2.内核中的

linux文件读写浅析

在<linux内核虚拟文件系统浅析>这篇文章中,我们看到文件是如何被打开.文件的读写是如何被触发的. 对一个已打开的文件fd进行read/write系统调用时,内核中该文件所对应的file结构的f_op->read/f_op->write被调用. 本文将顺着这条路走下去,大致看看普通磁盘文件的读写是怎样实现的. linux内核响应一个块设备文件读写的层次结构如图(摘自ULK3): 1.VFS,虚拟文件系统. 之前我们已经看到f_op->read/f_op->write如

linux异步IO浅析

知道异步IO已经很久了,但是直到最近,才真正用它来解决一下实际问题(在一个CPU密集型的应用中,有一些需要处理的数据可能放在磁盘上.预先知道这些数据的位置,所以预先发起异步IO读请求.等到真正需要用到这些数据的时候,再等待异步IO完成.使用了异步IO,在发起IO请求到实际使用数据这段时间内,程序还可以继续做其他事情). 假此机会,也顺便研究了一下linux下的异步IO的实现. linux下主要有两套异步IO,一套是由glibc实现的(以下称之为glibc版本).一套是由linux内核实现,并由l

linux内核cfs浅析

linux调度器的一般原理请参阅<linux进程调度浅析>. 之前的调度器 cfs之前的linux调度器一般使用用户设定的静态优先级,加上对于进程交互性的判断来生成动态优先级,再根据动态优先级决定进程被调度的顺序,以及调度后可以运行的时间片. 反过来,随着进程的运行,内核可能发现其交互性发生改变,从而调整其动态优先级(奖励睡眠多的交互式进程.惩罚睡眠少的批处理进程). cfs原理 cfs定义了一种新的模型,它给cfs_rq(cfs的run queue)中的每一个进程安排一个虚拟时钟,vrunt

linux虚拟文件系统浅析

虚拟文件系统(VFS) 在我看来, "虚拟"二字主要有两层含义: 1, 在同一个目录结构中, 可以挂载着若干种不同的文件系统. VFS隐藏了它们的实现细节, 为使用者提供统一的接口; 2, 目录结构本身并不是绝对的, 每个进程可能会看到不一样的目录结构. 目录结构是由"地址空间(namespace)"来描述的, 不同的进程可能拥有不同的namespace, 不同的namespace可能有着不同的目录结构(因为它们可能挂载了不同的文件系统). 操作已打开的文件 VFS

linux内核mem_cgroup浅析

memory cgroup mem_cgroup是cgroup体系中提供的用于memory隔离的功能. admin可以创建若干个mem_cgroup,形成一个树型结构.可以将进程加入到这些mem_cgroup中.(类似这样的管理功能都是由cgroup框架自带的.) 为了实现memory隔离,每个mem_cgroup主要有两个维度的限制: 1.res - 物理内存 2.memsw - memory + swap,物理内存 + swap 其中,memsw肯定是大于等于memory的. 另外注意,me

linux memory lock浅析

linux内核提供了用于锁定内存的系统调用,如: mlock:lock一段地址范围内已map的内存 mlockall:lock进程虚拟地址空间内已map的内存,还可以选择对于此后新map的空间是否自动lock mmap+MAP_LOCKED选项:在mmap的同时,对相应地址范围进行mlock 利用这些系统调用,用户进程可以对自己需要使用的内存进行lock.lock,则意味着相应的数据在unlock之前将一直存在于物理内存中,不会被回收. 对于应用程序来说,可以将内存中一些对程序性能影响较大的数据

linux内存管理浅析

[地址映射](图:左中) linux内核使用页式内存管理,应用程序给出的内存地址是虚拟地址,它需要经过若干级页表一级一级的变换,才变成真正的物理地址. 想一下,地址映射还是一件很恐怖的事情.当访问一个由虚拟地址表示的内存空间时,需要先经过若干次的内存访问,得到每一级页表中用于转换的页表项(页表是存放在内存里面的),才能完成映射.也就是说,要实现一次内存访问,实际上内存被访问了N+1次(N=页表级数),并且还需要做N次加法运算. 所以,地址映射必须要有硬件支持,mmu(内存管理单元)就是这个硬件.

linux seqlock &amp; rcu 浅析

在linux内核中,有很多同步机制.比较经典的有spin_lock(忙等待的锁).mutex(互斥锁).semaphore(信号量).等.并且它们几乎都有对应的rw_XXX(读写锁),以便在能够区分读与写的情况下,让读操作相互不互斥(读写.写写依然互斥). 而seqlock和rcu应该可以不算在经典之列,它们是两种比较有意思的同步机制. seqlock(顺序锁) 用于能够区分读与写的场合,并且是读操作很多.写操作很少,写操作的优先权大于读操作. seqlock的实现思路是,用一个递增的整型数表示