为什么内存分配器不主动 return 释放内存给 OS?

Why don't memory allocators actively return freed memory to the OS?

是的,这可能是您第三次看到这段代码,因为我问了另外两个关于它的问题 ( and ).. 代码相当简单:

#include <vector>
int main() {
    std::vector<int> v;
}

然后我在 Linux 上使用 Valgrind 构建并 运行 它:

g++ test.cc && valgrind ./a.out
==8511== Memcheck, a memory error detector
...
==8511== HEAP SUMMARY:
==8511==     in use at exit: 72,704 bytes in 1 blocks
==8511==   total heap usage: 1 allocs, 0 frees, 72,704 bytes allocated
==8511==
==8511== LEAK SUMMARY:
==8511==    definitely lost: 0 bytes in 0 blocks
==8511==    indirectly lost: 0 bytes in 0 blocks
==8511==      possibly lost: 0 bytes in 0 blocks
==8511==    still reachable: 72,704 bytes in 1 blocks
==8511==         suppressed: 0 bytes in 0 blocks
...
==8511== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

在这里,Valgrind 报告没有内存泄漏,即使有 1 个分配和 0 个空闲。

答案 指出 C++ 标准库使用的分配器不一定 return 内存返回 OS - 它可能将它们保存在内部缓存中。

问题是:

1) 为什么要将它们保存在内部缓存中?如果是为了速度,如何更快?是的,OS 需要维护一个数据结构来跟踪内存分配,但是这个缓存的维护者也需要这样做。

2) 这是如何实现的?因为我的程序 a.out 已经终止,所以没有其他进程在维护这个内存缓存——或者,有吗?

编辑:对于问题 (2) - 我看到的一些答案建议 "C++ runtime",这是什么意思?如果 "C++ runtime" 是 C++ 库,但库只是磁盘上的一堆机器代码,它不是 运行ning 进程 - 机器代码要么链接到我的 a.out(静态库,.a)或在 a.out.

过程中 运行time(共享对象,.so)被调用

澄清

首先,澄清一下。您问:...我的程序 a.out 已经终止,没有其他进程维护此内存缓存 - 或者,是否有一个?

我们所说的一切都在单个进程的生命周期内:进程总是return退出时所有分配的内存。没有比进程 1 还长的缓存。即使没有运行时分配器的任何帮助,内存也会被 returned:进程终止时 OS 只是 "takes it back"。因此,正常分配的已终止应用程序不会 system-wide 泄漏。

现在 Valgrind 报告的是正在使用的内存在进程 终止的那一刻,但在OS 清理所有内容之前。它在 运行时库 级别工作,而不是在 OS 级别工作。所以它说 "Hey, when the program finished, there were 72,000 bytes that hadn't been returned to the runtime" 但未说明的含义是 "these allocations will be cleaned up shortly by the OS".

潜在问题

显示的代码和 Valgrind 输出与标题问题并没有很好的关联,所以让我们将它们分开。首先,我们将尝试回答您提出的有关分配器的问题:为什么它们存在以及为什么它们通常不立即 return 释放内存到 OS,忽略示例。

您问的是:

1) Why keep them in an internal cache? If it is for speed, how is it faster? Yes, the OS needs to maintain a data structure to keep track of memory allocation, but this the maintainer of this cache also needs to do so.

这是两个问题合而为一的问题:一个是为什么要麻烦拥有一个用户态运行时分配器,另一个是(也许?)为什么这些分配器不立即 return 内存到OS 当它被释放时。它们是相关的,但让我们一次解决它们。

为什么存在运行时分配器

