munmap()在使用私有匿名映射时发生ENOMEM失败

22

最近我发现Linux不能保证用mmap分配的内存可以用munmap释放,如果这样会导致VMA(虚拟内存区域)结构的数量超过vm.max_map_count。Manpage几乎清楚地说明了这一点:

 ENOMEM The process's maximum number of mappings would have been exceeded.
 This error can also occur for munmap(), when unmapping a region
 in the middle of an existing mapping, since this results in two
 smaller mappings on either side of the region being unmapped.

问题在于Linux内核总是尝试合并VMA结构(如果可能的话),导致即使是单独创建的映射,munmap也无法成功。我能够编写一个小程序来确认这种行为:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#include <sys/mman.h>

// value of vm.max_map_count
#define VM_MAX_MAP_COUNT        (65530)

// number of vma for the empty process linked against libc - /proc/<id>/maps
#define VMA_PREMAPPED           (15)

#define VMA_SIZE                (4096)
#define VMA_COUNT               ((VM_MAX_MAP_COUNT - VMA_PREMAPPED) * 2)

int main(void)
{
    static void *vma[VMA_COUNT];

    for (int i = 0; i < VMA_COUNT; i++) {
        vma[i] = mmap(0, VMA_SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);

        if (vma[i] == MAP_FAILED) {
            printf("mmap() failed at %d\n", i);
            return 1;
        }
    }

    for (int i = 0; i < VMA_COUNT; i += 2) {
        if (munmap(vma[i], VMA_SIZE) != 0) {
            printf("munmap() failed at %d (%p): %m\n", i, vma[i]);
        }
    }
}

该程序使用mmap函数分配大量页面(两倍于默认允许的最大值),然后将每个第二个页面通过munmap函数卸载,以为每个剩余页面创建单独的VMA结构。在我的机器上,最后一个munmap调用总是失败并返回错误码ENOMEM

起初我认为,如果使用与创建映射时相同的地址和大小值来使用munmap函数,则munmap永远不会失败。但很明显,在Linux上情况并非如此,而且我无法找到有关其他系统是否存在类似行为的信息。

同时,在我的看法中,针对映射区域中间的部分进行取消映射操作在任何操作系统上都应该失败,这在所有正常实现中都是预期的,但我没有找到任何说明这种失败是可能的文档。

通常情况下,我会认为这是内核中的漏洞,但是考虑到Linux如何处理过度承诺内存和OOM,我几乎可以确定这是一种“功能”,旨在提高性能并降低内存消耗。

我找到的其他信息如下:

  • 由于其设计,Windows上的类似API不具有此“功能”(例如MapViewOfFileUnmapViewOfFileVirtualAllocVirtualFree) - 它们根本不支持部分取消映射。
  • glibc的malloc实现不会创建超过65535个映射,当达到此限制时则退回到sbrk:https://code.woboq.org/userspace/glibc/malloc/malloc.c.html。这似乎是对这个问题的一种解决方法,但仍然可能使free函数默默地泄漏内存。
  • jemalloc存在此问题,并试图避免使用mmap/munmap,但我不知道他们最终是如何处理的。

其他操作系统是否真的保证内存映射的释放?我知道Windows会这样做,但其他类Unix操作系统呢?FreeBSD? QNX?


编辑:我添加了一个示例,展示了glibc的free函数在内部的munmap调用失败时如何泄漏内存并返回错误码ENOMEM。使用strace查看munmap函数的失败情况:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

#include <sys/mman.h>

// value of vm.max_map_count
#define VM_MAX_MAP_COUNT        (65530)

#define VMA_MMAP_SIZE           (4096)
#define VMA_MMAP_COUNT          (VM_MAX_MAP_COUNT)

// glibc's malloc default mmap_threshold is 128 KiB
#define VMA_MALLOC_SIZE         (128 * 1024)
#define VMA_MALLOC_COUNT        (VM_MAX_MAP_COUNT)

int main(void)
{
    static void *mmap_vma[VMA_MMAP_COUNT];

    for (int i = 0; i < VMA_MMAP_COUNT; i++) {
        mmap_vma[i] = mmap(0, VMA_MMAP_SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);

        if (mmap_vma[i] == MAP_FAILED) {
            printf("mmap() failed at %d\n", i);
            return 1;
        }
    }

    for (int i = 0; i < VMA_MMAP_COUNT; i += 2) {
        if (munmap(mmap_vma[i], VMA_MMAP_SIZE) != 0) {
            printf("munmap() failed at %d (%p): %m\n", i, mmap_vma[i]);
            return 1;
        }
    }

    static void *malloc_vma[VMA_MALLOC_COUNT];

    for (int i = 0; i < VMA_MALLOC_COUNT; i++) {
        malloc_vma[i] = malloc(VMA_MALLOC_SIZE);

        if (malloc_vma[i] == NULL) {
            printf("malloc() failed at %d\n", i);
            return 1;
        }
    }

    for (int i = 0; i < VMA_MALLOC_COUNT; i += 2) {
        free(malloc_vma[i]);
    }
}

