Aller au contenu principal
genie-logicielLa gestion de la mémoire

La gestion de la mémoire

Ce que ce chapitre apporte

  • Distinguer allocation contiguë et allocation fragmentée.
  • Reconnaître fragmentation interne et fragmentation externe.
  • Comparer les algorithmes de placement premier, meilleur et pire ajustement.
  • Expliquer la pagination et la segmentation.
  • Comparer les algorithmes de remplacement de pages et compter les défauts.
  • Décrire le fonctionnement d'un ramasse-miettes générationnel.
  • Employer IDisposable et using pour les ressources non gérées.
Où on va
La mémoire physique est plus petite que la somme de ce que les programmes demandent, et pourtant tous s'exécutent. Ce chapitre explique comment le système y parvient : comment il place les processus, comment il découpe la mémoire pour éviter de la gaspiller, ce qu'il fait quand elle est pleine, et ce que la plateforme .NET automatise par-dessus tout cela.

Allouer de la mémoire à un processus

Définition

L'allocation est contiguë quand un processus occupe une zone d'un seul tenant, fragmentée quand il occupe plusieurs zones dispersées que le système présente comme continues.

L'allocation contiguë est simple à mettre en œuvre : une adresse de base et une taille suffisent à décrire l'espace d'un processus, et une addition suffit à traduire une adresse. Elle a un défaut décisif, qui apparaît dès que des processus se succèdent.

Les deux fragmentations

Définitions

La fragmentation interne est l'espace perdu à l'intérieur d'un bloc attribué, parce que le bloc est plus grand que ce qui était demandé.

La fragmentation externe est l'espace perdu entre les blocs : de la mémoire libre existe, mais en morceaux trop petits pour servir.

La fragmentation externe en pratique
Sur une mémoire de 1000 unités, trois processus occupent 300, 200 et 300 unités. Les deux extrêmes se terminent, libérant 300 unités au début et 300 à la fin, plus 200 restées libres.
Un processus de 500 unités se présente. La mémoire libre totale est de 800 unités, et pourtant il ne peut pas être placé : aucun bloc libre ne fait 500 d'un seul tenant.

Deux réponses existent. Le compactage déplace les processus pour regrouper l'espace libre, opération coûteuse qui suppose de suspendre tout le monde. Ou l'on abandonne la contiguïté, ce qui conduit à la pagination.

Les algorithmes de placement

Quand plusieurs blocs libres conviennent, lequel choisir ?

AlgorithmeChoixEffet
Premier ajustementle premier bloc assez grandrapide, fragmente le début de la mémoire
Meilleur ajustementle plus petit bloc qui convientlaisse de petits résidus inutilisables
Pire ajustementle plus grand bloc disponiblelaisse de gros résidus, épuise les grands blocs
main.py
Sortie
>_ Prêt à exécuter…
Le pire ajustement n'est pas absurde
Son nom prête à sourire, et son intention est raisonnable : en prenant le plus grand bloc, on laisse un résidu assez grand pour resservir, alors que le meilleur ajustement laisse des miettes inutilisables.
En pratique il épuise rapidement les grands blocs et échoue sur les grosses demandes, comme le montre le calcul. Le premier ajustement reste le plus employé, parce qu'il est rapide et que ses résultats sont proches du meilleur ajustement.

Pagination et segmentation

Pagination

La mémoire physique est découpée en cadres de taille fixe, et l'espace d'adressage de chaque processus en pages de même taille. Une table des pages associe chaque page à un cadre, sans contrainte d'ordre ni de contiguïté.

La pagination supprime la fragmentation externe : puisque toutes les pages font la même taille, n'importe quel cadre libre convient. Elle introduit en revanche un peu de fragmentation interne, la dernière page d'un processus étant rarement remplie.

Segmentation

L'espace d'un processus est découpé en segments de tailles variables, correspondant à des unités logiques : code, données, pile, tas.

La segmentation suit la structure du programme, ce qui facilite le partage et la protection : un segment de code peut être marqué en lecture seule et partagé entre plusieurs processus. Comme les segments ont des tailles variables, elle réintroduit la fragmentation externe.

Les systèmes actuels combinent les deux : une segmentation logique, dont chaque segment est paginé.

La pagination rend la mémoire virtuelle possible
Si l'espace d'un processus est découpé en pages indépendantes, rien n'oblige à les avoir toutes en mémoire en même temps. Le système ne charge que celles qui servent, et laisse les autres sur disque.
C'est ce qui permet d'exécuter un programme plus gros que la mémoire physique, et c'est la raison pour laquelle un défaut de page doit être traité efficacement.

Le remplacement de pages

Définition

Un défaut de page survient lorsqu'un processus accède à une page absente de la mémoire physique. Le système doit alors la charger depuis le disque, et si la mémoire est pleine, choisir une page à évincer.

