← Retour au blog Khalil Drissi

Pile contre tas : comment la mémoire fonctionne vraiment

Écouter l'article
0:00

Les gens parlent de “la pile” et du “tas” comme si c’étaient des objets physiques qu’on pourrait montrer du doigt. Ce n’en sont pas. Ce sont deux régions du même espace d’adressage virtuel, gérées de façons complètement différentes. J’ai écrit du C pendant des années avant d’intérioriser vraiment la différence, et une fois que ce fut fait, beaucoup de bugs déroutants ont soudain pris du sens.

La pile est une région, pas une structure de données

Quand votre programme démarre, le système d’exploitation donne à chaque thread un bloc de mémoire contiguë appelé la pile. Elle croît dans une direction, généralement vers le bas, vers les adresses basses sur x86 et ARM. Chaque fois que vous appelez une fonction, le processeur empile une trame : l’adresse de retour, les registres sauvegardés et la place pour les variables locales. Quand la fonction retourne, cette trame disparaît instantanément. Aucune comptabilité, aucune recherche, juste un ajustement de registre.

C’est pourquoi l’allocation sur la pile est rapide. Il y a un registre, le pointeur de pile, et “allouer” 64 octets revient à lui soustraire 64. Libérer revient à les rajouter. Le coût est quasi nul.

Le piège, c’est la durée de vie. Une variable de pile vit exactement le temps de l’appel de fonction qui l’a créée. Retournez un pointeur vers une locale et vous pointez vers une mémoire que le prochain appel va écraser.

// Ceci est un bug. Le tampon meurt quand la fonction retourne.
char *make_greeting(void) {
    char buffer[32];
    snprintf(buffer, sizeof buffer, "bonjour");
    return buffer;   // pointeur pendouillant
}

Le tas, pour ce qui survit à une trame

Le tas est le reste de votre espace d’adressage utilisable, et il est géré par un allocateur (malloc et compagnie) plutôt que par le processeur. Quand vous demandez de la mémoire, vous obtenez un bloc qui reste valide jusqu’à ce que vous le libériez explicitement. Cette souplesse est tout l’intérêt, et c’est aussi là que le travail se cache. L’allocateur doit suivre quels blocs sont libres, en trouver un assez grand et le rendre. J’ai écrit un article entier sur écrire un allocateur mémoire simple parce que cette mécanique vaut la peine d’être comprise directement.

char *make_greeting(void) {
    char *buffer = malloc(32);   // vit sur le tas
    if (!buffer) return NULL;
    snprintf(buffer, 32, "bonjour");
    return buffer;   // valide, mais l'appelant en est désormais propriétaire
}

Les compromis que vous ressentez vraiment

Pourquoi ceci touche à la performance

Le point sur la localité est celui qui mord dans les vrais systèmes. Un pointeur vers le tas est une valeur, et suivre ce pointeur en est une aussi. L’endroit physique où il atterrit décide si votre processeur cale. Je creuse cela dans conception orientée données et caches du processeur, mais en résumé, éparpiller vos données sur des allocations de tas peut être plus lent que l’algorithme ne le laisse croire, uniquement à cause des défauts de cache.

Un modèle mental qui tient

Voici comment je le vois maintenant. La pile est un brouillon que le processeur gère pour vous, parfait pour des valeurs courtes et de taille connue. Le tas est un entrepôt que vous gérez vous-même, pour tout ce dont la taille ou la durée de vie ne peut être fixée à la compilation. La plupart des bugs en C viennent de la confusion entre les deux : retourner des pointeurs de pile, libérer deux fois la mémoire du tas, ou oublier de la libérer.

Si des langages comme Rust paraissent sûrs, c’est qu’ils encodent ces règles dans le système de types pour que le compilateur attrape les erreurs. Pour voir comment cela fonctionne sans ramasse-miettes, lisez sûreté mémoire avec l’ownership et l’emprunt de Rust. Mais on ne peut vraiment apprécier ce contre quoi Rust vous protège qu’après avoir senti soi-même les angles tranchants de la pile et du tas.

Commentaires
Laisser un commentaire