Dlaczego ten pożeracz wspomnień naprawdę nie zjada pamięci?

150

Chcę utworzyć program, który będzie symulował sytuację braku pamięci (OOM) na serwerze Unix. Stworzyłem ten super prosty pożeracz pamięci:

#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;
}

Zjada tyle pamięci, ile zdefiniowano, w memory_to_eatktórej teraz jest dokładnie 50 GB pamięci RAM. Alokuje pamięć o 1 MB i drukuje dokładnie punkt, w którym nie może przydzielić więcej, dzięki czemu wiem, jaką maksymalną wartość udało mu się zjeść.

Problem w tym, że to działa. Nawet w systemie z 1 GB pamięci fizycznej.

Kiedy sprawdzam górę, widzę, że proces zjada 50 GB pamięci wirtualnej i tylko mniej niż 1 MB pamięci rezydentnej. Czy istnieje sposób na stworzenie pożeracza pamięci, który naprawdę ją pochłania?

Specyfikacje systemu: jądro Linuksa 3.16 ( Debian ) najprawdopodobniej z włączonym overcommitem (nie wiem jak to sprawdzić) bez wymiany i zwirtualizowane.

Petr
źródło
16
może faktycznie musisz korzystać z tej pamięci (czyli pisać do niej)?
ms
4
Nie sądzę, aby kompilator go optymalizował, gdyby to była prawda, nie przydzieliłby 50 GB pamięci wirtualnej.
Petr
18
@Magisch Nie sądzę, że to kompilator, ale system operacyjny taki jak kopiowanie przy zapisie.
cadaniluk
4
Masz rację, próbowałem do niego napisać i właśnie zestrzeliłem moje wirtualne pudełko ...
Petr
4
Oryginalny program będzie zachowywał się zgodnie z oczekiwaniami, jeśli zrobisz to sysctl -w vm.overcommit_memory=2jako root; zobacz mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Pamiętaj, że może to mieć inne konsekwencje; w szczególności bardzo duże programy (np. przeglądarka internetowa) mogą nie uruchamiać programów pomocniczych (np. czytnik PDF).
zwolnić

Odpowiedzi:

221

Kiedy twoja malloc()implementacja żąda pamięci od jądra systemu (przez wywołanie systemowe sbrk()lub mmap()), jądro tylko zauważa, że ​​zażądałeś pamięci i gdzie ma ona zostać umieszczona w twojej przestrzeni adresowej. W rzeczywistości nie mapuje jeszcze tych stron .

Gdy proces następnie uzyskuje dostęp do pamięci w nowym regionie, sprzęt rozpoznaje błąd segmentacji i ostrzega jądro o stanie. Następnie jądro wyszukuje stronę w swoich własnych strukturach danych i stwierdza, że ​​powinieneś mieć tam stronę zerową, więc odwzorowuje stronę zerową (prawdopodobnie najpierw wykluczając stronę z pamięci podręcznej stron) i wraca z przerwania. Twój proces nie zdaje sobie sprawy, że coś takiego się wydarzyło, operacja jądra jest całkowicie przezroczysta (z wyjątkiem krótkiego opóźnienia, w którym jądro wykonuje swoją pracę).

Ta optymalizacja umożliwia bardzo szybkie zwrócenie wywołania systemowego i, co najważniejsze, pozwala uniknąć przydzielenia zasobów do procesu podczas mapowania. Pozwala to procesom rezerwować dość duże bufory, których nigdy nie potrzebują w normalnych okolicznościach, bez obawy o pochłonięcie zbyt dużej ilości pamięci.


Tak więc, jeśli chcesz zaprogramować pożeracza pamięci, absolutnie musisz coś zrobić z przydzieloną pamięcią. W tym celu wystarczy dodać jedną linię do kodu:

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;
    }
}

Zauważ, że w zupełności wystarczy napisać do pojedynczego bajtu na każdej stronie (która zawiera 4096 bajtów na X86). Dzieje się tak, ponieważ cała alokacja pamięci z jądra do procesu odbywa się na poziomie szczegółowości strony pamięci, co z kolei jest spowodowane sprzętem, który nie pozwala na stronicowanie z mniejszą szczegółowością.

