使用动态大小的内存池在多线程C/C++中实现一个内存管理器?

11

背景:我正在开发一个多平台框架,将作为游戏和实用工具创建的基础。基本想法是拥有一组工作线程,每个线程都在自己的线程中执行(此外,工作线程在运行时也可以生成)。每个线程都将拥有自己的内存管理器。

我一直想创建自己的内存管理系统,并且我认为这个项目非常适合尝试一下。我认为这样的系统适合于这个框架的使用类型,因为它们通常需要在实时中进行内存分配(例如游戏和纹理编辑工具)。

问题:

  • 没有普遍适用的解决方案(?) - 该框架将用于游戏/可视化(不是AAA级别,而是独立/玩家级别)和工具/应用程序创建。我的理解是,在游戏开发中,通常(至少对于游戏机游戏)只需在初始化时分配一大块内存,然后在内存管理器中内部使用此内存。但是这种技术是否适用于更一般的应用程序?

    在游戏中,理论上可以知道场景和资源需要多少内存,但例如,照片编辑应用程序将加载各种不同大小的资源...因此,后一种情况需要更动态的内存“块大小”?这导致了下一个问题:

  • 移动已分配的数据并保持有效指针 - 通常在堆上进行分配时,您将获取指向内存块的简单指针。在自定义内存管理器中,据我所知,类似的方法是返回指向预先分配块中某个空闲位置的指针。但是,如果预分配的块太小并且需要调整大小甚至进行碎片整理怎么办?数据将需要在内存中移动,旧指针将无效。是否有一种透明地包装这些指针的方法,但仍可像通常使用它们一样在“外部”使用C++指针?

  • 第三方库 - 如果没有透明地使用自定义的内存管理系统来进行应用程序中的所有内存分配,那么我链接的每个第三方库仍将在内部使用“旧”的操作系统内存分配。我了解到通常情况下,库会公开一些函数来设置自定义分配函数,以供该库使用,但并不保证我将使用的每个库都具有此能力。

  • 问题:是否可能和可行实现一个可以使用动态大小的内存块池的内存管理器?如果是,则如何进行碎片整理和内存调整,而不破坏当前正在使用的指针?最后,如何最好地实现这样一个系统与第三方库协作工作?

    我也感谢任何相关的阅读材料、论文、文章等!:-)


    1
    我会在评论中添加一些我发现有趣的资源,供未来的读者使用(尽量不要混淆实际问题):http://www.swedishcoding.com/2008/08/31/are-we-out-of-memory/ 以及 Jason Gregory 的书《游戏引擎架构》- 第5.2节内存管理。 - andsve
    1
    如果你不必这样做,也没有得到报酬,就不要重新发明轮子。 - AJMansfield
    1
    @AJMansfield 我这么做是为了学习和满足好奇心!同时,我也没有找到与我的具体问题相关的好答案,我想可能还有其他人对同样的事情感兴趣。 - andsve
    4个回答

    10

    作为曾经为几代游戏主机编写许多内存管理器和堆实现的人,让我告诉你,这已经不值得再做了。

    您的信息已经过时了-在Gamecube时代[大约2003年],我们曾使用您提到的方法- 分配一个大块并手动使用针对每个游戏进行调整的自定义算法来切割该块。

    一旦虚拟内存出现(xbox时代),游戏变得更加复杂[因此进行更多的分配并变成多线程]地址碎片化使得这种方法不可行。 因此,我们转向使用自定义分配器仅处理某些类型的请求-例如物理内存、无锁小块低碎片堆或最近使用的块的线程本地缓存。

    随着内置内存管理器变得越来越好,在一般情况下很难比它们更好,特定用例也只是勉强。 Doug Lea Allocator [或现在主流的C++ Linux编译器附带的任何内容]和最新的Windows低碎片堆真的非常好,您花费时间在其他地方投资会更好。

    我在工作中有测量各种指标的电子表格,涵盖了所有大型名称和我多年来收集的相当多数量的分配器。 基本上,虽然专业分配器可以在一些指标[每个分配的最低开销、空间接近度、最低碎片化等]上获胜,但在总体指标上,主流分配器是最好的。

    作为你库的用户,我个人更喜欢你在需要时分配内存。使用`operator new`或`new`运算符,我可以使用标准C++机制来替换它们并使用自定义堆(如果我确实有一个自定义堆),或者我可以使用特定于平台的方式来替换你的分配(例如Xbox上的XMemAlloc)。我不需要标记[捕获调用堆栈远比这更好,我想要的话也可以做到]。在此之后,你给我一个接口,在需要分配内存时会回调,这只会让你实现起来很麻烦,而我可能只是将其传递给`operator new`。最糟糕的事情是“知道得最好”,并创建自己的自定义堆。如果内存分配性能是个问题,我更愿意你分享整个游戏使用的解决方案,而不是自己动手。

    2
    谢谢你详细的解释! :-) 我主要是根据Jason Gregory在《游戏引擎架构》一书中所写的内容,他更或多或少地解释了Uncharted在PS3上如何进行内存管理。然而,我相信你的话,试图超越操作系统实现可能是一件坏事。但我的主要目标和问题本质上是学习如何实现自定义内存分配器,即使性能非常应用程序特定。 因此,如果最终决定尝试,它将成为每个应用程序(和线程)的可选设置。 - andsve
    3
    做吧,现代编程中最有趣的事情之一就是实现堆!很遗憾你生活在一个这可能不重要的世界里,但不要让这阻止你,因为这真的非常有趣。而且可能会有用。 - Mike Vine

    3
    现在,正确的答案是:不要再实现另一个内存管理器。
    实现一个不会在各种使用模式和事件下失败的内存管理器非常困难。你可能能够构建一个在你自己的使用模式下工作良好的特定管理器,但编写一个为许多用户工作良好的管理器几乎是没有人真正做好的全职工作。更糟糕的是,实现一个内存管理器非常容易,在99%的时间内运行良好,但在1%的时间内崩溃或突然占用系统上大部分或全部可用内存,原因是预期之外的堆碎片化。
    我是一个曾经编写过多个内存管理器、看过多人编写他们自己的内存管理器以及看到更多人尝试编写内存管理器并失败的人。这个问题很难应对,不是因为编写模板分配器和基于继承等泛型类型很难,而是因为该线程中提出的其他解决方案 tend to fail under corner types of load behavior(在加载行为的边角情况下往往会失败)。一旦你开始支持字节对齐(如所有真实世界的分配器必须)那么堆碎片化就会出现。对于小型测试程序效果良好的可爱启发式方法,一旦面对大型现实世界程序就会惨败。
    一旦你使其工作起来,其他人将需要:cookie来验证内存践踏;堆使用情况报告;内存池;池中的池;内存泄漏跟踪和报告;堆稽核;块分裂和合并;线程本地存储;底部搜索;CPU和进程级页面错误和保护;设置、检查和清除“空闲内存”模式即0xdeadbeef;以及我头脑中无法想到的其他内容。
    编写另一个内存管理器完全属于过早优化。由于有多个自由、良好的内存管理器,并且它们背后拥有数千小时的开发和测试,你必须证明你花费自己的时间成本是以某种可衡量的改进结果为代价,并且你可以免费使用。
    如果你确信要实现自己的内存管理器(希望在阅读此消息后你不确定),请仔细阅读dlmalloc源代码,然后再仔细阅读tcmalloc源代码,然后确保你理解实现线程安全与线程非安全内存管理器的性能权衡,以及为什么天真的实现 tend to give poor performance results(往往会导致性能表现不佳)。

    3

    如果您想编写自己的malloc()/free()等函数,可能应该先查看现有系统(例如dlmalloc)的源代码。但值得注意的是,这是一个困难的问题。编写自己的malloc库很难,而要超越现有的通用型malloc库则更加困难。


    1
    但这是他提到的项目的核心。当然,在每个地方实现自定义内存管理可能是一个坏主意,但在经过分析后明智地使用它可以带来巨大的改进。 - manasij7479
    1
    虽然我目前正在做非常类似的事情(https://bitbucket.org/manasij7479/gl/),但我还没有遇到需要自己的分配器的情况。因此,除非他比我更先进,否则你的建议是有一定价值的。 - manasij7479
    您可以通过池化和其他技术创建有针对性的改进,而无需编写自己的malloc()/free(),例如通过STL分配器(正如您在自己的答案中建议的那样),但根据我的理解,这与实现自己的“内存管理系统”是不同的。 - crowder
    这只是一个抽象层次。在你所说的较低层次中,据我所知,同样的技术被广泛使用。对吧? - manasij7479
    1
    并不是所有的应用程序都适用于特定的池化技术,这些技术对于通用的、多线程的分配器库来说并没有意义。它们肯定不会产生通用的效率——一个特定的应用程序可能会因为使用特定的分配器库和特定的调整/性能偏差而在内存局部性、碎片化等方面获得巨大的好处,但另一个应用程序可能会因为使用同样的库而受到严重影响。不同的应用领域需要在其分配器中进行不同的权衡。通用的分配器库力求在各个方面都能够提供良好的性能。 - crowder
    显示剩余2条评论

    2
    1. 准备多个解决方案,让框架的用户采用任何一个特定的解决方案。针对您开发的通用分配器的策略类可以很好地完成此任务。

    2. 一种不错的方法是将指针封装在一个带有重载 * 运算符的类中。该类的内部数据仅为内存池的索引。现在,您可以在后台线程复制数据后快速更改索引。

    3. 大多数 好的 C++ 库都支持分配器,您应该实现其中之一。您还可以重载全局 new,以便使用您的版本。请记住,您通常不需要考虑库分配或释放大量数据的问题,这通常是客户端代码的职责。


    谢谢,这些都是我所有问题的简短而精彩的答案。正如你所建议的,我将使整个管理部分成为可选项,并尝试探索不同的解决方案以供选择。关于重载*运算符的好建议,我会进一步研究。 - andsve

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接