【问题标题】:Why don't memory allocators actively return freed memory to the OS?为什么内存分配器不主动将释放的内存返回给操作系统?
【发布时间】:2018-01-14 07:01:12
【问题描述】:

是的,这可能是您第三次看到此代码,因为我问了另外两个问题(thisthis)。 代码相当简单:

#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 个空闲。

here 的答案指出 C++ 标准库使用的分配器不一定会将内存返回给操作系统 - 它可能会将它们保存在内部缓存中。

问题是:

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

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

编辑:问题 (2) - 我看到的一些答案建议“C++ 运行时”,这是什么意思?如果“C++ 运行时”是 C++ 库,但该库只是磁盘上的一堆机器码,它不是一个正在运行的进程——机器码要么链接到我的a.out(静态库,.a ) 或在a.out 的过程中在运行时调用(共享对象,.so)。

【问题讨论】:

  • 我想您想问为什么分配器不将内存返回给操作系统,但您的示例没有这样做。 Valgrind 在标准库/运行时级别工作,因此它会报告您尚未返回运行时的内容,正如我在下面的回答中所述,您甚至没有以您的方式对其进行测试想要因为未使用的向量。一个更好的测试是做一个大的分配(你可以使用vector或只是new char[1234567])并观察操作系统报告的内存使用量增加了类似的量,然后释放它观察它可能不会下降(或可能,这取决于!)。

标签: c++ memory-management libstdc++


【解决方案1】:

澄清

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

我们所说的一切都在单个进程的生命周期内:进程总是在退出时返回所有分配的内存。没有超过进程1 的缓存。即使没有运行时分配器的任何帮助,内存也会返回:当进程终止时,操作系统只会“取回”它。因此,正常分配的终止应用程序不会导致系统范围的泄漏。

现在 Valgrind 报告的是正在使用的内存进程终止时,但在操作系统清理所有内容之前。它在运行时库级别工作,而不是在操作系统级别。所以它的意思是“嘿,当程序完成时,有 72,000 个字节尚未返回到运行时”,但未说明的含义是“这些分配将很快被操作系统清理”。

基本问题

显示的代码和 Valgrind 输出与名义问题的相关性并不高,所以让我们将它们分开。首先,我们将尝试回答您提出的有关分配器的问题:它们为什么存在以及为什么它们通常不会立即将释放的内存返回给操作系统,忽略示例。

你问:

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

这是一个合二为一的问题:一个是为什么要使用用户态运行时分配器,另一个是(也许?)为什么这些分配器在释放内存时不立即将内存返回给操作系统.它们是相关的,但让我们一次解决一个。

为什么存在运行时分配器

为什么不只依赖操作系统内存分配例程?

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

    Windows 有点不同。它具有HeapAlloc 调用,它是“操作系统”的一部分,并且确实提供了类似于malloc 的分配和释放任意大小的内存块的功能。然后使用一些 编译器,malloc 只是作为HeapAlloc 的一个瘦包装器来实现(此调用的性能在最近的 Windows 版本中得到了极大的改进,使其成为可能)。尽管如此,虽然HeapAllocOS 的一部分,但它并没有在 kernel 中实现——它也主要在用户模式库中实现,管理一个列表free 和 used 块,偶尔会调用内核从内核获取内存块。所以它主要是 malloc 的另一种伪装,它所持有的任何内存也不能用于任何其他进程。

  • 性能!即使有适当的内核级调用来分配任意内存块,到内核的简单开销往返通常是数百纳秒或更多。另一方面,经过良好调整的malloc 分配或空闲通常只有十几个指令,并且可能在 10 ns 或更短的时间内完成。最重要的是,系统调用不能“信任他们的输入”,因此必须仔细验证从用户空间传递的参数。在free 的情况下,这意味着它会检查用户是否传递了一个有效的指针!大多数运行时free 只是简单地实现崩溃或静默损坏内存,因为没有责任保护进程免受自身的影响。
  • 更紧密地链接到语言运行时的其余部分。在 C++ 中用于分配内存的函数,即newmalloc 和朋友,是语言定义的一部分。然后将它们作为实现语言其余部分的运行时的一部分来实现是完全自然的,而不是在很大程度上与语言无关的操作系统。例如,语言可能对各种对象有特定的对齐要求,最好由语言感知分配器处理。对语言或编译器的更改也可能意味着对分配例程进行必要的更改,并且希望内核更新以适应您的语言功能将是一个艰难的决定!

为什么不将内存归还给操作系统