为什么不只依赖 OS 内存分配例程?

  • 许多操作系统,包括大多数 Linux 和其他 Unix-like 操作系统,根本没有 OS 系统调用来分配和释放任意块的记忆。 Unix-alikes 提供 brk,它只会增加或缩小一个连续的内存块 - 您无法 "free" 任意较早的分配。他们还提供 mmap 允许您独立分配和释放内存块,但这些分配的粒度 PAGE_SIZE,在 Linux 上为 4096 字节。所以如果你想要请求 32 字节,如果你没有自己的分配器,你将不得不浪费 4096 - 32 == 4064 字节。在这些操作系统上,您实际上 需要 一个单独的内存分配运行时,将这些 coarse-grained 工具变成能够有效分配小块的东西。

    Windows有点不同。它有 HeapAlloc 调用,它是 "OS" 的一部分,并且确实提供了类似于 malloc 的分配和释放任意大小的内存块的功能。使用 一些 编译器, malloc 只是作为 HeapAlloc 的薄包装实现(此调用的性能在最近的 Windows 版本中得到了很大改进,使这可行)。尽管如此,虽然 HeapAllocOS 的一部分,但它并未在 内核 中实现 - 它也主要是在 user-mode 库中实现,管理空闲和已用块列表,偶尔调用内核从内核获取内存块。所以它主要是 malloc 的另一种伪装,它所持有的任何内存也不可用于任何其他进程。

  • 性能!即使有适当的 kernel-level 调用来分配任意内存块,到内核的简单开销往返通常也是数百纳秒或更多。另一方面,well-tuned malloc 分配或释放通常只有十几条指令,可能在 10 纳秒或更短时间内完成。最重要的是,系统调用不能 "trust their input" 因此必须仔细验证从 user-space 传递的参数。在 free 的情况下,这意味着它会检查用户传递的指针是否有效!大多数运行时 free 实现简单地崩溃或悄悄破坏内存,因为没有责任保护进程不受自身影响。
  • 更接近 link 语言运行时的其余部分。在 C++ 中用于分配内存的函数,即 newmalloc 和朋友,是语言定义的一部分。然后将它们作为实现语言其余部分的运行时的一部分来实现是完全自然的,而不是 OS 大部分 language-agnostic。例如,语言可能对各种 objects 有特定的对齐要求,最好由语言感知分配器处理。对语言或编译器的更改也可能意味着对分配例程的必要更改,希望更新内核以适应您的语言功能将是一个艰难的决定!

为什么不 Return 内存到 OS

你的例子没有显示它,但你问了,如果你写了一个不同的测试,你可能会发现在分配然后释放一堆之后内存,您的进程驻留设置大小 and/or 所报告的虚拟大小 OS 可能不会在释放后减少。也就是说,即使您已释放内存,该进程似乎仍保留着内存。事实上,许多 malloc 实现都是如此。首先,请注意,这本身并不是 泄漏 - 未 return 分配的内存仍然可供分配它的进程使用,即使其他进程不可用。