是的,这正是正在发生的事情。我在第二段提到了这种优化。问题在于代码不知道哪些VMAs已经合并。想象一下,在同一个进程中工作的两个库。每个库都分配了一块内存,这两个块被合并成一个单独的VMA。唯一可靠地释放此内存的方法是使用单个munmap()调用删除整个VMA。显然,对于两个独立的库来说,这是不可能的,因为它们彼此不知道。我希望内核保证为每个mmap()调用分配单独的VMA,以避免这种失败。 - user1143634
问题在于这只是内核进行的一种乐观优化,使得用户代码在某些情况下泄漏内存。我几乎可以确定的是,假设是99.99%的程序永远不会达到这个限制,即使有些程序确实达到了这个限制,用户也可以手动增加vm.max_map_count以防止将来出现此问题。这就是为什么我也称之为“特性”的原因。它可能并不那么糟糕,但从计算机科学的角度来看,这是一个错误。问题在于至少Glibc和jemalloc试图解决这个问题,所以发生这种情况的概率并不低。 - user1143634
内核有可能避免这种情况。正如我在帖子中提到的,至少Windows不会进行这样的优化,因此您总是可以在那里释放内存。 - user1143634
好的,我现在明白你的观点了。我将不得不查看malloc代码,但是如果它仅在从unmap获取ENOMEM时泄漏内存,则可能存在错误,也许应该跟踪它无法取消映射的段,并在其他映射和取消映射减少碎片化后稍后重试。但请记住,在Linux中只有成功地映射段并不意味着您可以使用所有页面。对此有很多不同的观点,但我认为更大的问题是您可以成功地映射一个段,然后在尝试使用它时获得OOM。 - JimD.
是的,这种优化看起来就像OOM killer的延续。如果您不喜欢Linux中默认的OOM行为,您必须使用vm.overcommit_memory、oom_score_adj等进行调整,否则您的程序可能会被杀死。同样的事情也发生在这里——您必须调整vm.max_map_count,否则您的程序可能会失败。默认的Linux配置非常面向桌面,具有“任何东西随时都可以死掉,这没关系”的态度,但我怀疑大多数服务器都运行相同的配置。因此,人们正在尝试解决这些问题(Glibc和jemalloc就是其中的例子)。 - user1143634
显示剩余9条评论
1个回答

4
在Linux系统中解决这个问题的一种方法是一次性使用mmap映射多于1页的内存(例如每次1 MB),然后在它之后映射一个分隔页面。因此,您实际上要对257页内存调用mmap,然后使用PROT_NONE重新映射最后一页,以便无法访问它。这应该能够打败内核中的VMA合并优化。由于您一次分配了多个页面,因此不应遇到最大映射限制。缺点是您必须手动管理如何切片大型mmap

至于您的问题:

  1. 系统调用在任何系统上都可能出现各种各样的失败原因。文档并不总是完整的。

  2. 只要传入的地址位于页面边界上,并且长度参数向上取整到页面大小的下一个倍数,则可以从mmap区域中的部分区域中取消映射。


仍有可能两个线程映射了将成为同一VMA结构的内存区域,在这种情况下,跟随的mprotect和munmap调用都将失败。至于系统调用的失败:对于正常工作的程序,某些事情永远不应该允许失败,因为这样的失败可能是无法处理的。 - user1143634
@Ivan:你的程序可能因为意外原因而失败。最明显的是外部信号,比如 kill -9。不太明显的原因可能是磁盘已满,或者是由于 Linux 的过度分配内存策略造成的故障。这些实际上并不被称为 bug,只是系统的局限性。我不理解你的线程问题。我的解决方案是通过让软件(而不是操作系统)管理单个页面,并要求 mmap 一次返回多个页面来减少映射页面的数量。 - jxh
显式终止的进程是一个独立的事物 - 这些并不是不可预测的。内存超额提交和VMA合并在某些情况下可以被称为错误,在其他情况下可以称之为“功能”。这就是为什么并非所有内核都进行此类优化:Windows NT从不合并VMAs。至于分割VMAs-在多线程环境中仍然存在竞争,可能会触发mprotect失败,随之而来的是munmap失败,进程仍然必须处理VMA计数限制。我可以理解他们为什么这样做,并且我理解这很少成为问题,特别是如果它也提高了内核性能。 - user1143634
@Ivan:再次强调,答案的重点是建议对单个页面进行进程管理,并使用少量的页面集mmap调用。因此,操作系统只看到进程具有与进程mmap调用匹配的低VMA计数。 - jxh
Linux允许您通过sysctl将映射计数限制提高到2^31,这意味着使用4KiB页面的8TiB或RAM,munmap不存在ENOMEM失败的可能性。看起来这是一个更简单的解决方案。 - Petr Skocik
@PSkocik:很好,vm.max_map_count,但如果有一个可以设置每个进程限制的更好的方法就更好了... - jxh

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