cmaster - przywróć monikę
źródło
6
Możliwe jest również zatwierdzenie pamięci za pomocą mmapi MAP_POPULATE(chociaż na stronie podręcznika jest napisane, że " MAP_POPULATE jest obsługiwane tylko dla prywatnych mapowań od Linuksa 2.6.23 ").
Toby Speight
2
Zasadniczo to prawda, ale myślę, że wszystkie strony są mapowane do kopiowania przy zapisie na wyzerowaną stronę, a nie w ogóle nie występują w tabelach stron. Dlatego każdą stronę musisz pisać, a nie tylko czytać. Innym sposobem wykorzystania pamięci fizycznej jest zablokowanie stron. np mlockall(MCL_FUTURE). zadzwoń . (Wymaga to roota, ponieważ ulimit -lma tylko 64 kB dla kont użytkowników w domyślnej instalacji Debiana / Ubuntu.) Właśnie wypróbowałem to na Linuksie 3.19 z domyślnym sysctl vm/overcommit_memory = 0, a zablokowane strony zużywają swap / fizyczną pamięć RAM.
Peter Cordes
2
@cad Podczas gdy X86-64 obsługuje dwa większe rozmiary stron (2 MiB i 1 GiB), są one nadal traktowane wyjątkowo w jądrze Linuksa. Na przykład są używane tylko na wyraźne żądanie i tylko wtedy, gdy system został skonfigurowany tak, aby na nie zezwalał. Ponadto strona 4 kB nadal pozostaje ziarnistością, przy której pamięć może być mapowana. Dlatego nie sądzę, aby wspominanie o ogromnych stronach cokolwiek dodawało do odpowiedzi.
cmaster
1
@AlecTeal Tak, to prawda. Dlatego, przynajmniej w Linuksie, jest bardziej prawdopodobne, że proces, który zużywa zbyt dużo pamięci, zostanie wystrzelony przez zabójcę braku pamięci, niż malloc()powróci jedno z jego wywołań null. To oczywiście wada tego podejścia do zarządzania pamięcią. Jednak to już istnienie mapowań kopiowania przy zapisie (pomyśl o bibliotekach dynamicznych i fork()) sprawia, że ​​jądro nie może wiedzieć, ile pamięci będzie faktycznie potrzebne. Tak więc, gdyby nie przeciążał pamięci, zabrakłoby pamięci możliwej do mapowania na długo przed faktycznym wykorzystaniem całej pamięci fizycznej.
cmaster
2
@BillBarth Jeśli chodzi o sprzęt, nie ma różnicy między tym, co nazwałbyś błędem strony, a segfault. Sprzęt widzi tylko dostęp, który narusza ograniczenia dostępu określone w tabelach stron, i sygnalizuje ten stan jądru poprzez błąd segmentacji. Dopiero wtedy strona oprogramowania decyduje, czy błąd segmentacji powinien zostać obsłużony poprzez dostarczenie strony (aktualizacja tabel stron), czy też SIGSEGVsygnał powinien zostać dostarczony do procesu.
cmaster
28

Wszystkie strony wirtualne rozpoczynają się od kopiowania przy zapisie mapowanych na tę samą zerowaną stronę fizyczną. Aby wykorzystać strony fizyczne, możesz je zabrudzić, pisząc coś na każdej stronie wirtualnej.

Jeśli pracujesz jako root, możesz użyć mlock(2)lub mlockall(2)zmusić jądro do łączenia stron, gdy są przydzielone, bez konieczności ich brudzenia. (zwykli użytkownicy ulimit -linni niż root mają tylko 64 kB.)

Jak sugerowało wielu innych, wydaje się, że jądro Linuksa tak naprawdę nie alokuje pamięci, chyba że do niej napiszesz

Ulepszona wersja kodu, która robi to, czego chciał OP:

To również rozwiązuje niezgodność łańcuchów formatu printf z typami memory_to_eat i eaten_memory, używając %zido drukowania size_tliczb całkowitych. Rozmiar pamięci do zjedzenia w kiB może być opcjonalnie określony jako argument wiersza poleceń.

Nieuporządkowany projekt wykorzystujący zmienne globalne i zwiększający się o 1 000 zamiast 4 000 stron pozostaje niezmieniony.

