Munmap() с ENOMEM с закрытым анонимным сопоставлением

Недавно я обнаружил, что Linux не гарантирует, что память, выделенная с помощью mmap, может быть освобождена с помощью munmap, если это приведет к ситуации, когда количество областей VMA (область виртуальной памяти) превышает vm.max_map_count. Manpage ясно указывает на это (почти):

 ENOMEM The process 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 никогда не сбой, если он используется с теми же значениями для адреса и размера, которые были использованы для создания сопоставления. По-видимому, это не относится к Linux, и я не смог найти информацию о подобном поведении в других системах.

В то же время, по моему мнению, частичная разметка, примененная к середине отображаемой области, как ожидается, не сработает на любой ОС для каждой разумной реализации, но я не нашел никакой документации, в которой говорится, что такой отказ возможен.

Я обычно рассматривал бы эту ошибку в ядре, но, зная, как Linux справляется с overcommit памяти и OOM, я почти уверен, что это "функция", которая существует для повышения производительности и снижения потребления памяти.

Другая информация, которую я смог найти:

  • Подобные API-интерфейсы в Windows не имеют этой "функции" из-за их дизайна (см. MapViewOfFile, UnmapViewOfFile, VirtualAlloc, VirtualFree)), они просто не поддерживают частичное развязывание.
  • glibc malloc реализация не создает больше, чем 65535 сопоставлений, отбрасывая до sbrk, когда достигается этот предел: https://code.woboq.org/userspace/glibc/malloc/malloc.c.html. Это похоже на обходной путь для этой проблемы, но все же можно сделать free тихое утечку памяти.
  • У jemalloc возникли проблемы с этим и попытался избежать использования mmap/munmap из-за этой проблемы (я не знаю, как это закончилось для них).

Другие ОС действительно гарантируют освобождение памяти? Я знаю, что Windows делает это, но как насчет других Unix-подобных операционных систем? FreeBSD? QNX?


EDIT: Я добавляю пример, который показывает, как 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 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]);
    }
}

Ответ 1

Один из способов обойти эту проблему в Linux - это mmap больше, чем 1 страница за один раз (например, 1 Мб за раз), а также отобразить после нее разделительную страницу. Итак, вы на самом деле вызываете mmap на 257 страницах памяти, затем переназначаете последнюю страницу с помощью PROT_NONE, чтобы к ней не удалось получить доступ. Это должно победить оптимизацию слияния VMA в ядре. Поскольку вы выделяете сразу несколько страниц, вы не должны превышать максимальный предел отображения. Недостатком является то, что вам нужно вручную управлять тем, как вы хотите нарезать большой mmap.

Что касается ваших вопросов:

  • Системные вызовы могут выходить из строя в любой системе по разным причинам. Документация не всегда завершена.

  • Вам разрешено munmap часть области mmap d, если адрес передан в ложь на границе страницы, а аргумент длины округляется до следующего кратного размера страницы.