Почему этот пожиратель памяти действительно не ест память?

150

Я хочу создать программу, которая будет имитировать ситуацию нехватки памяти (OOM) на сервере Unix. Я создал этот супер-простой едок памяти:

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

unsigned long long memory_to_eat = 1024 * 50000;
size_t eaten_memory = 0;
void *memory = NULL;

int eat_kilobyte()
{
    memory = realloc(memory, (eaten_memory * 1024) + 1024);
    if (memory == NULL)
    {
        // realloc failed here - we probably can't allocate more memory for whatever reason
        return 1;
    }
    else
    {
        eaten_memory++;
        return 0;
    }
}

int main(int argc, char **argv)
{
    printf("I will try to eat %i kb of ram\n", memory_to_eat);
    int megabyte = 0;
    while (memory_to_eat > 0)
    {
        memory_to_eat--;
        if (eat_kilobyte())
        {
            printf("Failed to allocate more memory! Stucked at %i kb :(\n", eaten_memory);
            return 200;
        }
        if (megabyte++ >= 1024)
        {
            printf("Eaten 1 MB of ram\n");
            megabyte = 0;
        }
    }
    printf("Successfully eaten requested memory!\n");
    free(memory);
    return 0;
}

Он потребляет столько памяти, сколько определено, в memory_to_eatкотором сейчас находится ровно 50 ГБ ОЗУ. Он распределяет память на 1 МБ и печатает именно ту точку, где не удается выделить больше, так что я знаю, какое максимальное значение ему удалось съесть.

Проблема в том, что это работает. Даже в системе с 1 ГБ физической памяти.

Когда я проверяю top, я вижу, что процесс съедает 50 ГБ виртуальной памяти и только менее 1 МБ резидентной памяти. Есть ли способ создать пожиратель памяти, который действительно потребляет его?

Спецификации системы: ядро ​​Linux 3.16 ( Debian ), скорее всего, с включенной функцией overcommit (не знаю, как это проверить), без подкачки и виртуализации.

Petr
источник
16
может быть, вам действительно нужно использовать эту память (т.е. записать в нее)?
мс
4
Я не думаю, что компилятор оптимизирует его, если бы это было правдой, он не выделил бы 50 ГБ виртуальной памяти.
Петр
18
@Magisch Я не думаю, что это компилятор, но ОС, как копирование при записи.
Каданилюк
4
Вы правы, я попытался написать в него, и я просто взорвал свою виртуальную коробку ...
Петр
4
Исходная программа будет вести себя так, как вы ожидаете, если вы используете ее sysctl -w vm.overcommit_memory=2как root; см. mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Обратите внимание, что это может иметь другие последствия; в частности, очень большие программы (например, ваш веб-браузер) могут не запускать вспомогательные программы (например, программа чтения PDF).
Звол

Ответы:

221

Когда ваша malloc()реализация запрашивает память у системного ядра (через sbrk()или mmap()системный вызов), ядро ​​только отмечает, что вы запросили память и где она должна быть размещена в вашем адресном пространстве. На самом деле он еще не отображает эти страницы .

Когда процесс впоследствии обращается к памяти в новом регионе, оборудование распознает ошибку сегментации и предупреждает ядро ​​об этом состоянии. Затем ядро ​​просматривает страницу в своих собственных структурах данных и обнаруживает, что у вас должна быть нулевая страница, поэтому она отображается на нулевой странице (возможно, сначала удаляя страницу из кеша страниц) и возвращается из прерывания. Ваш процесс не осознает, что все это произошло, работа ядра совершенно прозрачна (за исключением небольшой задержки, пока ядро ​​выполняет свою работу).

Эта оптимизация позволяет системному вызову возвращаться очень быстро, и, что наиболее важно, она позволяет избежать выделения каких-либо ресурсов для вашего процесса при сопоставлении. Это позволяет процессам резервировать довольно большие буферы, которые им никогда не нужны в нормальных условиях, не опасаясь сожрать слишком много памяти.


Итак, если вы хотите запрограммировать пожиратель памяти, вам действительно нужно что-то делать с выделенной вам памятью. Для этого вам нужно всего лишь добавить одну строку в ваш код:

int eat_kilobyte()
{
    if (memory == NULL)
        memory = malloc(1024);
    else
        memory = realloc(memory, (eaten_memory * 1024) + 1024);
    if (memory == NULL)
    {
        return 1;
    }
    else
    {
        //Force the kernel to map the containing memory page.
        ((char*)memory)[1024*eaten_memory] = 42;

        eaten_memory++;
        return 0;
    }
}

