您有没有想过为什么人们要编写自己的内存分配器?他们是受虐狂吗?他们认为自己能写出比操作系统更好的内存分配器吗?难道他们不应该只使用垃圾收集语言吗?
大约在2010年左右,当Chipmunk2D还很新的时候,它没有使用任何自定义的分配器。即使是临时数据,也会在需要时分配、跟踪,并在我使用完后释放。在我开发它的时候,我使用的是OSX,这或多或少还不错。我甚至在YouTube上有一些旧的压力测试视频,在一台旧的Core2 Duo笔记本电脑上实时记录了数以万计的碰撞物体。然而,在Windows XP上运行相同的代码并不是那么棒。如果有人建议使用自定义分配器,我可能会对这个想法嗤之以鼻,说“那太愚蠢了,我可以做一些更简单的事情。”此外,看着OSX上的采样分析器数据告诉我,内存功能毕竟不到CPU时间的1%。为什么要费心去提高分配器的效率呢?另一方面,我知道我真正想做的是尝试将我所有的冲突数据打包在一起,使其缓存友好。所以我所做的就是保存各种类型的结构库。如果我需要碰撞对结构,我可以只从池中抓取一个。如果池是空的,我会先在一个大块中再分配几千字节。我对联系数据做了类似的处理,但是因为只需要保存一个帧,所以我可以一次将整个数据块释放回池中。这解决了我的Windows性能问题,而且本地化在其他平台上也带来了非常好的性能提升!
精明的读者现在可能正在执行手掌操作,就像我刚才描述的板条分配器和区域分配器一样。:)。
这些都是很棒的特性,很难实现合理的通用替代方案。另一方面,它们内部的许多程序和系统都有独特的内存要求。对于花栗鼠2D的碰撞系统,我需要的是完全不同的:
所以,虽然我当时并不知道,但我有几个理由想要一个自定义分配器,并且我意外地实现了几个!最后一项我特别感兴趣,因为它与我过去认为的自定义分配器完全相反。我脑海中有一个模型,其中自定义分配器与性能有关,垃圾收集与简化所有权有关,但事实证明两者并不是那么相互排斥。在垃圾收集语言中使用一些自定义分配器技术可以提高性能,而在传统语言中使用它们可以带来许多与垃圾收集相同的好处。
多年来,我浪费了很多时间调试内存问题。只需一个哈希表和一组链表,您就可以跟踪所有分配的历史记录。这使得跟踪自由错误、双重自由错误和内存泄漏之后的使用情况变得非常容易。在分配周围使用保护页,可以检测溢出。这样的技术可以很好地补充Valgrind等外部工具。在简化内存所有权、拥有帮助检测错误的工具和在问题确实发生时调试问题的工具之间,我可以高兴地说,我已经多年没有花很多时间调试内存问题了。:)。
你听到人们谈论的几个通用分配器非常简单,你可以用一段话来描述它们!(虽然我也会作弊并使用图表。)。
Chipmunk2D中的碰撞对示例基本上是一个平板分配器。其想法是,您的分配器只需要保留您已分配的大块内存块(SLAB)的列表,并将这些内存块分解成用于您的对象的固定大小的小内存块,这些内存块存储在空闲分配的链接列表中。诀窍是将分配本身用作链接列表节点,这样您就不必浪费任何额外的内存来进行跟踪。分配内存变得与将节点推入或弹出到链表中一样快,并且您只需在现有存储板中的空间用完时才与操作系统对话。此外,所有内存都打包在一起,这有助于很好地利用CPU缓存。作为额外的好处,您可以肯定地知道,您正在将较小的、短暂的分配打包在一起,并最大限度地减少主内存空间的碎片。
何时使用它:当您需要保留一个大小完全相同的短期分配池时。
线性分配器(有时称为凹凸分配器)是最简单、最有用的自定义分配器之一。简而言之:给定一个内存块,从头开始,一个接一个地进行分配。完成所有分配后,释放或重用该块。一般来说,您还需要处理对齐、溢出和内存不足问题,但这些问题都不是特别复杂。当您需要临时内存来构建临时数据结构,或者知道要分配的所有数据都有有限的生命周期时,线性分配器非常有用。这在处理GUI中的用户输入事件或游戏中的帧时效果很好。不仅您的数据最终被很好地打包到CPU高速缓存中,而且分配的实际成本只是很小的算术运算,并且释放基本上是免费的!线性分配器最大的缺点是您需要预先知道最坏的内存使用情况。
区域分配器(有时称为竞技场分配器)通过放宽预先内存分配使线性分配器更加灵活。不是单个内存块,而是一系列线性分配器。每当一个空间用完时,分配另一个块并切换到它。然后,您需要做的就是保存您分配的块的列表,以便在使用完区域后可以释放它们(或将它们返回到池中)。
区域分配器扩展起来非常简单,也是线程安全的。您可以根据需要为每个线程创建一个线性分配器,而不是单个线性分配器。只有由区域的线性分配器共享的块列表需要受互斥锁保护。
何时使用:当您需要有限寿命的快速临时内存,但不知道需要多少内存时。
伙伴块分配器是我个人实现的最奇特的分配器。它非常普通,而且正是我多年前认为的那种浪费时间的事情。另一方面,它并不特别复杂,我自己的实现仅有200SLOC。其基本思想是从要拆分的一大块内存开始,在进行分配时递归地将该块一分为二,直到达到所需的大小。因为子块总是被分成几对(伙伴),所以通过一些数学运算就可以很容易地计算出任何给定块的伙伴的位置。当释放一个块时,您可以很容易地检查该伙伴是否空闲,并将它们重新连接到一个更大的块中。
虽然我不能用一段话简明扼要地描述整个算法,但如果你想要更清晰的图像,互联网上有很多文章。
希望我已经说服了某些人,自定义分配器毕竟不是一个糟糕的主意,并给了他们一些搜索更多信息的条件。