Warum frisst dieser Speicherfresser nicht wirklich Speicher?

Lesezeit: 13 Minuten

Benutzeravatar von Petr
Petr

Ich möchte ein Programm erstellen, das eine Out-of-Memory-Situation (OOM) auf einem Unix-Server simuliert. Ich habe diesen supereinfachen Speicherfresser erstellt:

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

Es frisst so viel Speicher wie in definiert memory_to_eat das sind jetzt genau 50 GB RAM. Es weist Speicher um 1 MB zu und druckt genau den Punkt, an dem es nicht mehr zuordnen kann, damit ich weiß, welchen maximalen Wert es essen konnte.

Das Problem ist, dass es funktioniert. Selbst auf einem System mit 1 GB physischem Speicher.

Wenn ich nach oben schaue, sehe ich, dass der Prozess 50 GB virtuellen Speicher und nur weniger als 1 MB residenten Speicher verbraucht. Gibt es eine Möglichkeit, einen Speicherfresser zu erstellen, der ihn wirklich verbraucht?

Systemspezifikationen: Linux-Kernel 3.16 (Debian) höchstwahrscheinlich mit aktiviertem Overcommit (nicht sicher, wie man es auscheckt), ohne Swap und virtualisiert.

  • Vielleicht müssen Sie diesen Speicher tatsächlich verwenden (dh darauf schreiben)?

    – Frau

    20. Oktober 2015 um 10:26 Uhr

  • Ich glaube nicht, dass der Compiler es optimiert, wenn das wahr wäre, würde es nicht 50 GB virtuellen Speicher zuweisen.

    – Petr

    20. Oktober 2015 um 10:28 Uhr

  • @Magisch Ich glaube nicht, dass es der Compiler ist, sondern das Betriebssystem wie Copy-on-Write.

    – Cadaniluk

    20. Oktober 2015 um 10:28 Uhr

  • Du hast Recht, ich habe versucht, ihm zu schreiben, und ich habe gerade meine virtuelle Kiste zerstört …

    – Petr

    20. Oktober 2015 um 10:34 Uhr

  • Das ursprüngliche Programm verhält sich dann wie erwartet sysctl -w vm.overcommit_memory=2 als Wurzel; sehen mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Beachten Sie, dass dies andere Konsequenzen haben kann; Insbesondere sehr große Programme (z. B. Ihr Webbrowser) können keine Hilfsprogramme (z. B. den PDF-Reader) erzeugen.

    – zol

    20. Oktober 2015 um 12:44 Uhr


Wenn dein malloc() -Implementierung fordert Speicher vom Systemkern an (über eine sbrk() oder mmap() Systemaufruf), merkt sich der Kernel nur, dass Sie den Speicher angefordert haben und wo er in Ihrem Adressraum platziert werden soll. Es bildet diese Seiten noch nicht ab.

Wenn der Prozess anschließend auf Speicher innerhalb der neuen Region zugreift, erkennt die Hardware einen Segmentierungsfehler und macht den Kernel auf den Zustand aufmerksam. Der Kernel sucht dann die Seite in seinen eigenen Datenstrukturen und stellt fest, dass Sie dort eine Nullseite haben sollten, also ordnet er eine Nullseite zu (womöglich zuerst eine Seite aus dem Seitencache entfernen) und kehrt vom Interrupt zurück. Ihr Prozess erkennt nicht, dass irgendetwas davon passiert ist, die Kernel-Operation ist vollkommen transparent (mit Ausnahme der kurzen Verzögerung, während der Kernel seine Arbeit erledigt).

Diese Optimierung ermöglicht es, dass der Systemaufruf sehr schnell zurückkehrt, und, was am wichtigsten ist, es vermeidet, dass irgendwelche Ressourcen Ihrem Prozess zugewiesen werden, wenn das Mapping durchgeführt wird. Dadurch können Prozesse ziemlich große Puffer reservieren, die sie unter normalen Umständen nie benötigen, ohne befürchten zu müssen, zu viel Speicher zu verschlingen.


Wenn Sie also einen Speicherfresser programmieren möchten, müssen Sie unbedingt etwas mit dem zugewiesenen Speicher tun. Dazu müssen Sie Ihrem Code nur eine einzige Zeile hinzufügen:

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

Beachten Sie, dass es völlig ausreichend ist, auf ein einzelnes Byte innerhalb jeder Seite zu schreiben (das auf X86 4096 Bytes enthält). Das liegt daran, dass die gesamte Speicherzuweisung vom Kernel zu einem Prozess mit Speicherseitengranularität erfolgt, was wiederum an der Hardware liegt, die Paging mit kleineren Granularitäten nicht zulässt.

  • Es ist auch möglich, Speicher mit festzuschreiben mmap und MAP_POPULATE (Beachten Sie jedoch, dass die Manpage sagt: “MAP_POPULATE wird für private Mappings erst seit Linux 2.6.23 unterstützt“).

    – Toby Speight

    20. Oktober 2015 um 18:07 Uhr


  • Das ist im Grunde richtig, aber ich denke, die Seiten werden alle per Copy-on-Write auf eine genullte Seite abgebildet, anstatt überhaupt nicht in den Seitentabellen vorhanden zu sein. Deshalb müssen Sie jede Seite schreiben und nicht nur lesen. Eine weitere Möglichkeit, physischen Speicher zu verbrauchen, besteht darin, die Seiten zu sperren. zB anrufen mlockall(MCL_FUTURE). (Dies erfordert root, weil ulimit -l ist nur 64 KB für Benutzerkonten bei einer Standardinstallation von Debian/Ubuntu.) Ich habe es gerade unter Linux 3.19 mit dem Standard-sysctl versucht vm/overcommit_memory = 0und gesperrte Seiten verbrauchen Swap / physischen RAM.

    – Peter Cordes

    21. Oktober 2015 um 4:00 Uhr

  • @cad Während der X86-64 zwei größere Seitengrößen (2 MiB und 1 GiB) unterstützt, werden sie vom Linux-Kernel immer noch ganz speziell behandelt. Beispielsweise werden sie nur auf ausdrücklichen Wunsch und nur dann verwendet, wenn das System so konfiguriert wurde, dass sie dies zulassen. Außerdem bleibt die 4-KB-Seite immer noch die Granularität, mit der Speicher abgebildet werden kann. Deshalb glaube ich nicht, dass die Erwähnung riesiger Seiten der Antwort etwas hinzufügt.

    – Cmaster – Wiedereinsetzung von Monica

    21. Oktober 2015 um 7:19 Uhr


  • @AlecTeal Ja, das tut es. Deshalb ist es zumindest unter Linux wahrscheinlicher, dass ein Prozess, der zu viel Speicher verbraucht, vom Out-of-Memory-Killer abgeschossen wird, als der eigene malloc() ruft zurück null. Das ist eindeutig der Nachteil dieses Ansatzes zur Speicherverwaltung. Es ist jedoch bereits die Existenz von Copy-on-Write-Mappings (denken Sie an dynamische Bibliotheken und fork()), die es dem Kernel unmöglich machen, zu wissen, wie viel Speicher tatsächlich benötigt wird. Wenn also der Arbeitsspeicher nicht überbelegt würde, würde Ihnen der abbildbare Arbeitsspeicher ausgehen, lange bevor Sie tatsächlich den gesamten physischen Arbeitsspeicher verwenden würden.

    – Cmaster – Wiedereinsetzung von Monica

    21. Oktober 2015 um 23:39 Uhr

  • @BillBarth Für die Hardware gibt es keinen Unterschied zwischen dem, was Sie als Seitenfehler und Segfault bezeichnen würden. Die Hardware sieht nur einen Zugriff, der die in den Seitentabellen festgelegten Zugriffsbeschränkungen verletzt, und signalisiert diesen Zustand dem Kernel über einen Segmentierungsfehler. Nur die Softwareseite entscheidet dann, ob der Segmentierungsfehler durch die Bereitstellung einer Seite (Aktualisierung der Seitentabellen) behandelt werden soll, oder ob a SIGSEGV Signal an den Prozess geliefert werden soll.

    – Cmaster – Wiedereinsetzung von Monica

    22. Oktober 2015 um 1:53 Uhr

Alle virtuellen Seiten beginnen mit Copy-on-Write, die auf dieselbe genullte physische Seite abgebildet werden. Um physische Seiten zu verbrauchen, können Sie sie verschmutzen, indem Sie etwas auf jede virtuelle Seite schreiben.

Wenn Sie als Root ausgeführt werden, können Sie verwenden mlock(2) oder mlockall(2) um den Kernel die Seiten verkabeln zu lassen, wenn sie zugewiesen sind, ohne sie verschmutzen zu müssen. (Normale Nicht-Root-Benutzer haben eine ulimit -l von nur 64 KB.)

Wie viele andere angedeutet haben, scheint der Linux-Kernel den Speicher nicht wirklich zuzuweisen, es sei denn, Sie schreiben darauf

Eine verbesserte Version des Codes, die das tut, was das OP wollte:

Dies behebt auch die Nichtübereinstimmungen der Zeichenfolgen im printf-Format mit den Typen memory_to_eat und eaten_memory, using %zi zu drucken size_t ganze Zahlen. Die zu fressende Speichergröße in KiB kann optional als Befehlszeilenargument angegeben werden.

Das chaotische Design, das globale Variablen verwendet und um 1.000 statt 4.000 Seiten wächst, bleibt unverändert.

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

  • Ja, Sie haben Recht, es war der Grund, obwohl Sie sich über den technischen Hintergrund nicht sicher sind, aber es macht Sinn. Es ist jedoch seltsam, dass ich damit mehr Speicher zuweisen kann, als ich tatsächlich verwenden kann.

    – Petr

    20. Oktober 2015 um 10:44 Uhr

  • Ich denke, auf Betriebssystemebene wird der Speicher nur dann wirklich verwendet, wenn Sie hineinschreiben, was sinnvoll ist, wenn man bedenkt, dass das Betriebssystem nicht den gesamten Speicher im Auge behält, den Sie theoretisch haben, sondern nur den, den Sie tatsächlich verwenden.

    – Magisch

    20. Oktober 2015 um 10:45 Uhr


  • @Petr mind Wenn ich meine Antwort als Community-Wiki markiere und Sie Ihren Code für die zukünftige Benutzerlesbarkeit bearbeiten?

    – Magisch

    20. Oktober 2015 um 10:46 Uhr

  • @Petr Es ist überhaupt nicht seltsam. So funktioniert die Speicherverwaltung in heutigen Betriebssystemen. Ein Hauptmerkmal von Prozessen besteht darin, dass sie unterschiedliche Adressräume haben, was erreicht wird, indem jedem von ihnen ein virtueller Adressraum bereitgestellt wird. x86-64 unterstützt 48 Bit für eine virtuelle Adresse mit sogar 1 GB Seiten, also theoretisch einige Terabyte Speicher pro Prozess Sind möglich. Andrew Tanenbaum hat einige großartige Bücher über Betriebssysteme geschrieben. Wenn Sie interessiert sind, lesen Sie sie!

    – Cadaniluk

    20. Oktober 2015 um 10:47 Uhr


  • Ich würde die Formulierung “offensichtliches Speicherleck” nicht verwenden. Ich glaube nicht, dass Overcommit oder diese Technologie des “Speicherkopierens beim Schreiben” überhaupt erfunden wurde, um mit Speicherlecks umzugehen.

    – Petr

    20. Oktober 2015 um 10:58 Uhr

Benutzeravatar von Bathsheba
Bathseba

Hier wird eine sinnvolle Optimierung vorgenommen. Die Laufzeit eigentlich nicht erwerben den Speicher, bis Sie ihn verwenden.

Eine einfache memcpy ausreichen, um diese Optimierung zu umgehen. (Vielleicht finden Sie das calloc optimiert immer noch die Speicherzuweisung bis zum Verwendungspunkt.)

  • Bist du dir sicher? Ich denke, wenn sein Zuweisungsbetrag das Maximum von erreicht virtuell verfügbarer Speicher würde der malloc scheitern, egal was passiert. Wie würde malloc() wissen, dass niemand den Speicher verwenden wird?? Es kann nicht, also muss es sbrk() oder was auch immer das Äquivalent in seinem Betriebssystem ist, aufrufen.

    – Peter – Setzen Sie Monica wieder ein

    20. Oktober 2015 um 10:33 Uhr


  • Ich bin hübsch sicher. (Malloc weiß es nicht, aber die Laufzeit würde es sicherlich tun). Es ist trivial zu testen (obwohl es mir gerade nicht leicht fällt: Ich sitze in einem Zug).

    – Bathseba

    20. Oktober 2015 um 10:36 Uhr


  • @Bathsheba Würde es auch ausreichen, ein Byte auf jede Seite zu schreiben? Vorausgesetzt malloc weist Seitengrenzen zu, was mir ziemlich wahrscheinlich erscheint.

    – Cadaniluk

    20. Oktober 2015 um 10:38 Uhr


  • @doron hier ist kein Compiler beteiligt. Es ist das Verhalten des Linux-Kernels.

    – el.pescado – нет войне

    20. Oktober 2015 um 10:53 Uhr

  • Ich denke glibc calloc macht sich mmap(MAP_ANONYMOUS) zunutze und gibt genullte Seiten aus, sodass es die Seiten-Null-Arbeit des Kernels nicht dupliziert.

    – Peter Cordes

    21. Oktober 2015 um 4:03 Uhr

Benutzeravatar von Doron
doron

Ich bin mir nicht sicher, aber die einzige Erklärung, die ich mir vorstellen kann, ist, dass Linux ein Copy-on-Write-Betriebssystem ist. Wenn einer anruft fork die beiden Prozesse zeigen auf denselben physikalischen Speicher. Der Speicher wird nur kopiert, sobald ein Prozess tatsächlich in den Speicher SCHREIBT.

Ich denke, hier wird der eigentliche physikalische Speicher nur zugewiesen, wenn man versucht, etwas darauf zu schreiben. Berufung sbrk oder mmap möglicherweise nur die Speicherbuchhaltung des Kernels aktualisieren. Der tatsächliche Arbeitsspeicher wird möglicherweise nur zugewiesen, wenn wir tatsächlich versuchen, auf den Speicher zuzugreifen.

Benutzeravatar von Alexis Wilke
Alex Wilke

Grundlegende Antwort

Wie von anderen erwähnt, belegt die Zuweisung von Speicher bis zur Verwendung nicht immer den erforderlichen RAM. Dies passiert, wenn Sie einen Puffer zuweisen, der größer als eine Seite ist (normalerweise 4 KB unter Linux).

Eine einfache Antwort wäre, dass Ihre “Eat Memory”-Funktion immer 1 KB anstelle von immer größeren Blöcken zuweist. Dies liegt daran, dass jeder zugewiesene Block mit einem Header (einer Größe für zugewiesene Blöcke) beginnt. Wenn Sie also einen Puffer mit einer Größe kleiner oder gleich einer Seite zuweisen, werden immer alle diese Seiten festgeschrieben.

Nach Ihrer Idee

Um Ihren Code so weit wie möglich zu optimieren, möchten Sie Speicherblöcke zuweisen, die auf die Größe einer Seite ausgerichtet sind.

Soweit ich in Ihrem Code sehen kann, verwenden Sie 1024. Ich würde vorschlagen, dass Sie Folgendes verwenden:

int size;

size = getpagesize();

block_size = size - sizeof(void *) * 2;

Was ist das für ein Voodoo-Zauber sizeof(void *) * 2?! Bei Verwendung der standardmäßigen Speicherzuweisungsbibliothek (z nicht SAN, fence, valgrin, …), befindet sich kurz vor dem zurückgegebenen Zeiger ein kleiner Header malloc() die einen Zeiger auf den nächsten Block und eine Größe enthält.

struct mem_header { void * next_block; intptr_t size; };

Jetzt mit block_sizeall dein malloc() sollte an der zuvor ermittelten Seitengröße ausgerichtet sein.

Wenn Sie alles richtig ausrichten möchten, muss die erste Zuordnung eine ausgerichtete Zuordnung verwenden:

char *p = NULL;
int posix_memalign(&p, size, block_size);

Weitere Zuweisungen (vorausgesetzt, Ihr Tool macht das nur) können verwendet werden malloc(). Sie werden ausgerichtet.

p = malloc(block_size);

Hinweis: Bitte überprüfen Sie, ob es tatsächlich auf Ihrem System ausgerichtet ist … es funktioniert auf meinem.

Als Ergebnis können Sie Ihre Schleife vereinfachen mit:

for(;;)
{
    p = malloc(block_size);
    *p = 1;
}

Bis Sie einen Thread erstellen, die malloc() verwendet keine Mutexe. Es muss aber noch nach einem freien Speicherblock suchen. In Ihrem Fall wird es jedoch nacheinander sein und es wird keine Löcher im zugewiesenen Speicher geben, also wird es ziemlich schnell sein.

Kann es schneller sein?

Weitere Anmerkung zur allgemeinen Speicherallokation in einem Unix-System:

  • das malloc() Funktion und verwandte Funktionen weisen einen Block in Ihrem Heap zu; was am Anfang ziemlich klein ist (vielleicht 2 MB)

  • wenn der vorhandene Haufen ist voll es wird mit dem angebaut sbrk() Funktion; Was Ihren Prozess betrifft, erhöht sich die Speicheradresse immer, das ist was sbrk() tut (im Gegensatz zu MS-Windows, das überall Blöcke zuweist)

  • verwenden sbrk() einmal und dann den Speicher alle “Seitengröße” Bytes zu treffen, wäre schneller als die Verwendung malloc()

    char * p = malloc(size); // get current "highest address"
    
    p += size;
    p = (char*)((intptr_t)p & -size);  // clear bits (alignment)
    
    int total_mem(50 * 1024 * 1024 * 1024); // 50Gb
    void * start(sbrk(total_mem));
    
    char * end((char *)start + total_mem);
    for(; p < end; p += size)
    {
        *p = 1;
    }
    

    Notiere dass der malloc() oben kann Ihnen die “falsche” Startadresse geben. Aber Ihr Prozess macht wirklich nicht viel, also denke ich, dass Sie immer sicher sein werden. Dass for() Schleife wird jedoch so schnell wie möglich sein. Wie von anderen erwähnt, erhalten Sie die total_mem des virtuellen Speichers, der “sofort” zugewiesen wird, und dann der RSS-Speicher, der bei jedem Schreiben zugewiesen wird *p.

WARNUNG: Code nicht getestet, Nutzung auf eigene Gefahr.

1424320cookie-checkWarum frisst dieser Speicherfresser nicht wirklich Speicher?

This website is using cookies to improve the user-friendliness. You agree by using the website further.

Privacy policy