【问题标题】:If memory pools are faster than malloc, why doesn't malloc use them under the covers?如果内存池比 malloc 快,为什么 malloc 不在幕后使用它们?
【发布时间】:2020-08-24 12:18:19
【问题描述】:

我一直听说内存池在分配内存时可以显着提高性能。那么为什么传统的 malloc 实现不以某种方式使用它们?

我知道部分原因是内存池使用固定大小的内存块,但似乎有些不使用,他们唯一需要的是提前获取一些额外的内存。有没有一种方法可以将它们充分概括用于此类目的?

【问题讨论】:

  • 自定义内存池通常不会更快。在特定情况下,它们可以更快。 malloc 和类似的东西通常需要很好,并且他们为此使用策略(包括内存池)。
  • 如果您了解应用程序的内存分配模式,您几乎总能比通用内存管理器做得更好。如果你不这样做,你就不能。
  • @hughmanwho 并不是他们占用了更多空间,而是他们占用了可预测的空间。即,如果您为内存池预先分配 5MB,那么您的程序要么在启动时失败(因为无法分配 5MB),要么在程序的整个生命周期内保证有 5MB 可用。与在运行时直接调用malloc() 的程序相比——它可能运行良好 6 周,然后由于内存耗尽而失败,因为其他一些进程占用了几乎所有的 RAM;不是嵌入式程序员喜欢处理的事情
  • 在安全关键程序中,经常使用内存池(还有其他选项),因为行为的可预测性是基本的设计要求,而任意分配/释放内存会带来不可预测性(例如分配时间,或潜在的失败分配)。通常,池仅在启动时分配,如果分配失败,启动将中止,因为在启动后分配内存表示无法满足基本系统要求。
  • @user207421:我不认为这是 close 问题的充分理由,因为这里关于 SO 的大量其他有用问题是基于人们的误解或缺乏的知识。相反,我认为这是一个解释为什么基础可能是谬误的机会。无论如何,正如我在回答中所解释的那样,在某些情况下,内存池 更快,因此这不是谬误,只需将其放在上下文中即可。

标签: c++ c malloc memory-pool


【解决方案1】:

内存池可以比通用内存分配更有效,但通常只是因为您有关于分配模式的额外信息。也许它们最重要的特性是它们的运行时间是确定性的,尤其是在实时操作系统中。

例如,我曾经编写过一个嵌入式系统,我知道所需的最大分配是 128 字节(以下称为块)。为此,我维护了一组连续的块,并使用映射来确定一个块是否空闲。

它最初是一个位图,但我们最终通过将每个已使用/未使用的标志存储在一个单独的字节中来获得更高的性能。地图的内存使用量是地图的八倍,但是,由于池大小是已知的并且合理有限(一千左右),这还不算太糟糕。而且它让我们不必费力地进行池管理,从而提高了我们的速度。

我们还添加了其他优化,例如存储第一个空闲块,以便我们可以快速找到它。它易于维护,因为:

  • 释放低于当前最低值的块只会更新最低值;和
  • 分配最低块只会增加最低块 - 虽然这并没有保证它指向一个空闲块,但它仍然会使搜索更快,并避免可能不必要的分配搜索(例如,如果您首先释放的块低于您刚刚分配的块)。

然后,如果您要求超过块大小,它会返回 NULL(这在该系统中从未发生过,但出于偏执,我为它编写了代码以防万一)。如果您要求的东西可以放入一个块中,那么无论如何您都会得到一个完整的块(但是,当然,您仍然应该只使用您要求的内存,以防我以后想更改块大小或从单独的具有不同块大小的池)。

事实证明,这比当时的通用分配器要快很多,因为它们必须处理不同的请求大小并担心在释放内存时合并连续的空闲块等事情。

但它需要额外的知识,事实上没有分配会超过块大小。


另一种模型是为低于特定大小的请求设置一个池,但如果出现以下任一情况,则恢复为一般分配:

  • 您请求的块超出了块大小;或
  • 池当前已用尽。

在大多数情况下,这可以让您获得额外的效率(当然取决于您的分配模式),但允许分配超出此范围。它会在每次分配中引入一些额外的工作,因为您需要评估请求大小和池耗尽,但它仍然可能优于一般情况。

顺便说一句,我记得 Java 字符串中有类似的东西(不确定是否仍然如此,我已经有一段时间没有使用 Java了)。字符串对象分配内部有一个缓冲区用于存储 small 字符串,但也可以使用该空间来存储单独分配的字符块的指针(如果它大于内部缓冲区)。这减少了可能是大量小字符串的碎片(和取消引用),但仍允许在需要时使用更大的字符串。


有趣的是,我曾经在CPython 源代码中尝试过一个实验,以查看内存池是否可以提高性能,特别是考虑到其中进行的内存分配数量。它使用与上面给出的策略类似的策略,优先从池中分配,但如果请求的大小超出块大小或池已用尽,则恢复为原始策略。

