Perché questo mangiatore di memoria non mangia davvero memoria?


150

Voglio creare un programma che simuli una situazione di memoria insufficiente (OOM) su un server Unix. Ho creato questo semplicissimo mangiatore di memoria:

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

Mangia tutta la memoria definita in memory_to_eatcui ora sono esattamente 50 GB di RAM. Alloca memoria di 1 MB e stampa esattamente il punto in cui non riesce ad allocare di più, in modo che io sappia quale valore massimo è riuscito a mangiare.

Il problema è che funziona. Anche su un sistema con 1 GB di memoria fisica.

Quando controllo in alto vedo che il processo consuma 50 GB di memoria virtuale e solo meno di 1 MB di memoria residente. C'è un modo per creare un mangiatore di memoria che lo consumi davvero?

Specifiche di sistema: kernel Linux 3.16 ( Debian ) molto probabilmente con overcommit abilitato (non sono sicuro su come verificarlo) senza swap e virtualizzato.


16
forse devi effettivamente usare questa memoria (cioè scrivere su di essa)?
ms

4
Non penso che il compilatore lo ottimizzi, se fosse vero, non allocherebbe 50 GB di memoria virtuale.
Petr,

18
@Magisch Non penso che sia il compilatore ma il sistema operativo gradisce il copy-on-write.
Cadaniluk,

4
Hai ragione, ho provato a scrivergli e ho appena cancellato la mia scatola virtuale ...
Petr

4
Il programma originale si comporterà come previsto se lo si fa sysctl -w vm.overcommit_memory=2come root; vedi mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Si noti che ciò può avere altre conseguenze; in particolare, programmi molto grandi (ad es. il tuo browser web) potrebbero non generare programmi di aiuto (ad es. il lettore PDF).
zwol,

Risposte:


221

Quando l' malloc()implementazione richiede memoria dal kernel di sistema (tramite una sbrk()o una mmap()chiamata di sistema), il kernel prende nota solo che è stata richiesta la memoria e dove deve essere collocata nel proprio spazio degli indirizzi. In realtà non mappa ancora quelle pagine .

Quando il processo accede successivamente alla memoria all'interno della nuova area, l'hardware riconosce un errore di segmentazione e avvisa il kernel della condizione. Il kernel quindi cerca la pagina nelle proprie strutture di dati e trova che dovresti avere una pagina zero lì, quindi mappa in una pagina zero (possibilmente prima sfrattando una pagina dalla cache della pagina) e ritorna dall'interrupt. Il tuo processo non si rende conto che tutto ciò è avvenuto, l'operazione dei kernel è perfettamente trasparente (tranne per il breve ritardo mentre il kernel fa il suo lavoro).

Questa ottimizzazione consente alla chiamata di sistema di tornare molto rapidamente e, soprattutto, evita che qualsiasi risorsa venga impegnata nel processo al momento della mappatura. Ciò consente ai processi di riservare buffer piuttosto grandi che non sono mai necessari in circostanze normali, senza il timore di divorare troppa memoria.


Quindi, se vuoi programmare un mangiatore di memoria, devi assolutamente fare qualcosa con la memoria che assegni. Per questo, devi solo aggiungere una singola riga al tuo codice:

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

Si noti che è perfettamente sufficiente scrivere su un singolo byte all'interno di ciascuna pagina (che contiene 4096 byte su X86). Questo perché tutta l'allocazione di memoria dal kernel a un processo viene eseguita con granularità della pagina di memoria, che è, a sua volta, a causa dell'hardware che non consente il paging con granularità più piccole.


6
È anche possibile eseguire il commit della memoria con mmape MAP_POPULATE(sebbene si noti che la pagina man dice " MAP_POPULATE è supportato per i mapping privati ​​solo da Linux 2.6.23 ").
Toby Speight,

2
È fondamentalmente giusto, ma penso che le pagine siano tutte copiate su scrittura mappate su una pagina azzerata, piuttosto che non presenti affatto nelle tabelle delle pagine. Ecco perché devi scrivere, non solo leggere, ogni pagina. Inoltre, un altro modo per utilizzare la memoria fisica è bloccare le pagine. ad es mlockall(MCL_FUTURE). chiamata . (Questo richiede root, perché ulimit -lsono solo 64 kB per gli account utente su un'installazione predefinita di Debian / Ubuntu.) L'ho appena provato su Linux 3.19 con il sistema predefinito vm/overcommit_memory = 0e le pagine bloccate usano la RAM di swap / fisica.
Peter Cordes,

2
@cad Sebbene l'X86-64 supporti due dimensioni di pagina più grandi (2 MiB e 1 GiB), sono comunque trattati in modo abbastanza speciale dal kernel Linux. Ad esempio, vengono utilizzati solo su richiesta esplicita e solo se il sistema è stato configurato per consentirli. Inoltre, la pagina a 4 kiB rimane ancora la granularità con cui è possibile mappare la memoria. Ecco perché non penso che menzionare pagine enormi aggiunga qualcosa alla risposta.
cmaster - ripristina monica il

1
@AlecTeal Sì, lo fa. Ecco perché, almeno su Linux, è più probabile che un processo che consuma troppa memoria venga sparato dal killer della memoria piuttosto che quello delle sue malloc()chiamate ritorni null. Questo è chiaramente il rovescio della medaglia di questo approccio alla gestione della memoria. Tuttavia, è già l'esistenza di mappature copy-on-write (pensate alle librerie dinamiche e fork()) che rendono impossibile al kernel sapere quanta memoria sarà effettivamente necessaria. Quindi, se non sovraccaricasse la memoria, si esaurirebbe la memoria mappabile molto prima di utilizzare effettivamente tutta la memoria fisica.
cmaster - ripristina monica il

2
@BillBarth Per l'hardware non c'è differenza tra ciò che chiamereste un errore di pagina e un segfault. L'hardware vede solo un accesso che viola le restrizioni di accesso stabilite nelle tabelle delle pagine e segnala tale condizione al kernel tramite un errore di segmentazione. È solo il lato software che decide quindi se l'errore di segmentazione debba essere gestito fornendo una pagina (aggiornando le tabelle delle pagine) o se un SIGSEGVsegnale debba essere inviato al processo.
cmaster - ripristina monica il

28

Tutte le pagine virtuali iniziano copia su scrittura mappate sulla stessa pagina fisica azzerata. Per usare pagine fisiche, puoi sporcarle scrivendo qualcosa su ogni pagina virtuale.

Se eseguito come root, puoi usare mlock(2)o mlockall(2)fare in modo che il kernel colleghi le pagine quando vengono allocate, senza doverle sporcare. (i normali utenti non root hanno un valore ulimit -ldi soli 64 kB).

Come molti altri hanno suggerito, sembra che il kernel Linux non alloca realmente la memoria a meno che non ci si scriva

Una versione migliorata del codice, che fa ciò che l'OP desiderava:

Questo risolve anche la mancata corrispondenza della stringa del formato printf con i tipi di memory_to_eat e eaten_memory, usando %ziper stampare size_tnumeri interi. La dimensione della memoria da mangiare, in kiB, può essere facoltativamente specificata come arg della riga di comando.

Il design disordinato che utilizza variabili globali e cresce di 1k invece di 4k pagine, rimane invariato.

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

Sì, hai ragione, era la ragione, non era sicuro del background tecnico, ma ha senso. È strano però che mi permetta di allocare più memoria di quella che posso effettivamente usare.
Petr,

Penso che a livello di sistema operativo la memoria sia realmente utilizzata solo quando ci si scrive, il che ha senso considerando che il sistema operativo non tiene sotto controllo tutta la memoria che teoricamente si possiede, ma solo su ciò che si utilizza effettivamente.
Magisch,

@Petr mind Se contrassegno la mia risposta come wiki della community e modifichi nel tuo codice per la futura leggibilità dell'utente?
Magisch,

@Petr Non è affatto strano. Ecco come funziona la gestione della memoria sui sistemi operativi di oggi. Una caratteristica importante dei processi è che hanno spazi di indirizzi distinti, che si ottiene fornendo a ciascuno di essi uno spazio di indirizzi virtuale. x86-64 supporta 48 bit per un indirizzo virtuale, con anche 1 GB di pagine, quindi, in teoria, sono possibili alcuni Terabyte di memoria per processo . Andrew Tanenbaum ha scritto alcuni grandi libri sui sistemi operativi. Se sei interessato, leggili!
Cadaniluk,

1
Non userei l'espressione "ovvia perdita di memoria" Non credo che il sovraccarico o questa tecnologia di "copia da memoria in scrittura" sia stata inventata per gestire le perdite di memoria.
Petr,

13

Qui è in corso un'ottimizzazione sensata. Il runtime non acquisisce effettivamente la memoria fino a quando non la si utilizza.

Un semplice memcpysarà sufficiente per aggirare questa ottimizzazione. (Potresti scoprire che callocottimizza ancora l'allocazione della memoria fino al punto di utilizzo.)


2
Sei sicuro? Penso che se la sua quantità di allocazione raggiunge il massimo della memoria virtuale disponibile, il malloc fallirebbe, qualunque cosa accada. Come farebbe malloc () a sapere che nessuno userà la memoria ?? Non può, quindi deve chiamare sbrk () o qualunque sia l'equivalente nel suo sistema operativo.
Peter - Ripristina Monica il

1
Sono abbastanza sicuro. (malloc non lo sa, ma il runtime certamente lo farebbe). È banale testare (anche se non è facile per me adesso: sono su un treno).
Bathsheba,

@Bathsheba Basterebbe anche scrivere un byte per ogni pagina? Supponendo di mallocallocare ai limiti della pagina ciò che mi sembra abbastanza probabile.
Cadaniluk,

2
@doron non c'è nessun compilatore coinvolto qui. È il comportamento del kernel Linux.
el.pescado,

1
Penso che glibc callocsfrutti mmap (MAP_ANONYMOUS) dando pagine azzerate, quindi non duplica il lavoro di azzeramento delle pagine del kernel.
Peter Cordes,

6

Non sono sicuro di questo, ma l'unica spiegazione di cui posso fare è che Linux è un sistema operativo copia su scrittura. Quando uno chiama forkentrambi i processi puntano alla stessa memoria fisica. La memoria viene copiata solo quando un processo in realtà SCRIVE sulla memoria.

Penso che qui, la memoria fisica effettiva sia allocata solo quando si cerca di scrivergli qualcosa. La chiamata sbrko mmappotrebbe anche solo aggiornare la memoria del kernel. La RAM effettiva può essere allocata solo quando si tenta effettivamente di accedere alla memoria.


forknon ha nulla a che fare con questo. Vedresti lo stesso comportamento se avessi avviato Linux con questo programma di /sbin/init. (ovvero PID 1, il primo processo in modalità utente). Tuttavia, hai avuto la giusta idea generale con il copy-on-write: fino a quando non li sporchi, le pagine appena allocate vengono tutte mappate in copia-su-scrittura sulla stessa pagina azzerata.
Peter Cordes,

sapere di Fork mi ha permesso di fare un'ipotesi.
Doron,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.