Le choix de la victime détermine le nombre de défauts suivants, donc les performances.

AlgorithmeVictime choisie
Aléatoireune page au hasard
Premier entré premier sortila plus anciennement chargée
Moins récemment utiliséecelle dont le dernier accès est le plus ancien
Moins fréquemment utiliséecelle qui a été le moins souvent accédée
Non récemment utiliséeune page ni référencée ni modifiée récemment
main.py
Sortie
>_ Prêt à exécuter…
Ajouter des cadres n'améliore pas toujours les choses
L'intuition dit que plus de mémoire produit moins de défauts. Avec le premier entré premier sorti, ce n'est pas garanti : il existe des suites d'accès pour lesquelles ajouter un cadre augmente le nombre de défauts. Le phénomène porte le nom d'anomalie de Belady.
Les algorithmes fondés sur l'usage récent, comme le moins récemment utilisé, n'en souffrent pas. C'est un argument théorique en leur faveur, indépendamment des mesures.

La gestion automatique en .NET

Sur une plateforme managée, le programmeur n'alloue ni ne libère explicitement la mémoire des objets. Le ramasse-miettes s'en charge.

Principe

Le ramasse-miettes détermine quels objets sont encore accessibles depuis les racines du programme, c'est-à-dire les variables locales, les champs statiques et les registres. Ceux qui ne le sont plus sont récupérés.

L'accessibilité n'est pas l'utilité. Un objet référencé par une collection oubliée reste accessible, donc conservé, même si le programme ne s'en servira plus. C'est ainsi que des fuites existent malgré un ramasse-miettes.

Les générations

Le ramasse-miettes de .NET exploite une observation empirique : la plupart des objets meurent jeunes. Il classe donc les objets en trois générations.

GénérationContenuFréquence de collecte
0objets récemment allouéstrès fréquente, très rapide
1survivants d'une collecte de génération 0moins fréquente
2objets de longue durée de vierare, coûteuse

Une collecte de génération 0 ne parcourt qu'une petite zone et se termine en une fraction de milliseconde. Les objets qui survivent sont promus. Une collecte complète, qui inclut la génération 2, est bien plus coûteuse et suspend davantage l'exécution.

Le ramasse-miettes ne libère que la mémoire
Un fichier ouvert, une connexion réseau, un verrou, une poignée graphique ne sont pas de la mémoire. Ce sont des ressources du système, limitées en nombre, que le moteur d'exécution ne sait ni identifier ni libérer.
Un programme qui ouvre des milliers de fichiers sans les fermer épuise une limite du système et échoue, alors que sa consommation mémoire reste modeste. Le symptôme ne pointe pas vers sa cause.

IDisposable et using

La plateforme fournit une convention pour ces ressources : une classe qui en détient implémente IDisposable et expose une méthode Dispose qui les libère.

C#
public sealed class LecteurJournal : IDisposable
{
private readonly FileStream _flux;
public LecteurJournal(string chemin)
{
_flux = File.OpenRead(chemin);
}
public void Dispose()
{
_flux.Dispose(); // liberation explicite de la ressource
}
}
// Le bloc using appelle Dispose a la sortie, y compris si une exception survient.
using (var lecteur = new LecteurJournal("trace.log"))
{
// travail sur le fichier
}
// Forme abregee, portee jusqu'a la fin du bloc englobant
using var autre = new LecteurJournal("autre.log");
Pourquoi using plutôt qu'un appel manuel
Un appel à Dispose écrit à la fin d'une méthode n'est pas exécuté si une exception survient avant. La ressource reste alors ouverte, et le défaut n'apparaît que sous charge ou en cas d'erreur, c'est-à-dire au pire moment.
Le bloc using garantit l'appel dans tous les cas de sortie. La règle est simple : tout objet qui implémente IDisposable se manipule dans un using.

Exercices type

800 unités libres et un processus de 500 ne passe pas. Pourquoi ?

Fragmentation externe. L'espace libre existe, mais réparti en blocs dont aucun n'atteint 500 unités d'un seul tenant.

Deux réponses possibles. Le compactage déplace les processus pour regrouper l'espace libre, ce qui suppose de les suspendre et de recalculer leurs adresses, opération coûteuse.

Ou l'on abandonne l'exigence de contiguïté, ce qui conduit à la pagination : le processus occupe alors n'importe quels cadres libres, dispersés, et la table des pages se charge de l'illusion de continuité.

Quelle fragmentation la pagination supprime-t-elle, et laquelle introduit-elle ?

Elle supprime la fragmentation externe : toutes les pages ayant la même taille, n'importe quel cadre libre convient à n'importe quelle page. Il n'y a plus de trou trop petit.

