Warum frisst dieser Speicherfresser nicht wirklich Speicher?

150

Ich möchte ein Programm erstellen, das eine OOM-Situation (Out-of-Memory) 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 verbraucht so viel Speicher wie definiert, in memory_to_eatdem jetzt genau 50 GB RAM vorhanden sind. Es ordnet Speicher um 1 MB zu und druckt genau den Punkt, an dem es nicht mehr zuordnen kann, so dass ich weiß, welchen Maximalwert es geschafft hat, zu essen.

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

Wenn ich oben nachschaue, 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.

Petr
quelle
16
Vielleicht müssen Sie diesen Speicher tatsächlich verwenden (dh darauf schreiben)?
ms
4
Ich glaube nicht, dass der Compiler es optimiert. Wenn das stimmt, würde er keine 50 GB virtuellen Speicher zuweisen.
Petr
18
@Magisch Ich glaube nicht, dass es der Compiler ist, sondern das Betriebssystem wie Copy-on-Write.
Cadaniluk
4
Sie haben Recht, ich habe versucht, darauf zu schreiben, und ich habe gerade meine virtuelle Box zerstört ...
Petr
4
Das ursprüngliche Programm verhält sich wie erwartet, wenn Sie sysctl -w vm.overcommit_memory=2als Root arbeiten. Siehe 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 möglicherweise keine Hilfsprogramme (z. B. den PDF-Reader) erzeugen.
zwol

Antworten:

221

Wenn Ihre malloc()Implementierung Speicher vom Systemkern anfordert (über einen sbrk()oder einen mmap()Systemaufruf), notiert der Kernel nur, dass Sie den Speicher angefordert haben und wo er in Ihrem Adressraum abgelegt werden soll. Diese Seiten werden noch nicht zugeordnet .

Wenn der Prozess anschließend auf den Speicher innerhalb der neuen Region zugreift, erkennt die Hardware einen Segmentierungsfehler und warnt den Kernel vor dem Zustand. Der Kernel sucht dann die Seite in seinen eigenen Datenstrukturen und stellt fest, dass Sie dort eine Nullseite haben sollten, sodass er einer Nullseite zugeordnet wird (möglicherweise wird zuerst eine Seite aus dem Seiten-Cache entfernt) und vom Interrupt zurückkehrt. Ihr Prozess erkennt nicht, dass dies geschehen ist. Die Kerneloperation ist vollkommen transparent (mit Ausnahme der kurzen Verzögerung, während der Kernel seine Arbeit erledigt).

Durch diese Optimierung kann der Systemaufruf sehr schnell zurückgegeben werden, und vor allem werden Ressourcen vermieden, die bei der Zuordnung für Ihren Prozess festgeschrieben werden. Dies ermöglicht es Prozessen, ziemlich große Puffer zu reservieren, die sie unter normalen Umständen niemals 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 von Ihnen 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 vollkommen ausreichend ist, auf jeder Seite in ein einzelnes Byte zu schreiben (das auf X86 4096 Bytes enthält). Dies liegt daran, dass die gesamte Speicherzuweisung vom Kernel zu einem Prozess bei der Speicherseitengranularität erfolgt, was wiederum auf die Hardware zurückzuführen ist, die kein Paging bei kleineren Granularitäten zulässt.

cmaster - Monica wieder einsetzen
quelle
6
Es ist auch möglich, Speicher mit mmapund festzuschreibenMAP_POPULATE (beachten Sie jedoch, dass auf der Manpage steht, dass " MAP_POPULATE nur für private Zuordnungen seit Linux 2.6.23 unterstützt wird ").
Toby Speight
2
Das ist im Grunde richtig, aber ich denke, die Seiten sind alle Copy-on-Write-Seiten, die einer Seite mit Null zugeordnet sind, anstatt überhaupt nicht in den Seitentabellen vorhanden zu sein. Deshalb muss man jede Seite schreiben, nicht nur lesen. Eine andere Möglichkeit, den physischen Speicher zu verbrauchen, besteht darin, die Seiten zu sperren. zB anrufen mlockall(MCL_FUTURE). (Dies erfordert root, da ulimit -lbei einer Standardinstallation von Debian / Ubuntu nur 64 KB für Benutzerkonten vorhanden sind.) Ich habe es gerade unter Linux 3.19 mit dem Standard-sysctl vm/overcommit_memory = 0versucht, und gesperrte Seiten verbrauchen Swap / physischen RAM.
Peter Cordes
2
@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 besonders behandelt. Beispielsweise werden sie nur auf explizite Anforderung verwendet und nur, wenn das System so konfiguriert wurde, dass sie dies zulassen. Außerdem bleibt die 4-kiB-Seite weiterhin die Granularität, mit der der Speicher zugeordnet werden kann. Deshalb glaube ich nicht, dass das Erwähnen großer Seiten der Antwort etwas hinzufügt.
cmaster
1
@AlecTeal Ja, das tut es. Aus diesem Grund ist es zumindest unter Linux wahrscheinlicher, dass ein Prozess, der zu viel Speicher verbraucht, vom Killer für nicht genügend Speicher ausgeführt wird, als dass einer seiner malloc()Aufrufe zurückkehrt null. Das ist eindeutig der Nachteil dieses Ansatzes zur Speicherverwaltung. Es ist jedoch bereits das Vorhandensein 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 der Speicher nicht überlastet würde, würde Ihnen der abbildbare Speicher ausgehen, lange bevor Sie tatsächlich den gesamten physischen Speicher verwenden.
cmaster
2
@ BillBarth Bei der Hardware gibt es keinen Unterschied zwischen dem, was Sie als Seitenfehler und Segfault bezeichnen würden. Die Hardware sieht nur einen Zugriff, der gegen die in den Seitentabellen festgelegten Zugriffsbeschränkungen verstößt, und signalisiert diese Bedingung dem Kernel über einen Segmentierungsfehler. Nur die Softwareseite entscheidet dann, ob der Segmentierungsfehler durch Bereitstellung einer Seite (Aktualisierung der Seitentabellen) SIGSEGVbehoben werden soll oder ob ein Signal an den Prozess übermittelt werden soll.
cmaster - wieder herstellen Monica
28

Alle virtuellen Seiten beginnen mit dem Kopieren beim Schreiben, das derselben physischen Seite mit Null zugeordnet ist. Um physische Seiten zu verbrauchen, können Sie sie verschmutzen, indem Sie auf jede virtuelle Seite etwas schreiben.

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

Wie viele andere vorgeschlagen haben, scheint der Linux-Kernel den Speicher nur dann wirklich zuzuweisen, wenn Sie darauf schreiben

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

Dadurch werden auch die Nichtübereinstimmungen der Zeichenfolgen im printf-Format mit den Typen memory_to_eat und ate_memory behoben, mit denen Ganzzahlen %zigedruckt werden size_t. Die zu essende Speichergröße in kiB kann optional als Befehlszeilenargument angegeben werden.

Das chaotische Design, das globale Variablen verwendet und um 1 KB anstelle von 4 KB 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;
}
Magisch
quelle
Ja, Sie haben Recht, es war der Grund, nicht sicher über den technischen Hintergrund, aber es macht Sinn. Es ist jedoch seltsam, dass ich dadurch mehr Speicher zuweisen kann, als ich tatsächlich verwenden kann.
Petr
Ich denke, auf Betriebssystemebene wird der Speicher nur dann wirklich verwendet, wenn Sie in ihn schreiben. Dies ist sinnvoll, wenn man bedenkt, dass das Betriebssystem nicht den gesamten Speicher überwacht, den Sie theoretisch haben, sondern nur den, den Sie tatsächlich verwenden.
Magisch
@Petr mind Wenn ich meine Antwort als Community-Wiki markiere und Sie Ihren Code für zukünftige Benutzerlesbarkeit bearbeiten?
Magisch
@Petr Es ist überhaupt nicht komisch. So funktioniert die Speicherverwaltung unter heutigen Betriebssystemen. Ein Hauptmerkmal von Prozessen besteht darin, dass sie unterschiedliche Adressräume haben, was erreicht wird, indem jedem von ihnen ein virtueller Adressraum zur Verfügung gestellt wird. x86-64 unterstützt 48 Bit für eine virtuelle Adresse mit sogar 1 GB Seiten, sodass theoretisch einige Terabyte Speicher pro Prozess möglich sind. Andrew Tanenbaum hat einige großartige Bücher über Betriebssysteme geschrieben. Wenn Sie interessiert sind, lesen Sie sie!
Cadaniluk
1
Ich würde den Wortlaut "offensichtlicher Speicherverlust" nicht verwenden. Ich glaube nicht, dass Overcommit oder diese Technologie von "Speicherkopie beim Schreiben" erfunden wurde, um Speicherlecks überhaupt zu behandeln.
Petr
13

Hier wird eine sinnvolle Optimierung vorgenommen. Die Laufzeit erfasst den Speicher erst, wenn Sie ihn verwenden.

Eine einfache memcpywird ausreichen, um diese Optimierung zu umgehen. (Möglicherweise callocwird dadurch die Speicherzuordnung bis zum Verwendungsort noch optimiert.)

Bathseba
quelle
2
Bist du sicher? Ich denke, wenn sein Zuweisungsbetrag das Maximum des verfügbaren virtuellen Speichers erreicht, würde das Malloc ausfallen, egal was passiert. Wie würde malloc () wissen, dass niemand den Speicher nutzen wird? Es kann nicht, also muss es sbrk () oder was auch immer das Äquivalent in seinem Betriebssystem ist aufrufen.
Peter - Stellen Sie Monica
1
Ich bin mir ziemlich sicher. (Malloc weiß es nicht, aber die Laufzeit würde es sicherlich tun). Es ist trivial zu testen (obwohl es mir momentan nicht leicht fällt: Ich bin in einem Zug).
Bathseba
@Bathsheba Würde es auch ausreichen, ein Byte auf jede Seite zu schreiben? Vorausgesetzt malloc, es werden Seitengrenzen zugewiesen, was mir ziemlich wahrscheinlich erscheint.
Cadaniluk
2
@doron hier ist kein Compiler beteiligt. Es ist das Verhalten des Linux-Kernels.
el.pescado
1
Ich denke, glibc callocnutzt mmap (MAP_ANONYMOUS), um Seiten mit Nullen zu erstellen, sodass die Arbeit des Kernels zum Nullstellen von Seiten nicht dupliziert wird.
Peter Cordes
6

Ich bin mir nicht sicher, aber die einzige Erklärung, die ich finden kann, ist, dass Linux ein Copy-on-Write-Betriebssystem ist. Wenn man aufruft, zeigen forkbeide Prozesse auf denselben physischen Speicher. Der Speicher wird nur kopiert, wenn ein Prozess tatsächlich in den Speicher schreibt.

Ich denke hier wird der tatsächliche physische Speicher nur zugewiesen, wenn man versucht, etwas darauf zu schreiben. Aufruf sbrkoder mmapmöglicherweise nur Aktualisierung der Speicherbuchhaltung des Kernels. Der tatsächliche RAM kann nur zugewiesen werden, wenn wir tatsächlich versuchen, auf den Speicher zuzugreifen.

Doron
quelle
forkhat damit nichts zu tun. Wenn Sie Linux mit diesem Programm booten, sehen Sie dasselbe Verhalten wie /sbin/init. (dh PID 1, der erste Prozess im Benutzermodus). Sie hatten jedoch die richtige allgemeine Idee mit Copy-on-Write: Bis Sie sie verschmutzen, werden alle neu zugewiesenen Seiten alle Copy-on-Write-Seiten derselben Seite mit Null zugeordnet.
Peter Cordes
Wenn ich etwas über Gabel wusste, konnte ich raten.
Doron