为什么内存分配器没有主动将释放的内存返回给操作系统?

时间:2017-08-07 03:24:03

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

是的,这可能是您第三次看到此代码,因为我提出了另外两个问题(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个alloc和0 free。

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

问题是:

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

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

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

1 个答案:

答案 0 :(得分:9)

澄清

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

我们谈论的所有内容都在一个进程的生命周期内:进程始终在退出时返回所有已分配的内存。没有缓存超过流程 1 。即使没有运行时分配器的任何帮助,也会返回内存:操作系统只需&#34;将其取回&#34;当进程终止时。因此,正常分配的终止应用程序不会发生系统范围的泄漏。

现在Valgrind报告的是在进程终止时正在使用的内存,但在操作系统清除所有内容之前。它适用于运行时库级别,而不是OS级别。所以它说&#34;嘿,当程序完成时,有72,000个字节没有被返回到运行时&#34;但是没有说明的含义是&#34;操作系统很快就会清理这些分配&#34;。

基础问题

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

你问:

  

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

这是一个两个问题:一个是为什么根本没有使用userland运行时分配器,然后另一个是(可能是?)为什么这些分配器不会立即将内存返回给操作系统被释放了。它们是相关的,但让我们一次解决它们。

为什么运行时分配器存在

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

  • 许多操作系统,包括大多数Linux和其他类Unix操作系统,都没有OS系统调用来分配和释放任意内存块。 Unix-alikes提供brk只会增加或缩小一个连续的内存块 - 你无法自由&#34;任意早期分配。它们还提供mmap,允许您独立分配和释放内存块,但这些内容以PAGE_SIZE粒度分配,在Linux上为4096字节。因此,如果您需要32个字节的请求,如果您没有自己的分配器,则必须浪费4096 - 32 == 4064个字节。在这些操作系统上,您几乎需要一个单独的内存分配运行时,它将这些粗粒度工具转换为能够有效分配小块的东西。

    Windows有点不同。它有HeapAlloc调用,它是&#34; OS&#34;的一部分。并提供malloc - 类似于分配和释放任意大小的内存块的功能。然后,使用一些编译器,malloc只是作为HeapAlloc的一个瘦包装器实现(在最近的Windows版本中,此调用的性能得到了极大的提升,使其成为可行)。尽管如此,虽然HeapAlloc OS 的一部分,但它并未在内核中实现 - 它也主要在用户模式库中实现,管理一个空闲和已用块的列表,偶尔进行内核调用以从内核获取大块内存。所以它主要是malloc在另一个伪装中,它所持有的任何记忆也不能用于任何其他过程。

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

为什么不将内存返回给操作系统

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

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

  1. 内核API使其变得困难。对于旧学校brksbrk system calls,返回释放的内存是不可行的,除非它恰好位于从{{分配的最后一个块的末尾。 1}}或brk。这是因为这些调用提供的抽象是一个大的连续区域,您只能从一端扩展。你无法从中间移回内存。而不是试图支持所有释放的内存恰好位于sbrk区域末端的异常情况,大多数分配器都不会打扰。

    brk调用更灵活(此讨论通常也适用于mmapVirtualAlloc等效的Windows),允许您至少以页面粒度返回内存 - 但即使这很难!您无法返回页面,直到释放属于该页面的所有分配为止。取决于可能常见或不常见的应用程序的大小和分配/自由模式。它运行良好的情况是大型分配 - 大于一页。在这里,如果通过mmap完成,您确保能够释放大部分分配,并且实际上一些现代分配器直接从mmap满足大量分配,并通过{将它们释放回操作系统{1}}。对于mmap(以及扩展的C ++分配运算符),您甚至可以控制this threshold

    munmap

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

  2. 性能!每个内核调用都很昂贵,如上面的其他列表中所述。稍后将需要由进程释放的内存以满足另一个分配。而不是试图将其返回到操作系统,这是一个相对重量级的操作,为什么不将它保留在免费列表以满足未来的分配?正如手册页条目中所指出的,这也避免了将内核返回的所有内存清零的开销。它还提供了良好缓存行为的最佳机会,因为该过程不断重用地址空间的相同区域。最后,它避免了由glibc强加的TLB刷新(并且可能通过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. 收缩)。
  3. &#34;问题&#34;不返回内存对于长期存在的进程来说是最糟糕的,它会在某个时刻分配一堆内存,释放它然后再也不会分配那么多内存。即,分配高水位线的过程大于其长期典型分配量。但是,大多数流程都不遵循这种模式。进程通常释放大量内存,但以一定速率分配,使其总内存使用量不变或可能增加。应用程序确实拥有&#34;大而小#34;实况大小模式也许force the issue with malloc_trim
  4. 虚拟内存有助于缓解此问题。到目前为止,我一直在抛出诸如&#34;分配内存等#34;没有真正定义它意味着什么。如果一个程序分配然后释放2 GB的内存然后无所事事,是否会浪费2 GB的实际DRAM插入你的主板某处?可能不是。当然,它在您的进程中使用2 GB的虚拟地址空间,但虚拟地址空间是按进程进行的,因此不会直接从其他进程中取走任何内容。如果进程实际上在某个时刻写入了内存,它将被分配物理内存(是的,DRAM) - 在释放后,你 - 按照定义 - 不再使用它。此时,操作系统可以通过使用其他人来回收这些物理页面。

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

  5. 演示代码

    正如this answer中指出的那样,munmap的测试并非真正测试任何,因为空的,未使用的mmap赢了只要您使用一些最低级别的优化,即使create the vector object也是如此。即使没有优化,也不会发生分配,因为大多数vector<int>实现在第一次插入时分配,而不是在构造函数中分配。最后,即使您使用的是一些不常见的编译器或库来进行分配,它也会用于少量字节,而不是Valgrind报告的~72,000字节。

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

    std::vector<int> v

    结果为actual allocation and de-allocation。但是,它不会改变Valgrind输出,因为在程序退出之前正确地释放了向量分配,因此就Valgrind而言没有任何问题。

    在较高的层面上,Valgrind基本上将事物分类为“明确的泄漏”。并且&#34;在退出时没有被释放&#34;。前者发生在程序不再引用指向它所分配的内存的指针时。它无法释放这样的记忆,因此泄露了它。在退出时没有被释放的记忆可能是&#34;泄漏&#34; - 即应该被释放的对象,但它也可能只是开发人员知道的内存,而且不需要明确释放(因为销毁顺序问题)全局变量,特别是当涉及共享库时,即使你想要,也可能很难可靠地释放与全局或静态对象相关的内存。

    1 在某些情况下,某些故意的特殊分配可能比该过程更长,例如共享内存和内存映射文件,但这与普通C ++没有关系分配,您可以为了讨论的目的忽略它。

    2 最近的Linux内核也有特定于Linux的vector,它似乎与#include <vector> volatile vector<int> *sink; int main() { std::vector<int> v(12345678); sink = &v; } 具有相似的语义。