引子
在某些工作负载中,随着时间的推移,内存的使用会逐渐增长,直到 OOM。后面发现是内存碎片问题,而将系统默认的内存分配器(glibc malloc[1])换成 jemalloc[2] ,能有效控制内存的增长上界。
为了解其背后原理,便找来 jemalloc 最初的论文:A Scalable Concurrent malloc(3) Implementation for FreeBSD[3] 来一探究竟。当然,相比 2006 年论文发表时,当前的 jemalloc 可能已经发生了很大改变,因此本文只对当时论文内容负责。更多 jemalloc 机制,大家可以去其 github 仓库[4]查看文档和源码。
背景
在探讨论文的主要思路之前,我们先简单回顾下内存分配器(memory allocator)的作用和边界。简言之:
- 对下,向操作系统申请大块内存(使用 sbrk、mmap 等系统调用)
- 对上,处理应用层的各种尺寸的内存申请请求(malloc(size)),并在应用层“表示”不用(free)后进行释放
往小了说,分配器的功能非常简单:分配和释放(malloc 和 free)。想象中,实现也应该很简单,只需利用一个表来记录所有已使用内存和未分配内存( a bit of bookkeeping),然后:
- malloc 请求来了,先去空闲表中找,不够的话就问操作系统要
- free 请求来了,还回空闲表中,如果空的多了,就还给操作系统
但为了实现内存的高效分配和回收、控制内存的利用率,其间的学问就大了去了,CPU 缓存、 RAM 特性和虚拟内存都会对其造成影响。其中最核心的点,就是如何进行内存组织排布,以便在用户高并发、多尺寸、不定时的申请和释放后,仍然能保证较低的调用延迟和较高的使用率。当然,对于本论文来说,还要加上一条:越多的核数能够支持越高的并发,谓之可扩展(scale)。
需要说明的是,不要将本文的内存分配器和各种具有自动 GC 功能的编程语言运行时(比如 Java 的 JVM、Golang 的 Runtime)所混淆。后者是在 malloc/free 的基础上,往上继续封装了一层,通过对象间的引用关系来追踪每个对象的生命周期,以自动地回收空间。
可以这么理解,C++/C 等编程语言要用户自己通过 malloc/free 来管理内存;而使用 Java/Golang/Python,用户无脑新建对象就可以了,什么时候回收是语言“运行时”的工作。而我们本文只讨论前者。
但从另外角度来看,存储引擎中也实现了类似的功能。因为存储引擎本质上也是要面对用户 put/delete 的的请求,来进行存储的的分配和释放。只不过这里的存储,就不局限于内存(存储引擎多是 disk-based)。但其主要思想非常相似,比如使用空闲列表(free list)对可分配内存进行追踪。
主要思想
从论文标题可以看出,jemalloc 在提出时,主要为了解决在多核时代下内存分配器性能随核数而 scale 问题,但其实论文花了相当多的篇幅来阐释如何进行内存排布来解决碎片问题。下面,我们就围绕这两个方向来大致窥探下其原理。
多核并发
在多核时代进行内存分配时,主要面对的问题有:
- 抢锁竞争
- 缓存震荡
为了保证全局数据结构的一致性的问题,就必须引入某种手段(比如锁)来进行协调。但如果多线程抢锁过于频繁,就会造成严重的性能下降。为了降低对锁的竞争,自然的想法就是,对主要的全局数据结构粒度拆分(比如 Java 的 ConcurrentHashMap 就是将哈希桶分成了多个段进行上锁)。
分配器中最重要的数据结构就是空闲列表,我们可以将空闲列表拆分成多个,每个空闲列表使用单独的锁。这样可以缓解多线程的竞争问题,但却解决不了多核架构的另外一个问题——缓存震荡(cache sloshing)。
在多核架构中,如果两个线程没有正确的共享缓存。比如线程 A 和线程 B 共享了一个缓存行(Cache Line),且两线程分别会反复修改缓存行的不同部分。如果 A 和 B 调度到了不同的 CPU 上,就会造成 Cache Line 所有权的反复竞争。
cache sloshing
为了解决此问题,jemalloc 会将所管理的内存分为几个(通常是 CPU 核数的四倍)区域( 称为 Arena,“竞技场”)。在不同线程的 client 到来时,会均匀地(round robin)绑定到某个 Arena,之后该 client 所有内存的申请和释放都发生在该 Arena。论文还提及了 Larson and Krishnan (1998) 之前使用 hash 的方法进行绑定,但由于哈希过程是伪随机的(pseudo-random),因此很难保证线程到 Arena 的均匀。
下面我们来讨论如何在每个 Arena 内排布内存来应对用户对象的申请和释放。
内存排布
在开始讨论前,我们首先引入一个衡量内存利用率的指标——内存碎片(fragments),分为内部碎片和外部碎片。为了理解这个概念,我们可以思考下平日中“丰巢寄存柜”的工作原理,借此意象来比对理解分配器如何进行内存排布的。
“丰巢”一般是问物业要一块地方,来建立一个快递柜(对应一个 Arena),就近服务小区居民。对于每个快递柜,会进一步将其划分成一个个格子,但如何划分就是讲究之处。
由于快递通常大小不一,如果将快递柜等分,会有什么问题?
- 对于小快递,每个格子会浪费很多空间(内部碎片)
- 对于大快递,所有格子都放不下(明明总空间够,但却放不下,此时整个格子就是外部碎片)
为了解决这个问题,我们日常中所见的快递柜多会分成大大小小尺寸不等的格子。但仍然可能有快递员,选一个大格子却放一个小快递。对于真实世界来说,这无解,因为所有格子是在快递柜出厂时就分好的,也即“静态分配”的;但在计算机世界,我们对内存的划分都是“逻辑上的”,因此可以做到“动态分配”。
如果有人往大格子中放了一个小对象,可以将剩下的空间切出来形成一个新格子,给后面的对象用。且,在后面该小对象被取走后,可以将两个格子重新合并成一个大格子,以应对更大的对象。
这就是“伙伴分配算法”的基本思想,当然,这并非 jemalloc 原创。但 jemalloc 在“二分伙伴算法”基础上,通过统计用户负载,进一步精细化管理内存,从而控制了碎片的无节制增长。
比如 jemalloc 发现,大部分的对象不超过 512 B,因此引入了“量子间距”(quantum-spaced)。对这个尺寸附近及以下的格子并不使用伙伴算法,而是在一个页内进行静态分配,让其全是同样大小的格子——这样有什么好处呢?由于每个格子大小固定,就可以使用 bitmap 来充当空闲列表,从而加快了空闲列表的查找。
memory layout
这种精细化管理虽然会带来比较多的外部碎片(很多格子用不了),但却能更多地减少内部碎片(大部分格子能用满),得可偿失。
评估
由于实践中不同应用程序内存负载的千变万化,如何衡量分配器好坏本身就是一个非常复杂的问题。很可能一个分配器设计出来后,在某些用户负载中表现良好、但在另外负载中却表现极差,此时我们很难说其是一个好的分配器。好的分配器应该是多数方面表现均衡、某些方面表现突出——因为面对如此复杂的现实世界,不可能所有方面都好,总会有取舍。
因此作者花了很大功夫来设计了一套分配器性能评价工具,来证明 jemalloc 的优势,但这部分就不是本文重点了,感兴趣的同学可以去查论文原文。
小结
我们首先铺垫了内存分配器的一些背景,说明了分配器是什么(malloc/free),不是什么(auto gc),和什么相似(存储引擎 put/get);然后分析了论文中实现 jemalloc 的主要目标——多核扩展;继而讨论了其主要实现思路——为了避免竞争和缓存震荡问题,引入均匀的内存分区;为了减少碎片问题,基于伙伴算法对分区内存精细化管理,并以“丰巢快递柜”比对来帮助理解。
最后提了一嘴如何评估分配器的好坏。需要强调的是,本文旨在帮你建立直觉,有想要展开查证之处,强烈推荐大家去读论文原文。
参考资料
[1]glibc malloc: https://github.com/lattera/glibc/blob/master/malloc/malloc.c
[2]jemalloc: https://jemalloc.net/
[3]A Scalable Concurrent malloc(3) Implementation for FreeBSD: https://www.bsdcan.org/2006/papers/jemalloc.pdf
[4]jemalloc github 仓库: https://github.com/jemalloc/jemalloc