再一次,它有优化,然后是一些。例如,最后一个释放的块被缓存了,因此它可以立即分发而无需搜索池,以尝试加速 many-times(single-free-then-allocate) 模式。

然而,即使有各种优化、池和块大小,它似乎对我编写的一些测试代码的性能没有实质性影响,这让我相信 CPython 中使用的实际分配器已经挺好的。


而且,刚刚读完我几周前购买的这本出色的书(a),我现在知道为什么我没有取得任何进展。

事实证明,CPython已经进行了大量优化,包括内存池的使用。 “内存管理”一章更详细,但它基本上只使用普通分配器(原始域)来获取大块(> 256K)或特定的非对象相关内存。

所有对象,Python 几乎是所有个对象 :-),来自对象域(除了一些遗留的东西)。

对于这个域,它维护自己的堆并分配大小以匹配系统页面大小的区域,如果支持减少碎片,则使用mmap。所有使用的 arena 都保存在一个双向链表中,空的 arena 保存在一个单链空闲列表中。

在每个 arena 中,都会创建 4K 个池(因此每个 arena 64 个),一个池只能提供一种大小的分配,当从该池请求第一个分配时锁定。例如,1-16 字节的请求将从服务 16 字节块的池中获得 16 字节的块,33-48 字节的请求将来自服务于 48 字节块的池。

请注意,这是针对块大小为{16, 32, 48, ..., 512} 的 64 位系统。 32 位系统的块大小设置略有不同,{8, 16, 24, 32, ..., 512}

对于竞技场内的游泳池,它们是:

  • 部分使用,在这种情况下,根据其块大小,它们位于竞技场中的双向链表中。 arena 维护池的空闲列表,每个块大小一个。
  • 未使用,在这种情况下,它们位于一个空闲的池列表中,能够为任何大小的请求提供服务(不过,一旦锁定,这就是它们被限制的块大小)。李>
  • 已满,在这种情况下,它们将无法访问,除非取消分配。

请记住,这三个状态之间的转换都会导致池从一个列表移动到另一个列表。

我不会详细说明,因为你的脑袋可能会爆炸,就像我的一样 :-)

简而言之,CPython 对象分配总是针对一个特定块大小,最小的一个大于或等于您需要的大小。这些来自提供单个块大小的池(一旦锁定)。这些池存在于为防止碎片化而进行了高度优化的领域中。并且可以根据需要创建竞技场。

我只想说,这就是我的小实验没有改进 CPython 的原因:它已经以一种相当复杂但高效的方式进行内存池,而我试图拦截 malloc 的尝试完全没有效果。

我的开场白可以更有效率“但通常只是因为你有关于分配模式的额外信息”得到了那本书的评论的支持:

大多数内存分配请求都很小且大小固定。因为PyObject 是 16 字节,PyASCIIObject 是 42 字节,PyCompactUnicodeObject 是 72 字节,PyLongObject 是 32 字节。


(a)CPython Internals 如果你有兴趣,除了我喜欢关于工作原理的优秀技术书籍之外,我没有任何隶属关系。

【讨论】:

  • 哈,美好的回忆... '字符串对象分配内部有一个缓冲区用于存储小字符串,但也可以使用该空间来存储单独分配的字符块的指针' 我自己也在 1997 年发明了它:D 需要它来解析文档(电子表格定义,其中包含对几个函数的数千个引用)。
【解决方案2】:

像往常一样,一切都取决于
在这种情况下,它主要取决于您所说的性能

正如其他人已经说过的,内存池通常比标准的mallocfree 实现更快,但速度并不是在一般情况下必须考虑的唯一因素。一般分配器不应该提前分配太多数据,直到必要(池通常这样做),它应该分配任意大小的块(池通常不这样做)。

内存池在分配大量小块时更有效,尤其是相同大小的块,因为它们可以作为数组分配,所以一堆块有一个共同的头而不是每个块的单独头。
另一方面,通常不会全部用完,这可能会被视为内存效率的损失。

池在malloc/new 中可以更快,因为它们几乎可以立即从预先分配的合适大小的块数组中为您提供一个数据块,而不是搜索要从中切片的合适块。但是,您不能为所有可能的大小都设置一个池,因此通常您获得的块比需要的长一点,这又是一些内存损失。

它们在free/delete 中也可以更快,因为它们只是将池中的一个块标记为已释放,而不需要寻找相邻块来查看它们是否也空闲,并且'将新释放的块粘到他们身上。