Обратите внимание, что вполне достаточно написать один байт на каждой странице (который содержит 4096 байт в X86). Это связано с тем, что все выделение памяти от ядра процессу выполняется с гранулярностью страниц памяти, что, в свою очередь, связано с аппаратным обеспечением, которое не допускает разбиение на страницы при меньших гранулярностях.

cmaster - восстановить монику
источник
6
Также можно зафиксировать память с помощью mmapи MAP_POPULATE(хотя обратите внимание, что на странице руководства написано, что « MAP_POPULATE поддерживается только для частных сопоставлений, начиная с Linux 2.6.23 »).
Тоби Спейт
2
Это в основном правильно, но я думаю, что все страницы копируются при записи, сопоставляются с обнуленной страницей, а не вообще не присутствуют в таблицах страниц. Вот почему вы должны писать, а не просто читать, каждую страницу. Кроме того, еще один способ использования физической памяти - блокировка страниц. например, позвоните mlockall(MCL_FUTURE). (Для этого требуется root, поскольку ulimit -lдля учетных записей пользователей при установке Debian / Ubuntu по умолчанию используется только 64 КБ.) Я только что попробовал это в Linux 3.19 с sysctl по умолчанию vm/overcommit_memory = 0, и заблокированные страницы используют swap / физическую RAM.
Питер Кордес
2
@cad Хотя X86-64 поддерживает два больших размера страниц (2 МБ и 1 ГБ), они по-прежнему рассматриваются ядром linux как нечто особенное. Например, они используются только по явному запросу, и только если система была настроена на их разрешение. Кроме того, страница 4 КБ все еще остается гранулярностью, при которой может отображаться память. Вот почему я не думаю, что упоминание огромных страниц добавляет что-либо к ответу.
cmaster - восстановить монику
1
@AlecTeal Да, это так. Вот почему, по крайней мере в Linux, более вероятно, что процесс, который потребляет слишком много памяти, запускается убийцей нехватки памяти, чем тот, который malloc()возвращает один из его вызовов null. Это явно недостаток этого подхода к управлению памятью. Тем не менее, уже наличие отображений копирования при записи (например, динамических библиотек и т. Д. fork()) Делает невозможным для ядра знать, сколько памяти на самом деле потребуется. Таким образом, если бы он не перегружал память, вам бы не хватило картографической памяти задолго до того, как вы фактически использовали всю физическую память.
cmaster - восстановить монику
2
@BillBarth Для аппаратного обеспечения нет разницы между тем, что вы бы назвали ошибкой страницы и сегфоутом. Аппаратное обеспечение видит только тот доступ, который нарушает ограничения доступа, установленные в таблицах страниц, и сигнализирует об этом условии ядру через ошибку сегментации. Только программная сторона решает, следует ли обрабатывать ошибку сегментации путем предоставления страницы (обновление таблиц страниц) или SIGSEGVсигнал должен быть доставлен процессу.
cmaster - восстановить монику
28

Все виртуальные страницы начинаются копированием при записи, сопоставленными с одной и той же обнуленной физической страницей. Чтобы использовать физические страницы, вы можете испачкать их, записав что-то на каждую виртуальную страницу.

Если вы работаете от имени пользователя root, вы можете использовать mlock(2)или mlockall(2)заставить ядро ​​подключать страницы, когда они выделены, без необходимости их пачкать. (обычные пользователи без ulimit -lполномочий root имеют размер только 64 КБ.)

Как и многие другие предположили, кажется, что ядро ​​Linux на самом деле не выделяет память, если вы не пишете в нее

Улучшенная версия кода, которая делает то, что хотел OP:

Это также исправляет несоответствие строки формата printf с типами memory_to_eat и eaten_memory, используемыми %ziдля печатиsize_t целых чисел. Объем используемой памяти в килобайтах необязательно может быть указан в командной строке arg.

Грязный дизайн с использованием глобальных переменных и ростом на 1 Кб вместо 4 Кб страниц не изменился.

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

size_t memory_to_eat = 1024 * 50000;
size_t eaten_memory = 0;
char *memory = NULL;

void write_kilobyte(char *pointer, size_t offset)
{
    int size = 0;
    while (size < 1024)
    {   // writing one byte per page is enough, this is overkill
        pointer[offset + (size_t) size++] = 1;
    }
}

int eat_kilobyte()
{
    if (memory == NULL)
    {
        memory = malloc(1024);
    } else
    {
        memory = realloc(memory, (eaten_memory * 1024) + 1024);
    }
    if (memory == NULL)
    {
        return 1;
    }
    else
    {
        write_kilobyte(memory, eaten_memory * 1024);
        eaten_memory++;
        return 0;
    }
}

int main(int argc, char **argv)
{
    if (argc >= 2)
        memory_to_eat = atoll(argv[1]);

    printf("I will try to eat %zi kb of ram\n", memory_to_eat);
    int megabyte = 0;
    int megabytes = 0;
    while (memory_to_eat-- > 0)
    {
        if (eat_kilobyte())
        {
            printf("Failed to allocate more memory at %zi kb :(\n", eaten_memory);
            return 200;
        }
        if (megabyte++ >= 1024)
        {
            megabytes++;
            printf("Eaten %i  MB of ram\n", megabytes);
            megabyte = 0;
        }
    }
    printf("Successfully eaten requested memory!\n");
    free(memory);
    return 0;
}
Magisch
источник
Да, вы правы, это была причина, не уверенная в технических знаниях, но это имеет смысл. Странно, однако, что это позволяет мне выделять больше памяти, чем я могу фактически использовать.
Петр
Я думаю, что на уровне ОС память реально используется только тогда, когда вы записываете в нее, что имеет смысл, учитывая, что ОС не отслеживает всю память, которую вы теоретически имеете, а только ту, которую вы фактически используете.
Magisch
@Petr mind Если я отмечу мой ответ как вики сообщества, а вы отредактируете свой код для удобства чтения в будущем?
Magisch
@Petr Это совсем не странно. Вот как работает управление памятью в современных ОС. Основной особенностью процессов является то, что они имеют разные адресные пространства, что достигается путем предоставления каждому из них виртуального адресного пространства. x86-64 поддерживает 48 бит для одного виртуального адреса, даже с 1 ГБ страниц, поэтому теоретически возможно несколько терабайт памяти на процесс . Эндрю Таненбаум написал несколько замечательных книг об ОС. Если тебе интересно, читай их!
Каданилюк
1
Я не стал бы использовать формулировку «очевидная утечка памяти». Я не верю, что чрезмерная загрузка или эта технология «копирования памяти при записи» была изобретена для устранения утечек памяти вообще.
Петр
13

Здесь проводится разумная оптимизация. Среда фактически не приобретает память , пока вы не используете его.

Простого memcpyбудет достаточно, чтобы обойти эту оптимизацию. (Вы можете обнаружить, что это callocоптимизирует распределение памяти до момента использования.)

Вирсавия
источник
2
Ты уверен? Я думаю, что если его объем выделенной памяти достигнет максимальной доступной виртуальной памяти, то malloc потерпит неудачу, несмотря ни на что. Как malloc () узнает, что никто не собирается использовать память ?? Это не может, поэтому он должен вызвать sbrk () или любой другой эквивалент в его ОС.
Питер - Восстановить Монику
1
Я почти уверен. (Malloc не знает, но время выполнения, безусловно, будет). Это тривиально для тестирования (хотя сейчас мне непросто: я в поезде).
Вирсавия
@Bathsheba Будет ли достаточно записи одного байта для каждой страницы? Предполагая, что mallocвыделяет на границах страницы то, что мне кажется довольно вероятным.
Каданилюк
2
@doron здесь нет компилятора. Это поведение ядра Linux.
el.pescado
1
Я думаю, что glibc callocиспользует mmap (MAP_ANONYMOUS), дающий обнуленные страницы, поэтому он не дублирует работу ядра по обнулению страниц.
Питер Кордес
6

Не уверен насчет этого, но единственное объяснение, которое я могу понять, это то, что linux - это операционная система копирования при записи. При вызове forkоба процесса указывают на одну и ту же физическую память. Память копируется только один раз, когда один процесс действительно ЗАПИСЫВАЕТ в память.

Я думаю здесь, фактическая физическая память выделяется только тогда, когда кто-то пытается что-то записать в нее. Звонок sbrkили mmapвполне может только обновить память ядра памяти. Фактическая RAM может быть выделена только тогда, когда мы действительно пытаемся получить доступ к памяти.

Дорон
источник
forkне имеет к этому никакого отношения. Вы бы увидели то же поведение, если бы загрузили Linux с этой программой, как /sbin/init. (т.е. PID 1, первый процесс пользовательского режима). Тем не менее, у вас была правильная общая идея с копированием при записи: до тех пор, пока вы не испачкаете их, все вновь выделенные страницы будут копироваться при записи на одну и ту же нулевую страницу.
Питер Кордес
знание о форке позволило мне сделать предположение.
Дорон