tcmalloc详解
什么是tcmalloc
TCMalloc(Thread-Caching Malloc,线程缓存的malloc)是Google开发的内存分配算法库,最初作为Google性能工具库 perftools 的一部分,提供高效的多线程内存管理实现,用于替代操作系统的内存分配相关的函数(malloc、free,new,new[]等),具有减少内存碎片、适用于多核、更好的并行性支持等特性。
TCMalloc属于gperftools,gperftools项目包括heap-checker、heap-profiler、cpu-profiler、TCMalloc等组件。
gperftools源码地址:
TCMalloc源码地址:
TCMalloc全称Thread-Caching Malloc,即线程缓存的malloc,是Google开发的内存分配器,实现了高效的多线程内存管理器,用于替代系统的内存分配相关的函数(malloc,free,new,new[]等).整个 TCMalloc对内存的管理实现了三级缓存,分别是ThreadCache(线程级缓存),Central Cache(中央缓存:CentralFreeeList),PageHeap(页缓存).
TCMalloc 按照内存大小区间划分为小/中/大三类,由不同的数据结构进行管理。
优势性能对比:
一次malloc和free操作,ptmalloc需要300ns,而tcmalloc只要50ns。同时tcmalloc也优化了小对象的存储,需要更少的空间。tcmalloc特别对多线程做了优化,对于小对象的分配基本上是不存在锁竞争,而大对象使用了细粒度、高效的自旋锁(spinlock)。分配给线程的本地缓存,在长时间空闲的情况下会被回收,供其他线程使用,这样提高了在多线程情况下的内存利用率,不会浪费内存,而这一点ptmalloc2是做不到的。 PTMalloc和TCMalloc的多线程不同大小申请释放测试:
系统架构图:
内存管理可以分为三个层次,从下往上分别是:
- 操作系统内核的内存管理.
- glibc层使用系统调用维护的内存管理算法.
- 应用层从glibc动态分配内存后,根据应用程序本身的程序特性进行优化.比如引用计数std::shared_ptr,内存池. TCMalloc的层次和大致的内部结构:
TCMalloc 可以分为三个部分:前端,中端,后端.
- 前端是一个缓存,为应用程序提供快速的内存分配和释放.
- 中端负责重新填充前端缓存.
- 后端负责从操作系统获取内存.
TCMalloc将整个虚拟内存空间划分为n个同等大小的Page。将n个连续的page连接在一起组成一个Span。
PageHeap向OS申请内存,申请的span可能只有一个page,也可能有n个page。
ThreadCache内存不够用会向CentralCache申请,CentralCache内存不够用时会向PageHeap申请,PageHeap不够用就会向OS操作系统申请。
存储方式
在内存分配中实际的内存分配方式是按照定长记录和变长记录进行细分的
如何分配定长记录?
首先是基本问题,如何分配定长记录?例如,我们有一个 Page 的内存,大小为 4KB,现在要以 N 字节为单位进行分配。为了简化问题,就以 16 字节为单位进行分配。
解法有很多,比如,bitmap。4KB / 16 / 8 = 32, 用 32 字节做 bitmap即可,实现也相当简单。
出于最大化内存利用率的目的,我们使用另一种经典的方式,freelist。将 4KB 的内存划分为 16 字节的单元,每个单元的前8个字节作为节点指针,指向下一个单元。初始化的时候把所有指针指向下一个单元;分配时,从链表头分配一个对象出去;释放时,插入到链表。
由于链表指针直接分配在待分配内存中,因此不需要额外的内存开销,而且分配速度也是相当快。
如何分配变长记录?
定长记录的问题很简单,但如何分配变长记录的。对此,我们把问题化归成对多种定长记录的分配问题。
我们把所有的变长记录进行“取整”,例如分配7字节,就分配8字节,31字节分配32字节,得到多种规格的定长记录。这里带来了内部内存碎片的问题,即分配出去的空间不会被完全利用,有一定浪费。为了减少内部碎片,分配规则按照 8, 16, 32, 48, 64, 80这样子来。注意到,这里并不是简单地使用2的幂级数,因为按照2的幂级数,内存碎片会相当严重,分配65字节,实际会分配128字节,接近50%的内存碎片。而按照这里的分配规格,只会分配80字节,一定程度上减轻了问题。(tcmalloc 的设计引入了更细粒度的分配规则,比如:8, 16, 32, 48, 64, 80, 96 等。这些规则避免了直接使用幂次级数,并通过合理的间隔(如 16 或 32)来减少碎片率。
)
大的对象如何分配?
上面讲的是基于 Page,分配小于Page的对象,但是如果分配的对象大于一个 Page,我们就需要用多个 Page 来分配了:
这里提出了 Span 的概念,也就是多个连续的 Page 会组成一个 Span,在 Span 中记录起始 Page 的编号,以及 Page 数量。
分配对象时,大的对象直接分配 Span,小的对象从 Span 中分配。
Span如何分配?
对于 Span的管理,我们可以如法炮制:
还是用多种定长 Page 来实现变长 Page 的分配,初始时只有 128 Page 的 Span,如果要分配 1 个 Page 的 Span,就把这个 Span 分裂成两个,1 + 127,把127再记录下来。对于 Span 的回收,需要考虑Span的合并问题,否则在分配回收多次之后,就只剩下很小的 Span 了,也就是带来了外部碎片 问题。
为此,释放 Span 时,需要将前后的空闲 Span 进行合并,当然,前提是它们的 Page 要连续。
问题来了,如何知道前后的 Span 在哪里?
从Page到Span
由于 Span 中记录了起始 Page,也就是知道了从 Span 到 Page 的映射,那么我们只要知道从 Page 到 Span 的映射,就可以知道前后的Span 是什么了。
最简单的一种方式,用一个数组记录每个Page所属的 Span,而数组索引就是 Page ID。这种方式虽然简洁明了,但是在 Page 比较少的时候会有很大的空间浪费。
为此,我们可以使用 RadixTree 这种数据结构,用较少的空间开销,和不错的速度来完成这件事:
乍一看可能有点懵,这个跟 RadixTree 能扯上关系吗?可以把 RadixTree 理解成压缩过的前缀树(trie),所谓压缩,就是在一条路径上的节点都只有一个子节点,就把这条路径合并到父节点去,因此内部节点最少会有 Radix 个字节点。具体的分析可以参考一下 wikipedia 。
实现时,可以通过一定的空间换来时间,也就是减少层数,比如说3层。每层都是一个数组,用一个地址的前 1/3 的bit 索引数组,剩下的 bit 对下一层进行寻址。实际的寻址也可以非常快。
PageHeap
到这里,我们已经实现了 PageHeap,对所有 Page进行管理:
全局对象分配
既然有了基于 Page 的对象分配,和Page本身的管理,我们把它们串起来就可以得到一个简单的内存分配器了:
按照我们之前设计的,每种规格的对象,都从不同的 Span 进行分配;每种规则的对象都有一个独立的内存分配单元:CentralCache。在一个CentralCache 内存,我们用链表把所有 Span 组织起来,每次需要分配时就找一个 Span 从中分配一个 Object;当没有空闲的 Span 时,就从 PageHeap 申请 Span。
看起来基本满足功能,但是这里有一个严重的问题,在多线程的场景下,所有线程都从CentralCache 分配的话,竞争可能相当激烈。
ThreadCache
到这里 ThreadCache 便呼之欲出了:
每个线程都一个线程局部的 ThreadCache,按照不同的规格,维护了对象的链表;如果ThreadCache 的对象不够了,就从 CentralCache 进行批量分配;如果 CentralCache 依然没有,就从PageHeap申请Span;如果 PageHeap没有合适的 Page,就只能从操作系统申请了。
在释放内存的时候,ThreadCache依然遵循批量释放的策略,对象积累到一定程度就释放给 CentralCache;CentralCache发现一个 Span的内存完全释放了,就可以把这个 Span 归还给 PageHeap;PageHeap发现一批连续的Page都释放了,就可以归还给操作系统。
至此,TCMalloc 的大体结构便呈现在我们眼前了。