Stack et Heap

Lecture 24 min. ‱


Table des matiĂšres

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.

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.

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.

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 :

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.

 

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.

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

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

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.

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 :

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 :

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.

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.

Le curseur se dĂ©place d’un cran vers le bas.

On va push une frame 0 sur la pile.

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.

On continue l’exĂ©cution, nouvelle fonction f1, on push une nouvelle frame 1.

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.

MĂȘme principe.

On continue l’exĂ©cution, nouvelle fonction f2, on push une nouvelle frame 2.

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.

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

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.

f1 fini son tour, retour de contexte vers main.

Puis pop de la frame 1.

L’exĂ©cution continue, appel de la mĂ©thode f2, donc push de la frame 1.

Switch de contexte vers f2.

Retour de contexte vers main Ă  l’issue de l’exĂ©cution de f2

Pop de la frame 1.

Fin d’exĂ©cution du main. Retour de contexte vers ROOT.

Pop de frame 0.

Et fin d’exĂ©cution du programme.

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.

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

On effectue le switch de contexte vers main

Le curseur d’exĂ©cution atteint l’affection de a = 1.

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

Pareil pour b = 3.

On appel f1.

Push de la frame 1.

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

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.

On appel f2, push de la frame 2.

Switch de contexte vers f2.

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

On arrive alors Ă  la fin d’exĂ©cution de f2.

On effectue le retour de contexte vers f1.

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

Puis on remonte dans l’exĂ©cution de notre pile.

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

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

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

On fini alors l’exĂ©cution.

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.

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 :

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.

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.

 

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.

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.

Et l’on fait Ă©voluer la nouvelle longueur Ă  5.

La capacité reste inchangée.

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.

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.

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. đŸ€©

RĂ©allocation : Ça dĂ©pend, ça dĂ©passe

On reprend la mĂȘme situation aprĂšs allocation de “toto”.

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.

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

Allouons deux String, s1 et s2.

Le hasard de l’allocation, va localiser cîte à cîte les deux chaünes de caractùres.

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.

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

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

Modifier la capacité.

LibĂ©rer l’ancienne zone allouĂ©e.

On peut finalement faire que l’on dĂ©sirait, et Ă©crire “toto23”.

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.

La ligne

*s_ref += "23"

est identique en action Ă 

s += "23"

De fait, on a le mĂȘme systĂšme de rĂ©allocation de mĂ©moire.

Puis d’affection des nouvelles cases.

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.

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

Mais sans drop.

Par contre à l’issue de main.

Le mécanisme de Drop se déclenche.

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

Retour de fonction

On va complexifier un peu les choses. 😁

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

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.

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.

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.

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.

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.

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

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

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.

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

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.

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

Puis on termine l’exĂ©cution du main.

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.

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

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Ă© !

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.