您的示例没有显示它,但是您询问并且如果您编写了不同的测试,您可能会发现在分配然后释放一堆内存之后,您的进程驻留集大小和/或虚拟大小由报告免费后操作系统可能不会减少。也就是说,即使您已释放内存,该进程似乎仍会保留内存。事实上,许多malloc 实现都是如此。首先,请注意这本身并不是泄漏 - 未返回的内存仍然可供分配它的进程使用,即使其他进程不可用。

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

  1. 内核 API 让它变得很困难。对于老式的brksbrk system calls,返回释放的内存根本不可行,除非它恰好位于从brksbrk 分配的最后一个块的末尾。这是因为这些调用提供的抽象是一个大的连续区域,您只能从一端扩展。你不能从中间交还内存。大多数分配器并没有尝试支持所有释放的内存恰好位于brk 区域末尾的异常情况,而是甚至不打扰。

    mmap 调用更加灵活(并且此讨论通常也适用于 Windows,其中VirtualAllocmmap 等效项),允许您至少以页面粒度返回内存 - 但即使这样也很难!在作为该页面一部分的所有分配被释放之前,您无法返回页面。取决于可能常见或不常见的应用程序的大小和分配/空闲模式。它运作良好的一种情况是用于大型分配 - 大于一页。在这里,如果通过mmap 完成分配,您可以保证能够释放大部分分配,并且确实一些现代分配器直接从mmap 满足大量分配,并使用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.
    

    因此,默认情况下,128K 或更多的分配将由运行时直接从操作系统分配,并免费释放回操作系统。所以有时你会看到你可能期望的行为总是如此。

  2. 性能!如上面另一个列表所述,每个内核调用都很昂贵。稍后将需要由进程释放的内存来满足另一个分配。与其尝试将其返回给操作系统,这是一个相对重量级的操作,为什么不将其保留在 free list 上以满足未来的分配?正如手册页条目中所指出的,这也避免了将内核返回的所有内存归零的开销。它还提供了良好缓存行为的最佳机会,因为该进程不断重复使用地址空间的同一区域。最后,它避免了由munmap 强加的TLB 刷新(也可能通过brk 进行收缩)。
  3. 不返回内存的“问题”对于长期存在的进程来说是最糟糕的,这些进程在某个时刻分配了一堆内存,释放它,然后再也不会分配那么多。即,分配高水位线大于其长期典型分配量的进程。然而,大多数流程并不遵循这种模式。进程通常会释放大量内存,但分配的速度要使其整体内存使用量保持不变或可能会增加。确实具有“先大后小”实时大小模式的应用程序可能会force the issue with malloc_trim
  4. 虚拟内存有助于缓解该问题。到目前为止,我一直在抛出诸如“分配的内存”之类的术语,而没有真正定义它的含义。如果一个程序分配然后释放 2 GB 的 内存 然后无所事事,它是否浪费了 2 GB 的实际 DRAM 插入您的主板某处?可能不是。当然,它在您的进程中使用 2 GB 的虚拟地址空间,但虚拟地址空间是每个进程的,因此不会直接从其他进程中拿走任何东西。如果进程在某个时候确实写入了内存,它将被分配物理内存(是的,DRAM) - 在释放它之后,你 - 根据定义 - 不再使用它。此时,操作系统可能会回收这些物理页面以供其他人使用。

    现在这仍然需要你有交换来吸收脏的未使用的页面,但是一些分配器很聪明:他们可以发出一个madvise(..., MADV_DONTNEED) 调用告诉操作系统“这个范围没有任何有用的东西,你没有”不必在交换中保留其内容”。它仍然保留在进程中映射的虚拟地址空间并在以后可用(填充零),因此它比munmap 和随后的mmap 更有效,但它避免了毫无意义地将释放的内存区域交换为交换。2

演示代码

正如this answer 中指出的那样,您使用vector&lt;int&gt; 的测试并没有真正测试任何东西,因为只要您使用的是空的、未使用的std::vector&lt;int&gt; v,甚至create the vector object 都不会一些最低程度的优化。即使没有优化,也不会发生分配,因为大多数vector 实现在第一次插入时分配,而不是在构造函数中分配。最后,即使您正在使用一些不寻常的编译器或库来进行分配,它也只会占用少量字节,而不是 Valgrind 报告的约 72,000 字节。

你应该做这样的事情来真正看到向量分配的影响:

#include <vector>

volatile vector<int> *sink;

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

结果为@​​987654328@。然而,它不会改变 Valgrind 的输出,因为向量分配在程序退出之前被正确释放,所以就 Valgrind 而言没有问题。

在高层次上,Valgrind 基本上将事物分为“确定的泄漏”和“退出时未释放”。前者发生在程序不再引用指向它分配的内存的指针时。它无法释放这样的内存,因此泄漏了它。退出时未释放的内存可能是“泄漏” - 即应该释放的对象,但它也可能只是开发人员知道会在程序的长度内存在的内存,因此不需要被显式释放(由于全局变量的破坏顺序问题,尤其是在涉及共享库时,即使您愿意,也可能很难可靠地释放与全局或静态对象关联的内存)。


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

2 最近的 Linux 内核也有特定于 Linux 的 MADV_FREE,它似乎与 MADV_DONTNEED 具有相似的语义。

【讨论】:

  • 我意识到这是一篇过时的帖子,但它对我提出的一个问题非常有帮助:stackoverflow.com/questions/63454529/…。有没有一种提取信息的方法,例如linux工具/命令,在已被进程释放但操作系统尚未在该进程运行时从进程中回收的内存上?我想以某种方式检查来自ps 的过程的一部分'rss 值是否可以回收。抱歉,如果是愚蠢的问题,但我想我会在这里问。谢谢。
  • @Francis - 我在那边回答了你的问题。
  • 对许多人都错了的问题的彻底而清晰的回答。
猜你喜欢
  • 1970-01-01
  • 2019-11-02
  • 2015-12-31
  • 2015-08-08
  • 1970-01-01
相关资源
最近更新 更多