https://lafor.ge/feed.xml

Stack et Heap

2023-01-07

Lors du précédent article, nous avons exploré les lois qui régissaient le Drop des éléments dans le code. Je vous avais également relevé la relative supercherie de ce que l'on avait réalisé.

On drop bien des choses mais, dans les faits, rien ne se passait réellement.

Aujourd'hui, nous allons voir ce qui se déroule concrètement lors d'un drop.

Mais avant un peu de culture G sur la mémoire dans un ordinateur.

Mémoire physique et mémoire virtuelle

Un ordinateur marche dans son format actuel comme une usine avec un stock.

L'usine est le CPU, c'est lui qui effectue les tâches.

Le stockage est la mémoire et dans ce qui va nous intéresser par la suite la RAM ou mémoire vive.

missing alt

Si on rentre dans le détail de stockage, nous allons avoir des rayonnages, chaque rayonnage contient des emplacements.

Pour pouvoir identifier les rayonnages, on leur donne une adresse qui va de 0 à N.

Chaque emplacement est appelé un bit.

missing alt

Pour plus facilement se repérer dans l'entrepôt, on regroupe les rayons en des parties que l'on nomme des pages.

Si chaque rayon possède 64 emplacements, 64 bits. Et qu'une page fait 256 bits, alors une page fera 4 rayons.

missing alt

Si on essaie de continuer notre analogie avec l'usine, un ordinateur est constitué de chaînes de production distinctes qui ont un besoin d'isolation.

De fait, au lieu de laisser l'entrepôt en accès libre, on va subdiviser celui-ci en utilisant le groupement en pages.

Nous allons avoir N chaînes de productions qui vont avoir une zone de stockage dédiée.

Pour des raisons de facilité de communication, au lieu de dire à la chaîne de production 1 que ce qu'elle cherche est dans le rayon #12252525 emplacement #12. Nous allons simplement lui dire : rayon #10 emplacement #12.

De son point de vue, elle aura l'impression de pouvoir accéder à l'ensemble de l'entrepôt et d'être dans les premiers rayonnages alors que pas forcément.

Ses rayonnages peuvent même être dispersés dans l'entrepôt et donc le rayon #12 peut être au milieu de l'entrepôt et le rayon #13 à côté de la porte d'entrée.

Cette manière de faire à plusieurs avantages :

  • Chaque chaîne de production possède un stockage isolé des autres chaînes de production, donc pas de possibilité de piquer la marchandise d'un autre
  • L'entrepôt peut être virtuellement bien plus grand ou petit que dans la réalité, mais ça la chaîne de production ne le sait pas
  • Comme tout est virtuel, l'entrepôt virtuel peut-être réagencé et même composé de plusieurs entrepôts physiques différents

Dans notre analogie, une chaîne de production est un programme, ce qu'il voit comme entrepôt est appelé la mémoire virtuelle, l'entrepôt physique est la mémoire physque, elle est généralement composée de barrettes de RAM, mais cela peut aussi être du disque dur ou autre support de stockage.

Chaque rayon est appelé une adresse mémoire.

Les pages seront alors à la demande, attribuées aux différents programmes.

Des pages peuvent tout à fait, n'être attribuées à aucun programme.

Ici le programme 1 possède les pages 1 et 4 qui sont vues respectivement de sa part comme étant les pages 1 et 2.

Le programme 2, ici n'a qu'une unique page 3 qui est vu de sa part comme la page 1.

La page 2 est inutilisée.

missing alt

 

On peut faire la différence en les pages physiques et les pages virtuelles, les pages physiques sont appelées des frames et les pages virtuelles des pages.

Je ne ferai pas le distinguo ici, mais sachez que ça existe.

Si le programme 2 vient à venir en manque de place dans son entrepôt virtuel, il est tout à fait possible de lui allouer plus de place dans la mémoire physique.

La page 2 de la mémoire physique devient alors allouée au programme 2.

De son point de vue la mémoire a grandi.

missing alt