他们为什么要这样做?以下是一些原因:

  1. 内核API让它变难了。对于 old-school brksbrk system calls,return 释放内存是不可行的,除非它恰好在最后一个块的末尾从 brksbrk 分配。那是因为这些调用提供的抽象是一个单一的大型连续区域,您只能从一端扩展。你不能从中间交还记忆。与其尝试支持所有释放的内存恰好位于 brk 区域末尾的异常情况,大多数分配器甚至都不会打扰。

    mmap 调用更灵活(此讨论通常也适用于 Windows,其中 VirtualAllocmmap 等价物),允许您至少 return 页面粒度的内存 - 但即使那样也很难!在作为该页面一部分的 所有 分配被释放之前,您不能 return 页面。取决于大小和 allocation/free 可能常见或不常见的应用程序的模式。它适用于大型分配的情况 - 大于页面。在这里,如果通过 mmap 完成分配,你保证能够释放大部分分配,实际上一些现代分配器直接从 mmap 满足大量分配并将它们释放回 OS munmap。对于 glibc(以及 C++ 分配运算符的扩展),您甚至可以控制 this threshold:

    M_MMAP_THRESHOLD
      For allocations greater than or equal to the limit specified
      (in bytes) by M_MMAP_THRESHOLD that can't be satisfied from
      the free list, the memory-allocation functions employ mmap(2)
      instead of increasing the program break using sbrk(2).
    
      Allocating memory using mmap(2) has the significant advantage
      that the allocated memory blocks can always be independently
      released back to the system.  (By contrast, the heap can be
      trimmed only if memory is freed at the top end.)  On the other
      hand, there are some disadvantages to the use of mmap(2):
      deallocated space is not placed on the free list for reuse by
      later allocations; memory may be wasted because mmap(2)
      allocations must be page-aligned; and the kernel must perform
      the expensive task of zeroing out memory allocated via
      mmap(2).  Balancing these factors leads to a default setting
      of 128*1024 for the M_MMAP_THRESHOLD parameter.
    

    因此,默认情况下,运行时将直接从 OS 分配 128K 或更多内存,并在空闲时释放回 OS。所以有时你会看到你可能期望的行为总是如此。

  2. 性能!如上面的其他列表所述,每个内核调用都是昂贵的。不久之后将需要由一个进程释放的内存来满足另一个分配。与其尝试将其 return 添加到 OS,这是一个相对重量级的操作,为什么不将其保留在 空闲列表 上以满足未来的分配?正如手册页条目中指出的那样,这也避免了将内核 return 编辑的所有内存清零的开销。它还提供了良好缓存行为的最佳机会,因为进程不断 re-using 地址 space 的同一区域。最后,它避免了 munmap 强加的 TLB 刷新(并且可能通过 brk 收缩)。
  3. 不 returning 内存的 "problem" 对于 long-lived 进程来说是最糟糕的,这些进程在某个时候分配了一堆内存,释放它,然后再也不会分配那么多。即,其分配 high-water 标记 大于其长期典型分配量的进程。然而,大多数流程并不遵循这种模式。进程通常会释放大量内存,但会以一定速率进行分配,以使它们的整体内存使用量保持不变或可能增加。确实具有 "big then small" 实时大小模式的应用程序可能 force the issue with malloc_trim
  4. 虚拟内存有助于缓解此问题。到目前为止,我一直在使用 "allocated memory" 之类的术语,而没有真正定义它的含义。如果一个程序分配然后释放 2 GB 的 内存 然后坐在那里什么也不做,它是否浪费了插入主板某处的 2 GB 实际 DRAM?可能不会。当然,它在您的进程中使用了 2 GB 的虚拟地址 space,但是虚拟地址 space 是 per-process,因此不会直接从其他进程中拿走任何东西。如果进程在某个时候实际写入了内存,它将被分配物理内存(是的,DRAM)——在释放它之后,你——根据定义——不再使用它。此时 OS 可能会回收这些物理页面供其他人使用。

    现在这仍然需要您交换以吸收脏的 not-used 页,但有些分配器很聪明:它们可以发出 madvise(..., MADV_DONTNEED) 调用,告诉 OS "this range doesn't have anything useful, you don't have to preserve its contents in swap".它仍然保留进程中映射的虚拟地址 space 并在以后可用(零填充),因此它比 munmap 和后续的 mmap 更有效,但它避免了无意义地交换已释放的内存区域交换。2

演示代码

正如 this answer your test with vector<int> isn't really testing anything because an empty, unused std::vector<int> v won't even create the vector object 中所指出的,只要您使用了一些最低级别的优化。即使没有优化,也可能不会发生分配,因为大多数 vector 实现在第一次插入时分配,而不是在构造函数中分配。最后,即使您正在使用一些不寻常的编译器或库来执行 allo化,它将用于少数字节,而不是 Valgrind 报告的 ~72,000 字节。

你应该做这样的事情来实际看到矢量分配的影响:

#include <vector>

volatile vector<int> *sink;

int main() {
    std::vector<int> v(12345678);
    sink = &v;
}

这导致 actual allocation and de-allocation。但是,它不会更改 Valgrind 输出,因为向量分配在程序退出之前已正确释放,所以就 Valgrind 而言没有问题。

在高层次上,Valgrind 基本上将事物分为 "definite leaks" 和 "not freed at exit"。前者发生在程序不再引用指向它分配的内存的指针时。它不能释放这样的内存,所以泄露了它。退出时未释放的内存可能是 "leak" - 即本应释放的 objects,但它也可能只是开发人员知道会在程序长度内存在的内存,并且所以不需要显式释放(因为 order-of-destruction 全局变量的问题,特别是当涉及共享库时,可能很难可靠地释放与全局或静态相关的内存 objects 即使你想要)。


1 在某些情况下,一些故意的 特殊 分配可能会比进程长寿,例如共享内存和内存映射文件,但这不会' 与纯 C++ 分配相关,出于本次讨论的目的,您可以忽略它。

2 最近的 Linux 内核也有 Linux-specific MADV_FREE 似乎与 MADV_DONTNEED 有相似的语义。