【讨论】:

    【解决方案3】:

    我已经编写了具有多种方法和权衡的内存池。我相信malloc() 不会在幕后使用它们(如果这是真的),因为:

    1. 内存池浪费了内存,因为它们可能(他们经常这样做)使用离散(固定)块大小来提高速度。
      1. 例如:如果您要求12 字节,您可能会偷偷得到64 字节(假设这是最接近的块大小 >= 12 字节,并带有适当的对齐填充),取决于内存池的实现。不过,也许malloc() 会给您16 字节,这是最接近的对齐要求,因此浪费的字节更少。
      2. 请注意,内存池将块分配到最近的对齐块大小(意味着它必须是 A)对齐 B)您允许的有效块大小之一要分配)>= n 请求字节,而 malloc() 只是分配到最近的对齐要求(通常是 alignas(max_align_t),通常是 8 或 16 字节对齐,取决于架构)> = n 请求的字节数。
    2. 内存池浪费了内存,因为它们可能(取决于实现)具有大型映射数组或哈希表(这里有多种方法和权衡)要从 n bytes 映射到您想要分配到您可以从中提取的空闲列表链表(对于下一个块大小 >= n 字节)。

    换句话说,就像大多数事情一样,需要权衡取舍。我怀疑 malloc() 选择变慢和不确定是为了:

    1. 充分利用您的 RAM,而不是通过给您一个 64 字节(例如)来浪费它,每次请求 12 字节时阻塞,
    2. 并且没有大量(大小为数十千字节)的映射数组,或者您可以分配的最大块大小的奇怪最大大小限制,内存池可能会强制执行。

    内存池经常在速度、RAM 使用率、块大小和最大块数方面进行自定义,以满足您的特定要求和手头的应用程序。另一方面,malloc() 对于给定大小的 RAM,对于 所有可能的字节数,必须是通用通用 .它有很多不同的约束和要求。

    说了这么多,我正在考虑编写一些名为fast_malloc()fast_free() 的通用替代品。他们要么通过使用巨大的映射数组将n字节映射到块大小来分配和释放时间复杂度O(1),要么我可以选择一个使用较少程序空间的选项并且/ 或 RAM,但使用二进制搜索从 n 字节映射到块大小,因此具有 O(log m) 时间复杂度,其中 m 是可能的块数您可以分配的尺寸。如果需要,我什至可以让它在后台使用malloc() 在内存池用完时在运行时扩展内存池——但这不应该在微控制器上或实时、安全关键的情况下完成,确定性应用程序,在这种情况下,我将禁用该功能,并且仅在编译时静态分配,或在运行时初始化时分配一次。

    速度说明:

    1. 一个 O(log m) 时间复杂度(分配时为 O(log m),但免费时为 O(1) ) algorithm tunable-block-size memory pool 我写的执行速度是系统malloc() 的 1x~3x(即:我的实现花费了 ~33% 到 ~100% 的时间),具体取决于允许的块大小和分配的字节数。有关详细信息,请参阅此答案下方的评论。
    2. 为了获得最大速度,可以在 O(1) 时间内写入一个大的 1:1 映射数组以从 n 字节(调用 fast_malloc(n) 时)直接映射到 index到包含 {block_sizeptr_to_free_list} 结构的映射数组中。这个 1:1 O(N_MAX) 大小的映射数组用于映射到另一个 O(m) 大小的映射数组会执行得更快,但代价是程序空间/闪存中更多的内存使用量,可能还有 RAM,具体取决于您运行的硬件:微控制器与 PC。

    无论如何,确实可以编写一个fast_malloc() 实现,它确实在后台使用内存池,并且具有 O(1) 分配和释放时间复杂度, 如果块大小太大而无法在 O(1) 时间内分配(即:分配n 字节,其中n > N_MAX),则它会恢复为常规malloc(),在这种情况下它'd just pass the call to regular malloc().

    补充阅读:

    1. *****对学习一些内存池分配策略的基础知识很有帮助:http://dmitrysoshnikov.com/compilers/writing-a-pool-allocator/
    2. 另请参阅以下 Google 搜索:
      1. *****memory pool
      2. tunable memory pool
      3. tunable block size memory pool

    【讨论】:

    • as system malloc() 你使用/比较的是什么系统(windows、glibc、musl)?
    • @KamilCuk,我使用的是 64 位 Linux Ubuntu 20.04。我的构建选项是-Wall -Wextra -Werror -O3 -std=c11ldd --version 显示 ldd (Ubuntu GLIBC 2.31-0ubuntu9.2) 2.31,所以看起来我使用的是 glibc 2.31。 Source where I learned the ldd --version cmd.。我还运行了Profile-Guided Optimization (PGO),但并没有看到任何真正的改进。当然,使用-O3 比使用-O0 非常重要。
    猜你喜欢
    • 2011-03-30
    • 1970-01-01
    • 1970-01-01
    • 2016-04-03
    • 2015-12-24
    • 2011-03-31
    • 2021-12-15
    • 1970-01-01
    相关资源
    最近更新 更多