Elle introduit de la fragmentation interne : la dernière page d'un processus est rarement pleine. Un processus de 10 240 octets avec des pages de 4 096 octets occupe trois cadres, dont le dernier n'est rempli qu'à moitié.

La perte moyenne est d'une demi-page par processus, ce qui est faible et prévisible. C'est un bon échange contre une fragmentation externe imprévisible.

Pourquoi le moins récemment utilisé fait-il mieux que le premier entré premier sorti ?

Parce qu'il s'appuie sur l'usage réel plutôt que sur l'ordre d'arrivée.

Les programmes présentent une localité temporelle : une page utilisée récemment a de bonnes chances de l'être à nouveau. Évincer la page la moins récemment utilisée revient donc à parier sur ce comportement, et le pari est généralement gagnant.

Le premier entré premier sorti évince la plus ancienne, qui peut très bien être la plus sollicitée depuis le début.

Le coût de LRU est réel : il faut mettre à jour une information à chaque accès, pas seulement à chaque défaut. Les systèmes emploient donc des approximations, comme l'algorithme de la seconde chance.

Le ramasse-miettes supprime-t-il tout risque de fuite ?

Non. Il récupère les objets inaccessibles, pas les objets inutiles.

Un objet référencé par une collection statique, un abonnement à un événement jamais résilié, un cache sans limite de taille : tous restent accessibles, donc conservés, et la mémoire croît.

L'abonnement à un événement est le cas le plus fréquent. Le sujet garde une référence vers l'abonné, qui n'est donc jamais récupéré tant qu'il ne s'est pas désabonné. Sur une interface qui crée et détruit des vues, la fuite s'accumule sans bruit.

Le remède n'est pas technique mais disciplinaire : se désabonner, borner les caches, et vider ce qui n'a plus lieu d'être.

Pourquoi trois générations plutôt qu'une collecte globale ?

Parce qu'une collecte globale devrait parcourir tous les objets vivants à chaque passage, ce qui coûte proportionnellement à la mémoire occupée.

L'observation empirique est que la plupart des objets meurent jeunes. Collecter fréquemment la seule génération 0, qui est petite, récupère l'essentiel des déchets pour un coût minime.

Les objets qui survivent sont promus et cessent d'être examinés à chaque passage. Une collecte complète, incluant la génération 2, reste possible mais devient rare.

Le gain porte surtout sur les pauses : une collecte de génération 0 se compte en fractions de milliseconde, une collecte complète peut se voir sur une interface.

Quand faut-il implémenter IDisposable ?

Dès qu'une classe détient une ressource que le ramasse-miettes ne sait pas libérer : fichier, connexion réseau ou base, verrou, poignée système, ou tout autre objet lui-même IDisposable.

La règle se propage. Une classe qui contient un champ IDisposable doit elle-même l'être, et libérer son champ dans son propre Dispose. Sans cela, la ressource reste captive de l'objet englobant.

Du côté de l'appelant, la contrepartie est systématique : tout objet IDisposable s'utilise dans un bloc using, qui garantit la libération même en cas d'exception.

La méthode

  1. Nomme la fragmentation dont tu parles : interne dans un bloc, externe entre les blocs.
  2. Compare les algorithmes de placement sur un cas, aucun n'est meilleur en général.
  3. Retiens que la pagination échange une fragmentation externe imprévisible contre une fragmentation interne bornée.
  4. Compte les défauts de page plutôt que de raisonner sur l'intuition, l'anomalie de Belady existe.
  5. Cherche l'accessibilité, pas l'utilité, pour comprendre une fuite en environnement managé.
  6. Implémente IDisposable dès qu'une ressource non mémoire est détenue, et utilise using systématiquement.

En résumé

  • Allocation contiguë simple mais sujette à la fragmentation externe ; fragmentée plus souple.
  • Fragmentation interne : perdue dans un bloc. Externe : perdue entre les blocs.
  • Premier, meilleur, pire ajustement : aucun n'est meilleur en général.
  • La pagination supprime la fragmentation externe et rend la mémoire virtuelle possible.
  • La segmentation suit la structure logique du programme et facilite partage et protection.
  • LRU exploite la localité temporelle et bat FIFO, au prix d'une mise à jour à chaque accès.
  • L'anomalie de Belady : avec FIFO, ajouter un cadre peut augmenter les défauts.
  • Le ramasse-miettes récupère l'inaccessible, pas l'inutile. Les générations exploitent le fait que les objets meurent jeunes.
  • IDisposable et using pour tout ce qui n'est pas de la mémoire.

Et ensuite ? Un processus peut contenir plusieurs fils d'exécution qui partagent la même mémoire. Ce partage crée des problèmes que le chapitre suivant traite : threads et synchronisation.

La gestion de la mémoire | Plateforme ETS