Nous avons donc des programmes qui possèdent un certain nombre de pages.

missing alt

Ces pages sont alors fusionnées pour former une zone une mémoire unifiée du point de vue de programme.

missing alt

Segmentation de la mémoire virtuelle

Maintenant que chaque programme a sa propre zone mémoire où il peut y faire ce qu'il désire sans que cela gêne les autres programmes qui tournent à côté de lui, nous allons voir comment chaque programme organise sa mémoire de travail.

Si vous regardez des tutos sur le sujet, vous allez tomber sur ce genre de schémas.

missing alt

Décortiquons un peu ce que l'on voit.

Tout d'abord, l'adresse la plus petite est la 0. Et le MAX diffère selon la plateforme, mais c'est un très très très grand nombre.

Ensuite en partant de bas en haut. On voit des sections, ces sections sont appelées des segments, et ces segments ont des petits noms :

  • Code : Il s'agit du fameux code assembleur/machine, c'est comment le programme doit tourner
  • Statique : Ce sont des informations qui sont stockées dès le début du programme et qui ne sont pas modifiables par la suite, on appelle ça de la mémoire read-only, on y retrouve les constantes, les variables statiques et un certain nombres d'autres choses.
  • Tas : La mémoire dynamique, tout ce qui doit évoluer au cours du programme et qui ne sera pas dans la Pile sera stocké dans le Tas
  • Libre : Une zone pas encore allouée, celle-ci peut correspondre à des pages virtuelles ou non.
  • Pile : La mémoire de travail du programme, on va voir son fonctionnement en détail

Le Tas possède une flèche vers le haut ce qui signifie que l'on monte dans les adresses quand on parcourt ce segment de mémoire.

Contrairement à la Pile qui est parcourue de haut en bas.

Ce n'est pas vrai pour toutes les architectures mais généralement, c'est ainsi fait pour des raisons de sécurité. En effet, depuis le tas, la première adresse est supérieure à 0 et comme on croît dans les adresses, il est impossible de pouvoir modifier durant l'exécution du programme, le segment Code qui est plus bas.

Pareillement, même si la Pile descend en adresse, elle ne peut pas descendre dans le segment du Tas, vous aurez le fameux StackOverflow bien avant. 😀

De fait, cette manière de procéder assure deux choses :

  • On ne peut pas modifier par inadvertance ou sciemment un programme qui tourne.
  • On s'aménage un espace libre qui peut être utilisé au besoin

Il existe d'autres segments mémoires mais, je n'en parlerai pas ici.

Ils servent à diverses tâches, comme discuter avec l'extérieur, le système d'exploitation et d'autres choses.

Stack : Empile et dépile

Bien voyons comment marche le segment mémoire Pile.

On appelle ça une pile, car comme pour une pile d'assiettes, pour atteindre celle du dessous, il faut enlever celle du dessus avant.

Cette Pile est elle-même décomposée en frames. Je n'ai pas trouvé de désignation française du terme.

Les frames seront nos assiettes dans la pile d'assiettes.

missing alt

Frames

Prenons le bout de code suivant.

Le curseur rouge correspond au moment d'exécution du programme.

Ici, nous sommes avant tout, même l'appel de la méthode main.

missing alt

Le curseur se déplace d'un cran vers le bas.

On va push une frame 0 sur la pile.

missing alt

On rentre dans la méthode main. On réalise ce que l'on appelle un switch de contexte.

On va alors marquer la position du curseur avant l'entré dans le main, ça nous permettra de revenir à cette position plus tard.

Sur le dessin, ceci est symbolisé par un curseur vert numéroté d'un 0.

Nous sommes désormais dans la méthode main.

missing alt

On continue l'exécution, nouvelle fonction f1, on push une nouvelle frame 1.

missing alt

Nouveau switch de contexte cette fois-ci vers f1. On mémorise la position du curseur par le curseur vert numéroté d'un 1.

missing alt

Même principe.

On continue l'exécution, nouvelle fonction f2, on push une nouvelle frame 2.

missing alt

Nouveau switch de contexte cette fois-ci vers f2. On mémorise la position du curseur par le curseur vert numéroté d'un 2.

missing alt

La fonction f2 fini, on effectue un retour de contexte.

missing alt

Et comme on a bien pris garde à baliser notre chemin qui a semé ses miettes de pain dans la forêt.

Il est facile de remonter à la position du curseur avant l'exécution de f2.

On remet alors le curseur rouge à son ancienne position. Et on dépile la frame 2, on nomme ça un pop.

À partir de ce moment, la frame et tout ce qu'elle contenait n'existe plus.

missing alt

f1 fini son tour, retour de contexte vers main.

missing alt

Puis pop de la frame 1.

missing alt

L'exécution continue, appel de la méthode f2, donc push de la frame 1.

missing alt

Switch de contexte vers f2.

missing alt

Retour de contexte vers main à l'issue de l'exécution de f2

missing alt

Pop de la frame 1.

missing alt

Fin d'exécution du main. Retour de contexte vers ROOT.

missing alt

Pop de frame 0.

missing alt

Et fin d'exécution du programme.

missing alt

Durant ce petit ballet, nous avons empilé des frames et dépilé des frames mais nous n'avons rien mis dedans, il est temps de remédier à cela.

Affectation et libération

On est reparti !

Au début du programme, pas de frame.

missing alt

On rentre dans le main, on push la frame 0.

missing alt

On effectue le switch de contexte vers main

missing alt

Le curseur d'exécution atteint l'affection de a = 1.

On vient alors stocker dans la frame 0, la valeur de a.

missing alt

Pareil pour b = 3.

missing alt

On appel f1.

Push de la frame 1.

missing alt

Switch de contexte vers f1. Les valeurs de a et b de la méthode main restent stockées dans la frame 0.

missing alt

Ici aussi, on a également une affectation de a = 4. Mais ce n'est pas le même a, il ne s'agit pas du même contexte et par conséquent on peut avoir deux a dans deux contextes différents qui sont stockés dans deux frames différentes.

missing alt

On appel f2, push de la frame 2.

missing alt

Switch de contexte vers f2.

missing alt

On affecte b = 8 qui est stocké dans la frame 2.

missing alt

On arrive alors à la fin d'exécution de f2.

On effectue le retour de contexte vers f1.

missing alt

Puis le pop de la frame 2 avec tout ce qu'elle contient, et donc également b = 8.

missing alt

Puis on remonte dans l'exécution de notre pile.

missing alt

On pop la frame 1. Et le a = 4.

missing alt

Et on continue à refermer ce qu'on avait ouvert.

missing alt

Pour finalement nettoyer toute trace de notre passage en libérant a = 1 et b = 3 au pop de la frame 0.

missing alt

On fini alors l'exécution.

missing alt

Ce qui est à comprendre ici est que ce qui est stocké dans une frame sera automatiquement nettoyé dès la fin de l'exécution du contexte.

Lors de notre article sur le Drop, j'ai utilisé une structure Toto.

struct Toto;

Sauf qu'un code comme

fn f1(toto: Toto) {

}

fn main() {
    let toto = Toto;
    f1(toto);
}

Dans les faits rien ne se passe réellement, car de toute manière à l'issue de la fin d'exécution de f1 toto sera nettoyé avec la frame du contexte de f1. Il y a un risque nul de fuite mémoire.

Heap : On stocke en vrac

La Heap ou Tas est appelée ainsi, car contrairement à la Pile où il y avait un relatif ordre. Le Tas est organisé de manière anarchique, on y stocke ce que l'on a besoin dès qu'il y a la place pour y stocker quelque chose.

Si l'on reprend notre schéma de segmentation de la mémoire, nous allons nous retrouver avec ceci.

missing alt

Des données, stockées pêle-mêle, pas forcément ordonnées dans leur ordre d'arrivée

String : vivre entre la Heap et la Stack

Un exemple de chose que l'on stocke dans le tas, sont les chaînes de caractères.

En Rust, il existe un type appelé String qui est une structure qui possède 3 champs principaux :

  • la longueur de la chaîne de caractères
  • la capacité de stockage actuel
  • une référence vers la chaîne de caractères elle-même

Un article sera dédié aux chaînes de caractères tellement c'est complexe comme sujet mais, ce qu'il faut retenir c'est qu'il s'agit de nombre qui sont stockés et interprétés par la suite comme des lettres et des chiffres ou tout autre caractère.

Ce tableau de nombre est stocké dans la Heap tandis que la structure vit dans une frame et donc dans la Stack.

Ici ce tableau est composé de 5 cases, car notre String a une capacité de 5 et une longueur de 4, car on utilise réellement 4 cases pour écrire t·o·t·o.

La dernière case est non-définie mais allouée à notre String. Autrement dit, personne d'autre ne peut lui voler cette case, même si elle est inutilisée.

missing alt

Allouer dans la Heap

Voyons ce qu'il se passe lors de la création d'un String.

La première chose qui est faite est de créer la structure String, pour cela, on compte le nombre de cases qu'il faut pour stocker ce que l'on a besoin et on rajoute un petit extra. Ici, on a une longueur de 4, mais une capacité de 5.

On vient alors allouer 5 cases dans la Heap. Ces cases sont désormais à la String s ! Personne d'autre ne pourra les récupérer.

Lorsque l'allocation est terminée, l'adresse du début de bloc alloué est retournée ce qui permet alors à notre structure String s d'y faire référence par la suite.

missing alt

 

La manière de définir cette capacité est un algorithme qui peut varier.

Pour des raisons de lisibilité sur le dessin, je me limite à 1 case supplémentaire mais il est probable que dans la réalité, on alloue plutôt le double de la longueur histoire d'être tranquille.

On peut alors y écrire les nombres qui représentent t·o·t·o.

missing alt

Lorsque l'on veut modifier s, ici par exemple concaténer avec la chaîne "2".

On vérifie la taille de ce qui doit être ajouté, si celle-ci ne dépasse pas la capacité restante alors on modifie les cases mémoires dans la heap.

missing alt

Et l'on fait évoluer la nouvelle longueur à 5.

La capacité reste inchangée.

missing alt

C'est maintenant que l'on raccroche les wagons avec l'article sur le Drop.

A l'issue de la méthode main, suivant les règles du borrow checker, comme plus aucune référence n'est valide alors il doit être drop.

missing alt

Ce signal Drop a pour conséquence de déclencher le mécanisme de libération de la zone mémoire allouée dans la heap précédemment pour stocker nos 5 caractères.

C'est donc ce que l'on va demander :

Libère les 5 cases mémoire que tu m'avais prêtées précédemment

Ce qui va alors se passer, c'est que l'on ne va pas effacer les données, ça pourrait être bien trop coûteux en temps en fonction de la taille de ce qui a été alloué (parfois plusieurs Giga octets).

A la place, on va marquer ces cases comme étant "libres". On nomme ça un tombstone ou pierre tombale en anglais.

Cela signifie que les données qui sont présentes à ces adresses ne veulent plus rien dire et peuvent être réécrites par une autre allocation.

missing alt

On termine l'exécution du programme en poppant la frame de main. Ce qui nettoie la variable s mais comme le Drop a déjà libéré la mémoire, pas non plus de risque de fuite mémoire.

Tout s'est passé automatiquement et le développeur ou la développeuse n'a jamais eu besoin d'y penser.

On manipule la chaîne de caractères, puis quand on en a plus besoin le borrow checker fait le travail à notre place et nettoie ce qu'il y a besoin de nettoyer. 🤩

missing alt

Réallocation : Ça dépend, ça dépasse

On reprend la même situation après allocation de "toto".

missing alt

Cette fois-ci la longueur ce que le l'on veut rajouter est de 2. Ce qui excède la capacité de 5.

Nous allons avoir besoin de réallouer de la place supplémentaire.

On rajoute alors plusieurs cases, ici 2 de plus.

missing alt

La nouvelle capacité devient 7 et la longueur devient 6.

missing alt

Allouons deux String, s1 et s2.

Le hasard de l'allocation, va localiser côte à côte les deux chaînes de caractères.

missing alt

Là aussi pas de place pour stocker le "toto23". On doit réallouer.

Mais cette réallocation est différente, on ne peut pas juste rajouter des blocs.

On doit tout réallouer ailleurs, là où il y a de la place.

missing alt

On peut alors copier les valeurs des blocs précédents dans les nouveaux blocs alloués.

missing alt

On peut alors modifier la zone mémoire pointée.

Modifier la capacité.

missing alt

Libérer l'ancienne zone allouée.

missing alt

On peut finalement faire que l'on désirait, et écrire "toto23".

missing alt

Tant qu'il y a de la place on peut toujours réallouer.

Référence chaînée

Nouvelle situation.

Cette fois-ci, la fonction f1 prend une référence de String.

En Rust, le caractère *, permet de déréférencer. Autrement dit, remonter la piste de la référence.

Un mécanisme au sein de la structure String, permet d'automatiquement déréférencer vers la zone mémoire de la heap qui permet de stocker la chaîne de caractères.

missing alt

La ligne

*s_ref += "23"

est identique en action à

s += "23"
missing alt

De fait, on a le même système de réallocation de mémoire.

missing alt

Puis d'affection des nouvelles cases.

missing alt

En fin de fonction f1, le borrow se termine mais comme la variable s vit toujours dans main.

Il n'y aura pas de Drop à lissue de f1.

missing alt

A lissue de f1, on pop la frame 1 contenant la s_ref.

Mais sans drop.

missing alt
missing alt

Par contre à l'issue de main.

missing alt

Le mécanisme de Drop se déclenche.

missing alt

Puis finalement, on peut libérer la zone de la heap allouée pour s.

missing alt

Retour de fonction

On va complexifier un peu les choses. 😁

On crée une structure Person qui possède 2 champs:

  • age un u8
  • name un String

On défini également une méthode with_name qui un &Person, un &str et renvoie une Person.

struct Person {
    name: String,
    age: u8
}

impl Person {
    fn with_name(&self, name: &str) -> Person {
        Person {
            name: name.to_string(),
            age: self.age
        }
    }
}

Nous nous retrouvons dans la situation où une Person a déjà été créée.

missing alt

On a alors une zone dans la frame de la pile qui contient à la fois la String et le u8.

Et dans la heap, la chaîne de caractères allouée pour s.name.

missing alt

On avance un peu dans le temps, on atteint l'appel à la méthode Person::with_name.

On lui fourni à la fois une reférence vers p, mais aussi une chaîne de caractères.

missing alt

Dans la frame 1 de Person::with_name, nous nous retrouvons donc avec:

Une reference à p sous la dénomination self, p qui vit dans la frame 0 de main.

Une référence name qui pointe ailleurs que dans la heap ou sur la frame 0. Cet "ailleurs" est le segment de mémoire Statique.

Il s'agit d'une zone spéciale de la mémoire qui est allouée automatiquement au début du programme et qui ne peut pas évoluer au cours de celui-ci. De plus ce qui est dedans vit tout au long du programme et n'est jamais libéré.

name lui-même est spécial, ce n'est pas seulement une référence, c'est ce que l'on nomme une slice. C'est une référence qui est au courant de la taille de ce qu'elle référence. Généralement un tableau. Cela tombe bien, une chaîne de caractères est un tableau ! 😄

Ici notre &str pointe vers une chaîne de 4 cases.

missing alt

Lors de l'exécution de la fonction with_name, il y a besoin de créer un nouveau Person.

Comme on veut potentiellement modifier la variable name, on va créer un nouveau String pour l'accueillir.

missing alt

Cela implique d'allouer de la place dans la heap.

missing alt

Puis de copier de la mémoire statique vers la zone de la heap allouée.

missing alt

En fin de méthode with_name, on devrait drop le Person créé, mais comme la signature est :

fn with_name(&self, name: &str) -> Person;

Et que p2 est là pour récupérer la Person créée.

On retourne le Person et comme tout va brûler dès que la frame sera pop.

missing alt

On move dans p2 qui vit dans la frame 0 la Person créée.

missing alt

A l'issu du move, on alors la frame 0, qui contient p2. Une personne qui a le même âge que p mais un nom différent. Et donc une zone mémoire dans la heap qui lui est propre.

missing alt

A l'issue de Person::with_name, on pop sa frame 1.

missing alt
missing alt

Puis on termine l'exécution du main.

missing alt

Cela déclenche les drop de p et p2. Et par voie de conséquence la libération des zones mémoire dans la heap qui appartenaient aux deux structures.

missing alt

Une fois sortie du main, la frame 0 est pop.

missing alt
missing alt

Seul "tata" dans la mémoire statique résiste fièrement, du moins jusqu'à ce que le programme se coupe et que les pages de la mémoire virtuelle ne soit allouée à un autre programme!

Accroissement de la Heap et de la Stack

Un mot sur l'augmentation de taille des segments mémoire.

Tout d'abord la pile, chaque taille de frame est connue à la compilation ce qui fait que chaque contexte à toujours ce qu'il lui faut comme mémoire de travail.

La seule limitation est le StackOverflow qui comme son nom l'indique, dit qu'on a trop empilé, autrement dit on est descendu trop bas, jusqu'à atteindre une limite infranchissable. Le programme est alors instantanément tué ou si le langage le permet on dépile jusqu'à ne plus être dans une situation gênante ! 🙄

Pour le tas, maintenant, le tas n'a pas de taille connue, il croît au besoin et peut même décroître.

Vous vous rappelez quand je disais qu'on pouvait rajouter des pages virtuelles à un programme qui tourne. Et bien c'est maintenant !

Pour faire grandir le tas, il suffit de modifier l'adresse de fin du tas. Comme tout ce qui a été déjà alloué a une adresse plus faible, aucune réallocation n'est nécessaire, autrement dit l'adresse ne change pas ( et heureusement 😅).

Et du côté de la pile non plus. Pourquoi et bien la première adresse de la pile reste toujours le MAX. Donc là non plus rien ne bouge.

Ce qui bouge c'est l'addresse de fin du tas qui devient plus grande, mais comme ce qui a été rajouté faisait parti de l'espace Libre, nous avons la certitude que rien n'avait besoin d'être bougé !

missing alt

Enfin, si le tas devient trop grand, plus grand que la mémoire virtuelle qu'on peut lui fournir, le système d'exploitation a son réseau d'assassins, l'OOM Killer qui tue le programme pour sauver le reste du système.

(parfois c'est pas toujours le programme en cause qui est tué 😭).

Cette erreur se nomme un Out of Memory.

C'est ce qui tuait le programme en C de l'article précédent.

Conclusion

Pour travailler un programme à besoin de mémoire de travail et de stockage. Il la récupère du système d'exploitation sous la forme d'une mémoire virtuelle qui est réservée.

Le programme subdivise alors cette mémoire virtuelle en segments qu'il utilise à sa guise.

Les contextes d'appels de fonctions se retrouvent dans la pile qui grandit avec la profondeur d'appels de fonctions.

Tout ce système est entièrement automatisé, y compris la libération de la mémoire à la fin de l'exécution d'une fonction.

Ce qui ne vit pas dans la pile, vit dans le tas. Le tas est beaucoup moins automatisé et nécessite qu'on libère explicitement la mémoire après usage.

Heureusement en Rust, nous avons le borrow checker qui est capable de déclencher lorsque c'est nécessaire les libérations de la mémoire dans le tas.

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.