#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
źródło
Tak, masz rację, to był powód, nie jestem pewien co do zaplecza technicznego, ale ma to sens. Dziwne jest jednak to, że pozwala mi przydzielić więcej pamięci niż faktycznie mogę wykorzystać.
Petr,
Myślę, że na poziomie systemu operacyjnego pamięć jest naprawdę używana tylko wtedy, gdy piszesz do niej, co ma sens, biorąc pod uwagę, że system operacyjny nie śledzi całej pamięci, którą teoretycznie masz, ale tylko tę, której faktycznie używasz.
Magisch
@Petr mind Jeśli oznaczę moją odpowiedź jako wiki społeczności i wyedytujesz swój kod w celu przyszłej czytelności?
Magisch
@Petr To wcale nie jest dziwne. Tak działa zarządzanie pamięcią w dzisiejszych systemach operacyjnych. Główną cechą procesów jest to, że mają odrębne przestrzenie adresowe, co jest realizowane poprzez zapewnienie każdemu z nich wirtualnej przestrzeni adresowej. x86-64 obsługuje 48-bitów dla jednego adresu wirtualnego, z nawet 1 GB stron, więc teoretycznie możliwe są niektóre terabajty pamięci na proces . Andrew Tanenbaum napisał kilka świetnych książek o systemach operacyjnych. Jeśli jesteś zainteresowany, przeczytaj je!
cadaniluk
1
Nie użyłbym sformułowania „oczywisty wyciek pamięci”. Nie wierzę, że overcommit lub technologia „kopiowania pamięci przy zapisie” została w ogóle wynaleziona, aby radzić sobie z wyciekami pamięci.
Petr
13

Przeprowadzana jest tutaj sensowna optymalizacja. Środowisko wykonawcze nie pobiera pamięci, dopóki jej nie użyjesz.

Prosty memcpywystarczy, aby obejść tę optymalizację. (Może się okazać, że callocnadal optymalizuje alokację pamięci do momentu użycia).

Batszeba
źródło
2
Jesteś pewny? Myślę, że jeśli jego wielkość alokacji osiągnie maksymalną dostępną pamięć wirtualną, malloc zawiedzie, bez względu na wszystko. Skąd malloc () miałoby wiedzieć, że nikt nie będzie używał pamięci? Nie może, więc musi wywołać sbrk () lub jakikolwiek odpowiednik w jego systemie operacyjnym.
Peter - Przywróć Monikę
1
Jestem całkiem pewny. (malloc nie wie, ale środowisko uruchomieniowe z pewnością tak). Testowanie jest trywialne (chociaż nie jest to dla mnie łatwe: jestem w pociągu).
Batszeba
@Bathsheba Czy wystarczy wpisać jeden bajt na każdej stronie? Zakładając, że mallocprzydziela na granice strony, co wydaje mi się całkiem prawdopodobne.
cadaniluk
2
@doron nie ma tu żadnego kompilatora. To zachowanie jądra Linuksa.
el.pescado
1
Myślę, że glibc callocwykorzystuje mmap (MAP_ANONYMOUS) dając zerowane strony, więc nie powiela pracy jądra zerowania stron.
Peter Cordes
6

Nie jestem pewien co do tego, ale jedyne wyjaśnienie, jakie mogę sobie wyobrazić, to fakt, że Linux jest systemem operacyjnym typu „kopiuj przy zapisie”. Kiedy ktoś wywołuje, forkoba procesy wskazują na tę samą pamięć fizyczną. Pamięć jest kopiowana tylko wtedy, gdy jeden proces faktycznie ZAPISUJE do pamięci.

Myślę, że tutaj faktyczna pamięć fizyczna jest przydzielana tylko wtedy, gdy próbuje się coś do niej zapisać. Wywołanie sbrklub mmapmoże tylko zaktualizować księgę pamięci jądra. Rzeczywista pamięć RAM może zostać przydzielona tylko wtedy, gdy faktycznie próbujemy uzyskać dostęp do pamięci.

doron
źródło
forknie ma z tym nic wspólnego. Zobaczysz to samo zachowanie, jeśli uruchomisz Linuksa z tym programem jako /sbin/init. (tj. PID 1, pierwszy proces w trybie użytkownika). Miałeś jednak dobry ogólny pomysł z kopiowaniem przy zapisie: dopóki ich nie zabrudzisz, wszystkie nowo przydzielone strony są mapowane w trybie kopiowania przy zapisie na tę samą zerowaną stronę.
Peter Cordes
Wiedza o widelcu pozwoliła mi zgadywać.
doron