FreeBSD的一个可扩展的并发malloc(3)实现
Jason Evans [email protected]
April 16, 2006
摘要
FreeBSD项目从版本5开始就致力为为多处理器计算系统持续提供可扩展支持。已经取得了足够足够的进展,C类库的malloc(3)内存分配器是运行在多处理器系统的多线程应用的潜在瓶颈。在本文中,我们提供一个新的内存分配器,建立在现有技术的基础上,为应用提供一个可扩展的并发分配。基准测试表明该分配器,对多线程应用内存分配可以随着处理器数量增加而扩展。同时,单线程分配性能与之前的分配器实现类似。
介绍
FreeBSD之前的malloc(3)由Kamp(1998)实现,通常被称为phkmalloc,长久以来被认为最有用的选择之一,并且相比已发布的其他实现表现良好(Feng and Berger, 2005; Berger et al., 2000; Bohra and Gabber, 2001)。然而,它是在多处理器系统很稀少时设计的,并且对多线程的支持也是参差不齐。FreeBSD项目致力于在SMP系统上持续努力提供可扩展的性能,并且已经取得了充分的进展,malloc(3)已经成为一些多线程应用的一个可伸缩扩展的瓶颈。本文提供一个新的malloc(3)实现,在这里被非正式的称之为jemalloc。
表面上看,内存分配与声明显得似乎是一个简单的问题,只不过为了对可用的内存保持跟踪,记录它正在使用中,需要“记账”一个标识位。然而,几十年的研究和许多分配器的实现都没有产生一个明显更好的分配器。事实上,度量分配器性能最佳的已知实践非常简单,度量性能的汇总统计也是如此。Wilson et al等人. (1995)对十年前的技术提供了一个很好地回顾。那时多处理器并不是一个重要的问题,另外,该回顾还提供了现代分配器面对的问题的一个全面的总结。下面简要介绍了特别影响jemalloc的各种各样问题,但是并不企图讨论当设计一个分配器时所需要考虑的所有问题。
分配器性能通常由应用的执行时间组合与应用内存使用的平均值或峰值来度量。它并不能充分的单独度量由分配器代码消费的时间。由于CPU缓存,RAM,和虚拟内存分页的影响,内存布局对如何快速的使运行中的应用停顿有重要的影响。现在普遍认为,综合的跟踪监控甚至不足以度量分配策略对碎片化的影响(Wilson et al., 1995)。分配器性能的唯一确定度量是通过度量应用实际的执行时间和内存使用获得的。这使得在限定分配器性能特征时形成了挑战。考虑到一个分配器对于某些特定的分配模式可能执行的非常差,但是如果所有基准测试的应用没有表现出任何此类模式,那么这个分配器可能会表现执行的很好,尽管某些工作负载表现不佳。这使得在广泛的多元化的应用中进行测试变得很重要。它还促进了分配器设计的一种处理方法,使退化边缘情况的数量与严重程度最小化。
碎片可以分为内部碎片和外部碎片。内部碎片是个别分配相关浪费空间的度量,由于未使用头部或尾部空间。外部碎片是基于虚拟内存系统的物理空间的度量,但不是由应用直接使用。这两种碎片对性能有不同特征的影响,依赖于应用的行为。理论上,所有碎片类型将会被最小化,但是分配器不得不进行一些权衡,从而影响每种类型碎片的发生次数。
过去的十年里RAM成本明显降低并且在更加丰富,所以phkmalloc被特别优化到更小的工作页集,jemalloc必须更多的关注缓存局部性,并且扩展一下,CPU缓存线的工作集。分页依然可能导致性能急剧下降(并且jemalloc不能忽视这个问题),但现在更普遍的问题是从RAM中获取数据涉及到与CPU性能相关的巨大延迟。
一个比其他分配器使用更少内存的分配器,不一定能展示更好的缓存局部性,如果应用的工作集不适合缓存,如果工作集是紧凑的压紧在内存中性能将会改善。适时地对象被分配的紧靠在一起,同样倾向于它们在一起使用,那么如果分配器可以连续的分配对象,那么就有可能改进提升局部性。事实上,总内存使用量是缓存局部性的一个合理代理;jemalloc第一次尝试最小化内存使用量,并且尝试仅当它不与第一个目标冲突时分配连续的资源。
现代的多处理器系统在每个缓存线的基础上保留了内存视图的一致性。如果两个线程同时运行在不同的处理器上并且修改在同一个缓存线里的不同的对象,那么这些处理器必须仲裁决定缓存线的所有权(图1)。这类错误的缓存线共享可能引起严重的性能下降。一种解决这个问题的方法时填补对齐分配,但是填充对齐直接违背了将对象尽可能紧凑的压紧在一起的目标;它可能造成严重的内部碎片。jemalloc在多分配竞技场(Arena)上代替信任来减小这个问题,并且将它留给应用程序作者来填充分配,为了避免在关键的性能代码里错误的缓存线共享,或者在代码里一个线程分配对象,并将对象传递给多个其他线程。
运行在多处理器系统上的多线程应用的分配器的一个主要目标是减少锁竞争。Larson and Krishnan (1998)在展示与测试策略方面做的很好。 他们尝试在他们的分配器里上锁,那并不是使用一个单独的分配器锁,每一个空闲列表有它自己的锁。这有一些帮助,但是没有足够的扩展性,尽管最小化了锁竞争。他们将此归因于“缓存晃动” – 在操作分配器数据结构期间,处理器之间缓存数据的快速迁移。他们的解决方法通过使用多个分配器竞技场(Arena),并且通过线程的唯一标识散列分配线程到各个竞技场Arena(图2)。这工作的很好,并且已经被其他实现使用(Berger et al., 2000; Bonwick and Adams, 2001)。jemalloc使用多竞技场(Arena)方式,但是分配线程到各个竞技场(Arena)动作使用了一个比散列更可靠的机制。
文章的剩余部分描述了主要的jemalloc算法与数据结构,展示了在多处理器系统上多线程应用的性能与可扩展性度量指标的基准测试,与单线程应用的性能与内存使用量一样,并且讨论了内存碎片的度量指标。
算法与数据结构
FreeBSD支持实时的分配器配置通过配置文件/etc/malloc.conf,MALLOC OPTIONS环境变量,或者malloc选项的全局变量。这些提供了一个低开销,无侵入配置机制,对调试与性能调优都有用。jemalloc使用这个机制支持phkmalloc支持的变量调试参数,以及公开的各种与性能相关的变量参数。
每个应用被配置在运行时有一个固定的竞技场(Arena)数量。默认的,竞技场(Arena)数量取决于处理器数量:
单处理器:所有分配器使用一个竞技场(Arena)。使用多个竞技场(Arena)是没有意义的,因为分配器内竞争仅可能发生在如果一个线程在分配期间被抢占。
多处理器:使用竞技场数量是处理器数量的4倍。通过分配线程至一个竞技场集,单独的竞技场被并发使用的可能性就会降低。
第一次线程分配或释放内存,它被分配到一个竞技场。与线程的唯一标识散列法不同,竞技场是以循环的方式被选择的,这样就可以保证所有竞技场都有大致相同数量的线程分配给他们。线程唯一标识的可靠伪随机散列(实际上,唯一标识就是指针)是众所周知的困难,这也是最终促成了这种方法的原因。它依然存在线程与其他线程竞争一个特殊的竞技场的可能,但是平均而言,初始化分配(可以理解为一次性的静态分配)不可能比循环分配更好。动态再均衡可能降低竞争,但是必要的记录代价很高,而且很少会带来足够的好处来保证开支。
Thread-local存储(TLS)对循环分配竞技场的高效实现非常重要,因为每个线程的竞技场分配需要存储在某个地方。Non-PIC code 与一些架构不支持TLS,所以在这些情况下,分配器使用线程的唯一标识散列。线程特定的数据(TSD)机制由pthreads类库提供,是TLS的一个可行的替代选择,除了FreeBSD的pthreads实现在内部分配内存外,如果分配器使用TSD将导致无限递归。
所有由sbrk(2) 或 mmap(2) 从内核请求内存都以“块”大小的倍数进行管理,块的base地址始终是块大小的倍数(图 3)。这样块的对齐允许对一个分配关联的块的计算是一个常量时间。块通常由一些特殊的竞技场(Arena)管理,并且观察这些关联对纠正分配器的功能很关键。块大小默认为2 MB。
分配大小的种类有三大类:small, large, and huge。所有分配请求都四舍五入到最接近的大小分类的边界。Huge的分配比一个块的一半还大,并且直接基于专用块。关于huge的分配的元数据被存储在一个单独的红黑树。因为大多数应用创建的huge分配很少,所以使用一个单独的树不会是可扩展性的问题。
对于small 与 large 的分配,使用二进制伙伴算法将块切分为页运行。运行可以反复的分成两半,小到只有一页,但是仅能以切分过程相反的方式合并。运行的状态信息被存储在每个块的开头作为页映射。通过将这些信息从运行中分离出来存储,页只有他们在使用它时才会被触及。这还允许将运行资源奉献给大型分配,这些分配大于页的一半,但不大于块的一半。
Small的分配分成3个子类目:tiny,quantum-spaced,与sub-page。现代架构根据数据类型对指针强加对齐约束。malloc(3)需要返回为任何目的而适当对齐的内存。这种最坏情况下的对齐要求在这里被称为量子大小(quantum size)(通常为16字节)。在实践中,2的幂次方对齐适用于tiny的分配,因为他们不能够包含足够大的对象,需要量子对齐(quantum对齐)。图4展示了所有分配大小的size分类。
通过除掉quantum-spaced这类尺寸,为没有子类目的small分配将会很简单。无论怎样,大多数应用主要分配的对象小于512字节,并且quantum spacing的尺寸类大大的降低了平均内部碎片。Large尺寸类可能会引起外部碎片的增加,但是实际上,降低的内部碎片通常大于外部碎片增长的偏移量
Small分配是分开的,这样每次运行都管理一个单独的尺寸类。区域位图存储在每次运行的开始,这比其他方法有几个优势:
• 位图可以快速扫描到第一个空闲区域,这允许in-use(在用)区域的紧密打包。
• 分配器数据与应用数据是分开的。这降低了应用破坏分配器数据的可能性。这也可能增加了应用数据的局部性,因为分配器数据没有与应用数据混合在一起。
• Tiny区域可以更容易的支持。这将更加困难(如果使用其他方法),例如,如果一个免费列表是嵌在自由区域。
运行的报文头部有一个潜在的问题:他们使用了应用程序原本可以直接使用的空间。这对于大于运行报文头大小的尺寸类可能造成严重的外部碎片化。为了限制外部碎片,所有都使用multi-page(多页)运行,除了最小的尺寸类外。因此,对于最大的small尺寸类(通常为2kb区域),外部碎片被限制在大概3%。
由于每次运行限制了它可以管理多少区域,所以必须为每种尺寸类的多次运行做好准备。在任何给定的时间,每个尺寸类最多有一个"当前"运行。当前运行保留到直到它完全充满或完成清空。考虑一下,如果没有滞后机制,一个malloc/free可能导致运行的创建/销毁。为了消除这个问题,运行基于充满度的四分位数分类,并且运行在QINIT的类别永远不会被销毁,它必须首先提升到一个更高的充满度类别(才可被销毁)(图5).
充满度类别也为从non-full(非满)状态的运行中选择一个新的当前运行提供一个机制。优先级顺序是: Q50, Q25, Q0, 然后 Q75. Q75 是最后的选择,因为像这类运行可能是几乎完全满的状态;通常选择像这种的运行可能导致当前运行快速周转(可类比为:上下文快速切换)。
原文
https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf