Чому цей пожирач пам'яті насправді не їсть пам'ять?


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 Гб фізичної пам'яті.

Коли я перевіряю зверху, я бачу, що процес з’їдає 50 ГБ віртуальної пам’яті і лише менше 1 Мб пам'яті резидента. Чи існує спосіб створити пам'ять, яка дійсно споживає його?

Характеристики системи: Ядро Linux 3.16 ( Debian ), швидше за все, з увімкненою надмірною передачею (не впевнений, як це перевірити) без заміни та віртуалізації.


16
можливо, вам доведеться насправді використовувати цю пам'ять (тобто записувати в неї)?
мс

4
Я не думаю, що компілятор не оптимізує це, якби це було правдою, він не виділив би 50 ГБ віртуальної пам'яті.
Петро

18
@Magisch Я не думаю, що це компілятор, але ОС, як копіювати при записі.
cadaniluk

4
Ви маєте рацію, я спробував написати це, і я просто заграв свою віртуальну скриньку ...
Петро

4
Оригінальна програма буде вести себе так, як ви очікували, якщо ви зробите sysctl -w vm.overcommit_memory=2як root; див. mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Зауважте, що це може мати інші наслідки; зокрема, дуже великі програми (наприклад, ваш веб-браузер) можуть нерезулювати помічники програм (наприклад, зчитувач PDF).
zwol

Відповіді:


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). Це тому, що весь розподіл пам’яті з ядра в процес відбувається за рахунок деталізації сторінки пам'яті, що, в свою чергу, через апаратного забезпечення, яке не дозволяє здійснювати пейджинг при менших деталях.


6
Крім того , можна зробити пам'ять з mmapі MAP_POPULATE(хоча примітка , що сторінка говорить « MAP_POPULATE підтримується для приватних відображень тільки з Linux 2.6.23 »).
Toby Speight

2
Це в основному правильно, але я думаю, що всі сторінки копіюються при записі відображаються на нульовій сторінці, а не взагалі відсутні в сторінках-таблицях. Ось чому ви повинні писати, а не лише читати, кожну сторінку. Також ще одним способом використання фізичної пам'яті є блокування сторінок. наприклад дзвінок mlockall(MCL_FUTURE). (Для цього потрібен корінь, тому що ulimit -lдля облікових записів користувачів за замовчуванням встановлення Debian / Ubuntu є лише 64кіБ.) Я просто спробував це на Linux 3.19 за допомогою типового sysctl vm/overcommit_memory = 0, а на заблокованих сторінках використовується swap / фізична оперативна пам'ять.
Пітер Кордес

2
@cad Хоча X86-64 підтримує два більші розміри сторінок (2 МіБ та 1 ГіБ), вони все ще обробляються ядром Linux. Наприклад, вони використовуються лише за явним запитом і лише в тому випадку, якщо система налаштована для їх дозволу. Крім того, сторінка 4 кіБ все ще залишається детальністю, за якою може бути відображена пам'ять. Ось чому я не думаю, що згадка про величезні сторінки нічого не відповідає.
cmaster - відновити моніку

1
@AlecTeal Так, це так. Ось чому, принаймні, у Linux, швидше за все, що вбивця, що не має пам’яті, знімає процес, що споживає занадто багато пам’яті, ніж той, який malloc()повертається з нього null. Очевидно, що цей підхід до управління пам'яттю є і недоліком. Однак, вже існування відображень копіювання на запис (думати про динамічні бібліотеки та fork()) унеможливлює ядро ​​знати, скільки пам'яті насправді потрібно. Отже, якби вона не перезаряджала пам'ять, у вас би не вистачало пам’яті з можливістю заздалегідь, перш ніж ви фактично використовували всю фізичну пам'ять.
cmaster - відновити моніку

2
@BillBarth Для обладнання немає різниці між тим, що ви б назвали помилкою сторінки, і segfault. Апаратне забезпечення бачить лише доступ, який порушує обмеження доступу, встановлені в таблицях сторінок, і сигналізує про це до ядра через помилку сегментації. Тоді лише програмне забезпечення вирішує, чи слід усунути помилки сегментації шляхом надання сторінки (оновлення таблиць сторінок), чи SIGSEGVсигнал повинен надходити до процесу.
cmaster - відновити моніку

28

Усі віртуальні сторінки починаються з копіювання при записі, зіставлені з тією ж нульовою фізичною сторінкою. Щоб використовувати фізичні сторінки, ви можете забруднити їх, записавши щось на кожну віртуальну сторінку.

Якщо він працює як root, ви можете використовувати mlock(2)або mlockall(2)розміщувати в ядрі сторінки, коли вони виділяються, не забруднюючи їх. (звичайні ulimit -lнекористувачі мають лише 64 Кб.)

Як багато інших пропонували, схоже, що ядро ​​Linux насправді не виділяє пам'ять, якщо ви не пишете на неї

Вдосконалена версія коду, яка робить те, чого хотіла ОП:

Це також виправляє невідповідність рядків формату printf з типами memory_to_eat та eaten_memory, використовуючи %ziдля друку size_tцілі числа. Розмір пам'яті, яку потрібно з'їсти, в кіБ, необов'язково може бути вказаний як аргумент командного рядка.

Безладний дизайн, що використовує глобальні змінні, і зростає на 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

@Petr mind Якщо я позначаю свою відповідь вікі спільноти і ви редагуєте свій код для подальшої читабельності користувача?
Magisch

@Petr Це зовсім не дивно. Ось так працює управління пам'яттю на сьогоднішніх ОС. Основна ознака процесів полягає в тому, що вони мають чіткі адресні простори, що здійснюється шляхом надання кожному з них віртуального адресного простору. x86-64 підтримує 48 біт для однієї віртуальної адреси з навіть 1 Гб сторінок, тому теоретично можливі деякі Терабайти пам'яті за процес . Ендрю Таненбаум написав кілька чудових книг про ОС. Якщо вам цікаво, прочитайте їх!
cadaniluk

1
Я б не використовував формулювання "очевидний витік пам'яті". Я не вірю, що надмірна комісія або ця технологія "копіювання пам'яті при записі" була придумана для усунення витоків пам'яті взагалі.
Петро

13

Тут робиться розумна оптимізація. Час виконання фактично не набуває пам'яті, поки ви не користуєтесь нею.

Простий memcpyбуде достатньо, щоб обійти цю оптимізацію. (Ви можете виявити, що callocвсе ще оптимізується розподіл пам'яті до моменту використання.)


2
Ти впевнений? Я думаю, якщо розмір його виділення досягне максимуму наявної віртуальної пам’яті, malloc не зможе, незважаючи ні на що. Як malloc () знає, що пам'ять ніхто не збирається використовувати ?? Він не може, тому він повинен викликати sbrk () або будь-який інший еквівалент в його ОС.
Пітер - Відновіть Моніку

1
Я майже впевнений. (malloc не знає, але час виконання, безумовно, буде). Тестувати банально (хоча зараз це не просто: я в поїзді).
Вірсавія

@Bathsheba Чи вистачить запису по одному байту на кожну сторінку? Припускаючи, що mallocвиділяє на межах сторінки те, що мені здається досить імовірним.
cadaniluk

2
@doron тут немає жодного компілятора. Це поведінка ядра Linux.
el.pescado

1
Я думаю, що glibc callocкористується перевагою mmap (MAP_ANONYMOUS), надаючи нульові сторінки, тому він не дублює роботу нульової сторінки ядра.
Пітер Кордес

6

Не впевнений у цьому, але єдине пояснення, що я можу зробити, це те, що Linux є операційною системою копіювання при записі. Коли виклик, forkобидва процеси вказують на однакову фізичну пам'ять. Пам'ять копіюється лише один раз, коли один процес фактично ЗАПИСЬ в пам'ять.

Я думаю, що тут фактична фізична пам'ять виділяється лише тоді, коли людина намагається щось написати до неї. Виклик sbrkабо mmapможе лише оновити зберігання пам'яті ядра. Фактична оперативна пам’ять може бути виділена лише тоді, коли ми фактично намагаємося отримати доступ до пам'яті.


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

Знання про виделку дозволило мені здогадатися.
Дорон
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.