内存管理之内存池概述(转)
原文链接:http://www.xiaoyaochong.net/wordpress/index.php/2013/08/10/%E5%BC%95%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E4%B9%8B%E5%86%85%E5%AD%98%E6%B1%A0%E6%A6%82%E8%BF%B0/
在我们编写代码的过程中,不可避免的要和内存打交道,在申请释放不太频繁的情况下,通常让系统进行内存管理即可。但是,直接使用系统调用malloc/free、new/delete进行内存分配和释放,存在一定的弊端:
1、调用malloc/new,系统根据“最先匹配”、“最优匹配”或其他算法在内存空闲块表中查找一块空闲内存,内存使用效率不高;
2、调用free/delete,系统可能需要进行空闲内存块合并操作,这会带来额外时间和空间上的开销;
3、频繁使用时容易产生大量内存碎片,从而降低程序运行效率和稳定性,在之前ijoin项目中出现过,后来用google开源的tcmalloc替代glibc默认的内存分配方式;
4、容易出现内存泄漏现象,造成内存大小持续增加,甚至内存耗尽。
对于长期运行的后台服务系统来说,出于性能和内存碎片等考虑,通常会考虑使用内存池来管理服务的内存分配,而不是简单使用malloc/free,new/delete来进行动态内存分配。
那么,内存池是什么?是在真正使用内存之前,先预先申请分配一定数量的、大小相等/不等的内存块。当有新的内存需求时,就从申请好的内存块中分出一部分内存块,若内存块不够时再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,内存分配效率得到提升。在内存池上分配的内存不需要释放,在内存池销毁时会释放从内存池分配出去的内存。
优点:
1、加快内存分配速度(快于标准的malloc),内存块够用时,仅是大小判断和指针偏移等简单操作;
2、小块内存的有效载荷高(没有合并内存块所需的指针),需要的额外信息少;
3、内存池上分配的内存通常不需要再单独释放,而是统一回收;
4、除了使用内存分配函数代替malloc,没有使用上的其他特殊约定。
缺点:
1、如果内存池的生命周期比较长,可能给系统造成较大的内存压力。
2、从内存池分配的内存,一般不能显式释放,造成某些内存得不到及时回收。
使用场景:
1、需要频繁分配小块内存。
2、内存使用有明确的生命周期。
那么在我们日常使用维护的应用系统中,是否也有类似使用内存池的例子呢?
在Ha3的SearcherCache里面用到了内存池——ChainedFixedSizePool进行内存管理,通过维护一个chunkSize固定的链表节点或数组来进行内存的申请和释放。在isearch/kingSo搜索引擎里面也有使用内存池机制来进行统一初始化和回收以及快速内存申请——MemPool。这个实现版本相对SearcherCache里面的ChainedFixedSizePool复杂些,它通过统计一定请求周期次数内,每种chunkSize满足请求周期内内存大小的命中次数,以预测常驻chunk的大小,使用的是一种自适应的调整策略,以使得常驻chunk能尽量满足一个周期内的内存需求,尽量避免在请求周期内向Linux内核重新申请内存。
像memcached,nginx都有使用内存池来管理内存分配,其实现的基本思想——Slab Allocator,其实就是一种很通用的内存池实现思路。Linux内核也使用了这种思想和其他一些思想来构建一个在空间和时间上都具有高效性的内存分配器。
让我们先通过下面两张google来的图来简单看下Linux的内存管理之道。
以上两张图将32位Linux内存管理中的地址映射,虚拟地址管理,物理内存管理等主要数据结构和逻辑关系比较好的表达出来了。
之后,让我们来简单了解下malloc,kmalloc,vmalloc,mmap内存分配的一些区别:
1、kmalloc和vmalloc用于分配内核空间的内存,malloc用于分配用户空间的内存;
2、kmalloc保证分配的内存在物理上是连续的,vmalloc分配的是在虚拟地址空间上连续;
3、kmalloc能分配的大小有限,vmalloc和malloc能分配的大小相对较大;
4、内存只有在要被DMA访问的时候才需要物理上连续;
5、vmalloc比kmalloc要慢,kmalloc分配的物理地址与虚拟地址只有一个PAGE_OFFSET偏移,不需要借助页表机制。vmalloc函数使用虚拟地址,每次分配都需要对页表进行设置,效率低;
6、malloc最终调用do_brk(),在用户空间的堆栈中申请空间,不过do_brk做“批发”,malloc做“零售”。malloc用来给用户态程序分配,处理的内存地址是虚拟地址,能够保证虚拟地址是连续的,但不能保证物理地址是连续;
7、mmap()将一个已经打开的文件的内容映射到Task的用户空间,使得能够像访问内存一样访问文件;
8、当需要分配一个不具有专用Slab队列的数据结构而不必为之使用整个页面时,应该通过kmalloc分配,这些一般是较小而又不常用的数据结构;
9、如果数据结构大小接近一个页面,则应该直接通过alloc_pages进行页面分配;
10、函数vmalloc从内核空间分配一块虚存以及相应的物理内存,类似于系统调用brk()。不过brk()是由进程在用户空间分配的。
11、由vmalloc分配的空间不会被kswapd换出, kswapd会扫描各个进程的用户空间,但是看不到通过vmalloc分配到页表项。
12、通过kmalloc分配的数据结构,则kswapd先从各个Slab队列中寻找和收集空闲不用的Slab,并释放占用的页面,但是不会将尚在使用的Slab所占据的页面换出。
从上面可知,kmalloc其实是通过Slab进行内存管理分配的。这几个接口在系统中的位置如下图:
下面就让我们一起了解下Linux Slab机制。
Linux采用了Slab来管理小块内存的分配与释放。Slab是由 Jeff Bonwick 为 SunOS 操作系统首次引入的一种算法。它的提出是基于以下因素考虑的:
1、内核函数经常反复请求相同的数据类型。比如:创建进程时,会请求一块内存来存放mm_struct结构;
2、不同的结构使用不同的分配方法可以提高效率。同样,如果进程在撤消的时候,内核不把mm_struct结构释放掉,而是存放到一个缓冲区里,以后若有请求mm_struct存储空间的行为就可以直接从缓冲区中取得,而不需重新分配内存;
3、如果伙伴系统频繁分配,释放内存会影响系统的效率,因此,可以把要释放到的内存放到缓冲区中,直至超过一个阀值时才释放至伙伴系统,这样可以在一定程度上减轻伙伴系统的压力 ;
4、为了减少“页内碎片”的产生,通常可以把小内存块按照2的倍数组织在一起,这一点和伙伴系统类似 。
Slab将缓存分为两种:一种是专用高速缓存,另外一种是普通高速缓存。
1、专用高速缓存中用来存放内核使用的数据结构,例如:mm_struct, inode, vm_area_struct等。
2、普通高速缓存是指存放一般的数据,比如内核为指针分配的一段内存。普通高速缓存将分配区分为32*(2^0),32*(2^1),32*(2^2),…,32*(2^12)大小,共13个区域大小。另外,每个大小均有两个高速缓存,一个为DMA高速缓存,一个是常规高速缓存,它们都在cache_sizes表中进行设定,并有对应的名字cache_names。
所有不同种类的高速缓存都通过双向链表的方式组织在一起,它的首结点是cache_chain 。cache_chain链的每个元素都是一个 kmem_cache 结构的引用(称为一个 cache),它定义了一个要管理的给定大小的对象池。为了有效地管理 Slab,根据已分配对象的数目,Slab 可以有 3 种状态,动态地处于缓冲区相应的队列中:
1、Full 队列,此时该 Slab 中没有空闲对象。
2、Partial 队列,此时该 Slab 中既有已分配的对象,也有空闲对象。
3、Empty 队列,此时该 Slab 中全是空闲对象。
NUMA系统中,每个节点都会拥有这 3 种 Slab 队列,struct kmem_list3结构用于维护相关队列。Slab 分配器优先从 Partial 队列里的 Slab 中分配对象。当 Slab 的最后一个已分配对象被释放时,该 Slab 从 Partial 队列转移到 Empty 队列;当 Slab 的最后一个空闲对象被分配时,该 Slab 从Partial 队列转移到Full 队列里。缓冲区中空闲对象总数不足时,则分配更多的 Slab;但是如果空闲对象比较富余,Empty 队列的部分 Slab 将被定期回收。为了支持多处理器同时分配对象,缓冲区为每个处理器维护一个本地缓存array_cache。处理器直接从本地缓存中分配对象,从而避免了锁的使用;当本地缓存为空时,从 slab 中批量分配对象到本地缓存。
Slab分配器把每一次请求的内存称之为对象。根据对象放置的位置又分为外置式和内置式Slab,由对象是否超过 1/8 个物理内存页框的大小决定。外置式Slab页面不含Slab管理的对象,全部用来存储Slab对象本身。内置式Slab,Slab管理的对象与Slab对象本身存储在一起。
Slab分配器通过着色机制将被管理的对象起始地址与cache line对齐。但是,Slab中的对象大小不确定,设置着色区的目的就是将Slab中第一个对象的起始地址往后推到与缓冲行对齐的位置。因为一个缓冲区中有多个Slab,因此,应该把每个缓冲区中的各个Slab着色区的大小尽量安排成不同的大小,这样可以使得在不同的Slab中,处于同一相对位置的对象,让它们在高速缓存中的起始地址相互错开,这样就可以改善高速缓存的存取效率。
与传统的内存管理模式相比,Slab缓存分配器提供了很多优点。首先,内核通常依赖于对小对象的分配,它们会在系统生命周期内进行无数次分配。Slab 缓存分配器通过对类似大小的对象进行缓存而提供这种功能,从而避免了常见的碎片问题。Slab 分配器还支持通用对象的初始化,从而避免了为同一目而对一个对象重复进行初始化。最后,Slab 分配器还可以支持硬件缓存对齐和着色,这允许不同缓存中的对象占用相同的缓存行,从而提高缓存的利用率并获得更好的性能。在2.6.18内核系统中,可以通过cat /proc/Slabinfo可以查看当前系统上的Slab统计信息。
随着大规模多处理器系统和 NUMA系统的广泛应用,Slab分配器逐渐暴露出自身的严重不足:
1、较多复杂的队列管理。在 Slab 分配器中存在众多的队列,例如针对处理器的本地对象缓存队列,Slab 中空闲对象队列,每个 Slab 处于一个特定状态的队列中,甚至缓冲区控制结构也处于一个队列之中。有效地管理这些不同的队列是一件费力且复杂的工作。
2、Slab 管理数据和队列的存储开销比较大。每个 Slab 需要一个 struct Slab 数据结构和一个管理所有空闲对象的 kmem_bufctl_t(4 字节的无符号整数)的数组。当对象体积较少时,kmem_bufctl_t 数组将造成较大的开销(比如对象大小为32字节时,将浪费 1/8 的空间)。为了使得对象在硬件高速缓存中对齐和使用着色策略,还必须浪费额外的内存。同时,缓冲区针对节点和处理器的队列也会浪费不少内存。测试表明在一个 1000 节点/处理器的大规模 NUMA 系统中,数 GB 内存被用来维护队列和对象的引用。
3、缓冲区内存回收比较复杂。
4、对 NUMA 的支持非常复杂。Slab 对 NUMA 的支持基于物理页框分配器,无法细粒度地使用对象,因此不能保证处理器级缓存的对象来自同一节点。
5、冗余的 Partial 队列。Slab 分配器针对每个节点都有一个 Partial 队列,随着时间流逝,将有大量的 Partial Slab 产生,不利于内存的合理使用。
6、性能调优比较困难。针对每个 Slab 可以调整的参数比较复杂,而且分配处理器本地缓存时,不得不使用自旋锁。
7、调试功能比较难于使用。
为了解决以上 Slab 分配器的不足之处,内核开发人员 Christoph Lameter 在 Linux 内核 2.6.22 版本中引入一种新的解决方案:Slub 分配器。Slub 分配器特点是简化设计理念,同时保留 Slab 分配器的基本思想:每个缓冲区由多个小的 Slab 组成,每个 Slab 包含固定数目的对象。Slub 分配器简化了kmem_cache,Slab 等相关的管理数据结构,摒弃了Slab 分配器中众多的队列概念,并针对多处理器、NUMA 系统进行优化,从而提高了性能和可扩展性并降低了内存的浪费。为了保证内核其它模块能够无缝迁移到 Slub 分配器,Slub 还保留了原有 Slab 分配器所有的接口 API 函数。
以上我们由简单到复杂的了解了内存池管理的基本思想,Linux的内存管理也是随着硬件的升级换代在不断发展变化之中,引入更优的管理策略,剪裁过于复杂的逻辑和数据结构以及过多的参数,面向相对统一的管理对象,调优调试更加友好(也体现了简单即是美),以充分挖掘和发挥新硬件的性能。
附: