Pourquoi ce mangeur de mémoire ne mange-t-il pas vraiment de mémoire?

150

Je veux créer un programme qui simulera une situation de manque de mémoire (MOO) sur un serveur Unix. J'ai créé ce mangeur de mémoire super simple:

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

Il consomme autant de mémoire que défini dans memory_to_eatlequel se trouve maintenant exactement 50 Go de RAM. Il alloue de la mémoire de 1 Mo et imprime exactement le point où il ne parvient pas à en allouer davantage, de sorte que je sache quelle valeur maximale il a réussi à manger.

Le problème est que cela fonctionne. Même sur un système avec 1 Go de mémoire physique.

Lorsque je vérifie haut, je vois que le processus consomme 50 Go de mémoire virtuelle et seulement moins de 1 Mo de mémoire résidente. Existe-t-il un moyen de créer un mangeur de mémoire qui le consomme vraiment?

Spécifications du système: noyau Linux 3.16 ( Debian ) très probablement avec overcommit activé (je ne sais pas comment le vérifier) ​​sans swap et virtualisé.

Petr
la source
16
peut-être devez-vous réellement utiliser cette mémoire (c'est-à-dire y écrire)?
ms
4
Je ne pense pas que le compilateur l'optimise, si c'était vrai, il n'allouerait pas 50 Go de mémoire virtuelle.
Petr
18
@Magisch Je ne pense pas que ce soit le compilateur mais le système d'exploitation comme la copie sur écriture.
cadaniluk
4
Vous avez raison, j'ai essayé d'écrire dessus et je viens de bombarder ma boîte virtuelle ...
Petr
4
Le programme d'origine se comportera comme prévu si vous le faites en sysctl -w vm.overcommit_memory=2tant que root; voir mjmwired.net/kernel/Documentation/vm/overcommit-accounting . Notez que cela peut avoir d'autres conséquences; en particulier, des programmes très volumineux (par exemple votre navigateur Web) peuvent ne pas générer de programmes d'aide (par exemple le lecteur PDF).
zwol

Réponses:

221

Lorsque votre malloc()implémentation demande de la mémoire au noyau système (via un appel sbrk()ou un mmap()appel système), le noyau note uniquement que vous avez demandé la mémoire et où elle doit être placée dans votre espace d'adressage. Il ne mappe pas encore ces pages .

Lorsque le processus accède ultérieurement à la mémoire dans la nouvelle région, le matériel reconnaît une erreur de segmentation et alerte le noyau de la condition. Le noyau recherche alors la page dans ses propres structures de données, et trouve que vous devriez y avoir une page zéro, donc il mappe dans une page zéro (peut-être d'abord expulser une page du cache de page) et revient de l'interruption. Votre processus ne se rend pas compte que rien de tout cela s'est produit, le fonctionnement du noyau est parfaitement transparent (à l'exception du court délai pendant lequel le noyau fait son travail).

Cette optimisation permet à l'appel système de revenir très rapidement et, surtout, elle évite que des ressources soient engagées dans votre processus lorsque le mappage est effectué. Cela permet aux processus de réserver des tampons assez volumineux dont ils n'ont jamais besoin dans des circonstances normales, sans craindre d'absorber trop de mémoire.


Donc, si vous voulez programmer un mangeur de mémoire, vous devez absolument faire quelque chose avec la mémoire que vous allouez. Pour cela, il vous suffit d'ajouter une seule ligne à votre code:

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

Notez qu'il est parfaitement suffisant d'écrire sur un seul octet dans chaque page (qui contient 4096 octets sur X86). En effet, toutes les allocations de mémoire du noyau à un processus sont effectuées avec une granularité de page mémoire, ce qui est, à son tour, à cause du matériel qui ne permet pas la pagination à des granularités plus petites.

cmaster - réintégrer monica
la source
6
Il est également possible de valider la mémoire avec mmapet MAP_POPULATE(mais notez que la page de manuel indique que " MAP_POPULATE est pris en charge pour les mappages privés uniquement depuis Linux 2.6.23 ").
Toby Speight
2
C'est fondamentalement vrai, mais je pense que les pages sont toutes mappées copie sur écriture sur une page remise à zéro, plutôt que de ne pas être présentes du tout dans les tableaux de pages. C'est pourquoi vous devez écrire, et pas seulement lire, chaque page. En outre, une autre façon d'utiliser la mémoire physique consiste à verrouiller les pages. par exemple, appelez mlockall(MCL_FUTURE). (Cela nécessite la racine, car il ulimit -lne s'agit que de 64 ko pour les comptes d'utilisateurs sur une installation par défaut de Debian / Ubuntu.) Je viens de l'essayer sous Linux 3.19 avec le sysctl par défaut vm/overcommit_memory = 0, et les pages verrouillées utilisent la mémoire vive / swap physique.
Peter Cordes
2
@cad Bien que le X86-64 supporte deux tailles de page plus grandes (2 Mio et 1 Gio), elles sont toujours traitées de manière assez spéciale par le noyau Linux. Par exemple, ils ne sont utilisés que sur demande explicite, et uniquement si le système a été configuré pour les autoriser. En outre, la page de 4 kio reste la granularité à laquelle la mémoire peut être mappée. C'est pourquoi je ne pense pas que mentionner d'énormes pages ajoute quoi que ce soit à la réponse.
cmaster - réintégrer monica
1
@AlecTeal Oui, c'est vrai. C'est pourquoi, au moins sous Linux, il est plus probable qu'un processus qui consomme trop de mémoire soit abattu par le tueur de mémoire insuffisante que l'un de ses malloc()appels retourne null. C'est clairement l'inconvénient de cette approche de la gestion de la mémoire. Cependant, c'est déjà l'existence de mappages de copie sur écriture (pensez aux bibliothèques dynamiques et fork()) qui empêchent le noyau de savoir combien de mémoire sera réellement nécessaire. Donc, s'il ne surchargeait pas la mémoire, vous manqueriez de mémoire mappable bien avant d'utiliser réellement toute la mémoire physique.
cmaster - réintégrer monica
2
@BillBarth Pour le matériel, il n'y a aucune différence entre ce que vous appelleriez un défaut de page et un segfault. Le matériel ne voit qu'un accès qui enfreint les restrictions d'accès définies dans les tables de pages et signale cette condition au noyau via une erreur de segmentation. Ce n'est que le côté logiciel qui décide alors si l'erreur de segmentation doit être traitée en fournissant une page (mise à jour des tables de pages), ou si un SIGSEGVsignal doit être délivré au processus.
cmaster - réintégrer monica
28

Toutes les pages virtuelles commencent par copie sur écriture mappées sur la même page physique remise à zéro. Pour utiliser des pages physiques, vous pouvez les salir en écrivant quelque chose sur chaque page virtuelle.

Si vous exécutez en tant que root, vous pouvez utiliser mlock(2)ou mlockall(2)demander au noyau de câbler les pages lorsqu'elles sont allouées, sans avoir à les salir. (les utilisateurs normaux non root ont ulimit -lseulement 64 ko.)

Comme beaucoup d'autres l'ont suggéré, il semble que le noyau Linux n'alloue pas vraiment la mémoire à moins que vous n'écriviez dessus

Une version améliorée du code, qui fait ce que l'OP voulait:

Cela corrige également les incompatibilités de chaîne de format printf avec les types de memory_to_eat et eaten_memory, en utilisant %zipour imprimer des size_tentiers. La taille de la mémoire à manger, en kio, peut éventuellement être spécifiée sous forme d'argument de ligne de commande.

La conception désordonnée utilisant des variables globales et augmentant de 1k au lieu de 4k pages, reste inchangée.

#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
la source
Oui, vous avez raison, c'était la raison, pas sûr de la formation technique, mais cela a du sens. C'est bizarre cependant, que cela me permette d'allouer plus de mémoire que je ne peux réellement utiliser.
Petr
Je pense qu'au niveau du système d'exploitation, la mémoire n'est vraiment utilisée que lorsque vous y écrivez, ce qui est logique étant donné que le système d'exploitation ne garde pas un œil sur toute la mémoire que vous avez théoriquement, mais uniquement sur celle que vous utilisez réellement.
Magisch
@Petr mind Si je marque ma réponse comme wiki communautaire et que vous modifiez votre code pour une future lisibilité utilisateur?
Magisch
@Petr Ce n'est pas du tout bizarre. C'est ainsi que fonctionne la gestion de la mémoire sur les systèmes d'exploitation actuels. Un trait majeur des processus est qu'ils ont des espaces d'adressage distincts, ce qui est accompli en fournissant à chacun d'eux un espace d'adressage virtuel. x86-64 prend en charge 48 bits pour une adresse virtuelle, avec même des pages de 1 Go, donc, en théorie, quelques téraoctets de mémoire par processus sont possibles. Andrew Tanenbaum a écrit de très bons livres sur les systèmes d'exploitation. Si cela vous intéresse, lisez-les!
cadaniluk
1
Je n'utiliserais pas l'expression «fuite de mémoire évidente». Je ne crois pas que le sur-engagement ou cette technologie de «copie de mémoire à l'écriture» ait été inventé pour traiter les fuites de mémoire.
Petr
13

Une optimisation sensible est en cours ici. Le moteur d' exécution ne fait pas acquérir la mémoire jusqu'à ce que vous l' utilisez.

Un simple memcpysuffira pour contourner cette optimisation. (Vous constaterez peut-être que cela callocoptimise encore l'allocation de mémoire jusqu'au point d'utilisation.)

Bathsheba
la source
2
Êtes-vous sûr? Je pense que si son montant d'allocation atteint le maximum de mémoire virtuelle disponible, le malloc échouerait, quoi qu'il arrive . Comment malloc () saurait-il que personne n'utilisera la mémoire ?? Il ne peut pas, il doit donc appeler sbrk () ou tout autre équivalent dans son système d'exploitation.
Peter - Réintègre Monica
1
J'en suis presque sûr. (malloc ne sait pas mais le runtime le ferait certainement). C'est trivial à tester (bien que pas facile pour moi en ce moment: je suis dans un train).
Bathsheba
@Bathsheba Ecrire un octet sur chaque page suffirait-il également? En supposant mallocalloue sur les limites de la page ce qui me semble assez probable.
cadaniluk
2
@doron, aucun compilateur n'est impliqué ici. C'est le comportement du noyau Linux.
el.pescado
1
Je pense que la glibc calloctire parti de mmap (MAP_ANONYMOUS) donnant des pages à zéro, donc elle ne duplique pas le travail de mise à zéro de page du noyau.
Peter Cordes
6

Je ne suis pas sûr de celui-ci, mais la seule explication dont je peux parler est que linux est un système d'exploitation de copie sur écriture. Quand on appelle forkles deux processus pointent vers la même mémoire physique. La mémoire n'est copiée qu'une fois qu'un seul processus est réellement ÉCRIT dans la mémoire.

Je pense qu'ici, la mémoire physique réelle n'est allouée que lorsque l'on essaie d'y écrire quelque chose. L'appel sbrkou mmappeut bien ne mettre à jour que la comptabilité de la mémoire du noyau. La RAM réelle ne peut être allouée que lorsque nous essayons réellement d'accéder à la mémoire.

Doron
la source
forkn'a rien à voir avec ça. Vous verriez le même comportement si vous démarrez Linux avec ce programme en tant que /sbin/init. (c'est-à-dire PID 1, le premier processus en mode utilisateur). Cependant, vous aviez la bonne idée générale avec la copie sur écriture: jusqu'à ce que vous les salissiez, les pages nouvellement allouées sont toutes mappées en copie sur écriture sur la même page remise à zéro.
Peter Cordes
connaître la fourchette m'a permis de deviner.
doron