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.
Allouer de la mémoire à un processus
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
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.
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 ?
| Algorithme | Choix | Effet |
|---|---|---|
| Premier ajustement | le premier bloc assez grand | rapide, fragmente le début de la mémoire |
| Meilleur ajustement | le plus petit bloc qui convient | laisse de petits résidus inutilisables |
| Pire ajustement | le plus grand bloc disponible | laisse de gros résidus, épuise les grands blocs |
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
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.
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é.
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
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.
| Algorithme | Victime choisie |
|---|---|
| Aléatoire | une page au hasard |
| Premier entré premier sorti | la plus anciennement chargée |
| Moins récemment utilisée | celle dont le dernier accès est le plus ancien |
| Moins fréquemment utilisée | celle qui a été le moins souvent accédée |
| Non récemment utilisée | une page ni référencée ni modifiée récemment |
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.
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ération | Contenu | Fréquence de collecte |
|---|---|---|
| 0 | objets récemment alloués | très fréquente, très rapide |
| 1 | survivants d'une collecte de génération 0 | moins fréquente |
| 2 | objets de longue durée de vie | rare, 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.
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.
using plutôt qu'un appel manuelDispose é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
- Nomme la fragmentation dont tu parles : interne dans un bloc, externe entre les blocs.
- Compare les algorithmes de placement sur un cas, aucun n'est meilleur en général.
- Retiens que la pagination échange une fragmentation externe imprévisible contre une fragmentation interne bornée.
- Compte les défauts de page plutôt que de raisonner sur l'intuition, l'anomalie de Belady existe.
- Cherche l'accessibilité, pas l'utilité, pour comprendre une fuite en environnement managé.
- Implémente
IDisposabledès qu'une ressource non mémoire est détenue, et utiliseusingsysté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.
IDisposableetusingpour 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.