内存分配器
Doug Lea
简介
内存分配器来自于底层软件工程的案例研究.我从1987年开始编写一个内存分配器,并且(在许多志愿者的帮助下)一直维护和完善它.这个分配器实现了标准C例程malloc(),free()和realloc(),以及一些辅助工具例程.这个分配器从来没有一个特定的名字。许多人将它称为Doug Lea的Malloc,或者简称为dlmalloc.
这个分配器的代码已经被放在公共域里(可以从ftp://g.oswego.edu/pub/misc/malloc.c获取),并且被广泛的使用着:它在一些linux版本里面作为默认的malloc本地版本被使用;它被编译到一些公共的软件包里(覆盖本地的malloc),并且已经被用于各种PC环境以及嵌入式系统,以及许多甚至我也不知道的地方。
在写了一些几乎严重依赖于动态分配内存的C++程序之后,我编写了这个分配器的第一个版本。我发现它运行起来比我期望的慢许多并且总体内存消耗量更大。这是由我用来运行这些C++程序的系统(主要是早期版本的SunOS和BSD)上的内存分配器的特性决定的。为了找出问题,首先我用C++写了一些特殊用途的分配器,通常是通过重载各个类的new操作符。它们中的一些在一篇C++分配技术论文中被描述,这篇论文被收入1989 C++报告Some storage allocation techniques for container classes中。
尽管如此,当我构造一些一般用途的编程支持类时(从1986到1991,我是GNU C++库libg++的主要作者),我很快认识到为每个趋向于被动态分配并且被大量使用的新类构造一个特殊的分配器不是一个好策略。我需要一个更广泛的解决方案--编写一个在普通C++和C负载下足够好的分配器使得程序员不用再尝试编写特殊用途的分配器,除非是在非常特殊的条件下。
本文展现了这个分配器的一些主要设计目标,算法以及实现考虑。更详细的文档可以在发布的代码中找到。
目标
一个好内存分配器需要均衡多个目标:
最大化兼容性
一个分配器应该可以和其他程序插件式兼容;特别是它应该遵守ANSI/POSIX惯例。
最大化可移植性
尽可能少的依赖于系统依赖的特性(像是系统调用),同时仍然要为只在某些系统上才存在的有用特性提供选择性的支持;在对齐和寻址规则上为所有已知的系统限制保持一致。
最小化空间
分配器不应该浪费空间:它应该尽可能少的从系统获取内存,并且以最小化分段(连续内存块中不被程序使用的“洞”)的方式维护内存。
最小化时间
malloc(),free()和realloc()例程在平均情况下应该尽可能的快。
最大化可调整性
可选的特性和行为应该可以被用户静态的(通过类似于#define的东西)或动态的(通过mallopt这样的控制命令)控制。
最大化局部性
分配的内存快通常在临近的地方被使用。这在程序执行期间帮助最小化页面和cache丢失。
最大化错误探测
把一个一般用途分配器也当作一般用途内存错误检测工具(像是Purify)使用看上去是不可能的。尽管如此,分配器应该提供一些方法探测由于改写内存,多次释放等等这样的错误导致的内存混乱。
最小化异常性
一个使用默认设置的分配器应该可以在一个很大程度上依赖于动态分配的宽阔的真实负载下正常工作--窗口工具包,GUI应用程序,编译器,解释器,开发工具,网络(包)密集型程序,图像密集型包,网络浏览器,字符串处理应用程序,等等。
Paul Wilson和他的同时已经在分配技术反面写了一篇出色的调查论文,更详细的讨论了以上目标中的一些。见1995年9月International Workshop on Memory Management中Paul R. Wilson, Mark S. Johnstone, Michael Neely, 和 David Boles的“Dynamic Storage Allocation: A Survey and Critical Review”(也可以通过 ftp获取)。(注意,他们描述的我的分配器的版本并不是最新的)。
和他们讨论的一样,通过最小化浪费(通常由分段导致)而最小化空间对于任何分配器来说都必须是一个主要目标。
举一个极端的例子,所有malloc()版本中最快的那一个总是分配系统上可用的下一个顺序的内存位置,并且相应的最快版本的free()是一个空操作。尽管如此,这样的实现是无法接受的:因为它从来不回收不使用的空间,它导致程序很快会用光内存。现实中被使用的一些分配器在某些负载下看到的浪费几乎就是这种极端状况。Wilson还指出,浪费可以用金钱来衡量:从全球来考虑,糟糕的分配方案可能会让人们在内存条上花费几十亿美元。
当时间-空间问题争霸时,权衡取舍和妥协是永不终止的。这里只是许多例子中的一小部分:
沿着这些线路,没有一组妥协是完美的。尽管如此,这些年来,这个分配器已经进化到做了一些大部分用户都可以接受的取舍。仍然持续影响这个malloc进化的驱动力包括:
算法
malloc算法的两个核心元素从最早的版本开始就一直保持不变:
边界标签:
内存块在它的前面和后边都携带他们的大小信息字段。这是为了两个重要功能:
最初版本实现的边界标签就是这种形式的。最近的版本忽略了正在被程序使用的块的尾部字段。这本身是一个小的权衡:这些字段在块活动时永远不会被使用,所以没有必要出现。消除他们减小了开销和浪费,尽管如此,这些字段的缺失稍微的弱化了错误检测,这使得无法检查是否用户错误的覆盖了那些具有已知值的字段。
分箱
可用的块被维护在按大小分组的箱子中。有许多(128个)固定大小的箱子,大小近似填充到对数。小于512字节的箱子只保存特定的一个尺寸(按8字节填充,简化了8字节对齐的实行)。以最小块优先,最佳适合的顺序搜索可用的块。如Wilson所示,(各种类型和近似的)最佳适合方案在实际负载下相对于其他一般方法(像是最先适合)趋向于产生最少的分段。
直到1995年发布的半本,块在箱子里还是未排序的,所以最佳适合策略还只是近似的。更近一些的版本并不在箱子中按大小排序块,而是通过最老优先规则。(这是在发现为了避免明显的糟糕情况花费稍许时间是值得的之后决定的)。
因此,这个算法一般被归类为带合并的最佳适合算法:释放的邻近的块被合并,并被保存在按大小搜索的箱子里。
这种方法导致每个块都有固定的薄记开销。因为大小信息和箱链接必须报存在每个可用的块中,在指针为32位的系统中最小的可分配块是16字节,而在指针为64位的系统上是24字节。这些最小尺寸比大部分人期望的大许多--它们可能导致巨大的浪费,例如,在分配许多小链表节点的应用程序中。尽管如此,最小16字节至少是任何需要8字节对齐,存在malloc薄记开销的系统的一个特性。
这个基本算法可以被做的非常快。即使它取决于寻找最佳适合的搜索机制,使用的索引技术,利用特殊情况,并且仔细的编写代码使得平均情况下只需要很少的几十个指令,当然也依赖于机器和分配模式。
当通过边界标签进行结合和通过分箱达到最佳适合表现了这个算法的主要想法的同时,进一步的思考产生了一些探索性的改进。包括局部化保持,拓展块保留,内存映射和高速缓冲。
局部化保持
程序在大约相同的时间里分配的块趋向于具有类似的引用模式和共存的生存时间。局部化保持最小化了页面失败和cache丢失,在现代处理器上这可能产生戏剧性的影响。如果局部化是唯一的目标,分配器可能总是尽可能接近的分配每个连续的块。尽管如此,这个最近适用(通常通过下一个适用近似)策略可能产生十分恶劣的分段。在malloc的当前版本中,一个下一个适用的版本只在受限制的上下文中使用,它在尽可能不与其他目标冲突的情况下保持局部化:如果一个指定大小的块不可用,最近被分割的空间被使用(并且重新分割),如果它足够大;否则使用最佳适用。这种受限制的使用消除了无法分配一个完全可用的现存块的情况;因此至少消除了这种形式的分段。并且,因为这种下一个适合的形式比最佳适合的箱搜索要快,它加快了平均malloc的速度。
拓展块保留
“拓展块”(Kiem-Phong Vo给它起了这个名字)表示紧邻着从系统分配的最高地址的空间。因为它在边界处,它是唯一一个可以任意的扩展(通过Unix的sbrk系统调用)成更大的块(除非因为所有的内存已经被消耗光了导致sbrk失败)。
一种用于处理拓展块的方法是使用与处理其它块相同的方法处理它。(这种技术在这个malloc的大部分版本中使用,直到1994年)。在这简化和加速实现的同时,不注意它会导致一些十分糟糕的错误情况下的空间特性:在这些问题中间,如果存在其它可用的块时拓展块被使用,就增加了稍后的请求会导致本来可以避免的sbrk的机会。
现在一个更好的策略被使用:把拓展块看作比其它所有块都大的块,因为它可以变得更大(直到达到系统限制),并且在最佳优先扫描中这样使用它。这导致拓展块只在没有块存在的情况下被使用,进一步避免了可避免的分段。
内存映射
除了通过sbrk扩展一般用途的分配区域,许多版本的UNIX支持像mmap这样为应用程序分配单独的不连续内存区域的系统调用。这在malloc中为满足一个内存请求提供了第二种选择。请求并且返回一个内存映射的块可以进一步的减少下游的分段,因为一个被释放的内存映射不会创建一个需要被管理的“洞”。尽管如此,mmap本身的限制和开销,只有在十分受限制的情况下才值得这么做。例如,在所有当代的系统中,所有被映射区域必须是页面对齐的。还有,调用mmap和mfree比雕刻出一个已存在的内存块慢许多。由于这些原因,现在的malloc版本只在以下情况下依赖于mmap:1,这个请求大于一个(可动态调整的)阀值(现在默认为1MB);2,这个被请求的空间无法从通过sbrk获取的已存在的区域里获取。
部分原因是因为在很多程序中mmap并不总是适用,当前版本的malloc也支持裁减主区域,这可以达到内存映射的一种效果--将不使用的空间还给系统。当一个生存时间很长的程序包含分配大量内存的高峰,然后又是更长时间的需求内存的低谷时,整体的系统性能可以通过把可扩展块的未使用部分返还给系统而得到提高。(在几乎所有的UNIX版本中,sbrk可以使用负参数达到这个效果。)释放空间使底层操作系统减小对交换空间的许求并且重用内存映射表。尽管如此,对于mmap来说,调用本身可能十分昂贵,所以在末尾的未使用内存超过一个可调整的阀值时才被尝试使用。
高速缓冲
在这个基本算法的最简单的版本中,每一个被释放的块被立即合并到相邻的块以形成一个尽可能最大的未使用块。类似的,块只有在被明确请求时才会被创建(通过分割较大的块)。
用于分割和合并块的操作需要花费时间。这种时间开销有时可以被避免,通过使用以下两种高速缓冲策略中的一种:
延迟合并
不把被释放的块合并起来,而是让他们保持当前大小以期望不久会有一个相同大小的请求。这节省了一次合并和随后的一次分割,以及用于寻找一个要分割的不完全匹配块的时间。
预分配
不是逐一的分割出新块,而是一次预先分割出多个。这通常要比一次一个要快。
因为分配器中的基本数据结构允许在malloc,free或realloc的任何一个中任意时刻进行合并,相应的高速缓冲探索都很容易应用。
很明显,高速缓冲的有效性依赖于分割,合并和查找的开销相对与用于跟踪被高速缓冲的块的开销。此外,有效性还不太明显的依赖于决定什么时候高速缓冲或合并它们的策略。
高速缓冲在连续分配和释放很小的块的程序中是一个很好的主意。例如,如果你写一个分配和释放很多树节点的程序,你会认定高速缓冲一些节点是有价值的,假设你知道一种快速的方式能做到这一点的话。尽管如此,因为不了解程序,malloc无法知道为了满足一个较大的请求而合并高速缓冲的小块,或者从别的地方获取一个较大的块哪个主意更好。对于分配器来说对这样的事情很难做出更明智的猜测。例如,让分配器判断通过合并块能获取多少总共的连续空间的开销和直接合并然后分割它们的开销一样大。
以前版本的分配器使用一些搜索-排序尝试对高速缓冲进行足够的猜测,尽管偶然会遇到最坏情况。随着事件的流逝,这些尝试在实际的负载下似乎是渐渐的失去效果。这可能是因为严重依赖于malloc的实际程序趋向于使用大量不同大小的块。例如,在C++程序中,这可能相应于程序中使用的不断增长类的数量。不同的类趋向于具有不同的大小。
作为一个结论,当前的版本从来不高速缓冲块。似乎把精力集中于进一步减少处理非高速缓冲块的开销会更有效,而不是依赖于进行减少的策略和尝试。尽管如此,这个问题仍然需要进一步的实验。
后备
在一些程序中仍然时分需要有一种高速缓冲,但是它并没有在该分配器中被实现--对很小的块进行后备。如上面提到的,基本算法对于很小的请求也征用一个最小的块,这是十分浪费的。例如,一个链表在指针为4字节的系统上可能分配只包含两个指针的节点,也就是说只需要8个字节。因为最小块大小是16字节,那么只分配链表节点的用户程序就会遭受额外的100%的开销。
消除这个问题的同时仍然维持可移植的对齐就需要分配器不征用任何额外的开销。实现这个目的的技术是存在的。例如,块会被检查,通过地址比较看看它们是否属于一个更大的汇总空间。尽管如此,这么做可能导致巨大的开销;事实上在该分配器中这个开销是无法接受的。如果块不是地址被跟踪,除非任意性被限制,检查可能导致在内存上进行随机搜索。此外,这种支持需要采用一种或多种策略控制是否及如何合并小块。
这个问题和限制导致极少数情况下程序员要经常写他们自己的特殊目的的内存管理例程(例如C++中的重载操作符new())。依赖于大量很小的内存块,但是知道总数近似值的程序会发现构造一些很简单的分配器是很有用的。例如,块可以从一个具有内嵌free链表的固定数组中分配,如果该数组快被用完,就依赖malloc作为补充。一些更灵活的方法是,它们可以基于GNU gcc和libg++提供的C或C++版本的obastack。
Doug Lea最后修改: Wed Dec 4 12:20:31 EST