Aller au contenu principal

Processus, espaces d’adressage et mémoire

Un même programme peut s’exécuter deux fois en même temps, avec des valeurs de variables différentes dans chaque exécution. Pour comprendre pourquoi, il faut distinguer l’état d’exécution, les adresses que voit le programme et la mémoire réellement utilisée par la machine. Cette distinction explique aussi pourquoi redémarrer un noyau de notebook fait perdre les variables, alors qu’imposer une limite mémoire à un conteneur répond à un autre problème.

Programme, processus, thread et système d’exploitation​

Le chapitre Processes d’OSTEP définit un processus comme un programme en cours d’exécution : ses instructions, mais aussi son état courant et ses ressources ouvertes. Le chapitre Concurrency: An Introduction distingue le processus de ses threads.

ObjetCe qu’il représenteCe qu’il partage
ProgrammeDes instructions et des données statiques prêtes à être exécutéesPlusieurs processus peuvent exécuter le même programme
ProcessusLes ressources et l’état d’une exécution, dont un espace d’adressage, des threads et des fichiers ouvertsLes processus d’une machine utilisent ensemble son CPU et sa mémoire physique
ThreadUn fil d’exécution dans un processus, avec sa propre position dans les instructions, ses registres et sa pileLes threads d’un processus partagent son espace d’adressage et accèdent aux mêmes données du tas

Chaque thread a sa pile pour suivre ses appels de fonctions. Ces piles restent dans le même espace d’adressage : elles ne constituent pas des barrières d’isolation mémoire entre threads. Les modifications concurrentes de données partagées doivent être coordonnées, sinon l’ordre d’exécution peut changer le résultat.

Le système d’exploitation ordonnance l’exécution, gère la mémoire et fournit des services comme les fichiers. Son noyau s’exécute avec des privilèges ; les applications ordinaires lui demandent des services par des appels système. L’introduction aux systèmes d’exploitation d’OSTEP explique le passage contrôlé entre mode utilisateur et mode noyau. Le « noyau » décrit dans Architecture de Jupyter est, lui, un processus utilisateur qui exécute du Python ou du R, distinct du noyau du système d’exploitation.

De la création à la fin du processus​

Pour les interfaces Unix, le chapitre Process API sépare plusieurs opérations :

  1. fork() crée un processus enfant. Parent et enfant poursuivent leur exécution au retour de l’appel, avec des identités de processus et des états mémoire privés distincts.
  2. exec() remplace l’image du programme dans le processus courant par un autre programme, en reconstruisant code, données, tas et pile. En cas de succès, l’exécution ne revient jamais à l’ancien programme ; exec() ne crée pas lui-même de processus.
  3. Le parent peut attendre la fin de l’enfant avec wait() ou waitpid(). L’attente bloquante suspend le thread appelant ; les autres threads du processus peuvent continuer à s’exécuter. Si l’enfant est déjà terminé et que son état n’a pas encore été récupéré, l’appel peut revenir immédiatement. Un signal ou une erreur peut aussi mettre fin à l’attente : le retour de l’appel ne signifie donc pas toujours que l’enfant est terminé.
  4. Un enfant qui se termine cesse de s’exécuter. Unix conserve normalement un petit enregistrement de sa terminaison, que le parent récupère avec wait() ou waitpid(). Un processus terminé dont cet enregistrement n’a pas encore été récupéré est un zombie. Le manuel Linux de wait() précise aussi qu’ignorer explicitement SIGCHLD ou définir SA_NOCLDWAIT empêche les enfants terminés de devenir des zombies.

Le chapitre Processes explique l’exécution à l’aide de trois états : un processus prêt peut s’exécuter mais attend du temps CPU ; un processus en cours d’exécution utilise le CPU ; un processus bloqué attend un événement, par exemple une entrée-sortie. L’ordonnanceur peut retirer une tâche du CPU pour en exécuter une autre qui est prête. L’ordre du code source ne suffit pas à prévoir lequel des deux processus s’exécutera en premier.

Espaces d’adressage, piles et tas​

Dans Address Spaces, l’espace d’adressage est la vue qu’un processus a des adresses mémoire et de leurs correspondances. Un pointeur contient généralement une adresse virtuelle. La même valeur numérique dans deux processus peut désigner des emplacements différents en mémoire physique. Un espace d’adressage privé isole les accès ; il n’attribue pas à chaque processus un bloc entier de RAM qui lui serait exclusivement réservé.

Cet espace contient le code, les données statiques, les piles et le tas ; il peut aussi contenir des fichiers projetés en mémoire et des régions partagées. Une pile conserve les cadres d’appel, notamment les points de retour et l’état local ; le tas accueille les données allouées dynamiquement. Memory API distingue deux niveaux de gestion : l’allocateur gère les blocs à l’intérieur du processus, tandis que le système d’exploitation gère ses correspondances mémoire. malloc() est un appel de bibliothèque qui peut réutiliser de l’espace existant au lieu de demander de la mémoire au noyau à chaque appel. Libérer la mémoire d’un objet ne rend pas nécessairement ses pages au système immédiatement ; par exemple, la glibc restitue l’espace libre au sommet du tas selon les conditions de seuil décrites dans le manuel de mallopt().

Deux processus peuvent choisir de projeter une même région de mémoire partagée. Le manuel Linux de mmap() précise, par exemple, que les modifications d’une projection MAP_SHARED sont visibles par les autres processus qui projettent cette région. Les espaces d’adressage restent distincts, mais certaines correspondances désignent un stockage commun. Les écritures partagées doivent toujours être coordonnées.

Mémoire virtuelle, pagination et défauts de page​

La mémoire virtuelle dissocie l’espace d’adressage de la mémoire physique. Address Translation explique la répartition du travail : le système établit les correspondances et les permissions d’accès, puis l’unité matérielle de gestion de mémoire (MMU) traduit les adresses lors des accès.

Paging divise l’espace d’adressage virtuel en pages de taille fixe et la mémoire physique en cadres de page de même taille. Une table des pages indique le cadre physique correspondant à une page virtuelle et ses conditions d’accès. Une plage d’adresses virtuelles contiguës peut donc occuper des cadres physiques non contigus.

Calculer une traduction d’adresse​

Prenons un modèle pédagogique avec des pages de 4096 octets, soit 4 KiB. Il s’agit d’un paramètre choisi pour le modèle, pas de la taille des pages de la machine locale. En divisant l’adresse virtuelle 12345 par la taille d’une page, on obtient le numéro de page virtuelle 3 et un reste de 57, le déplacement dans la page. Si le processus A fait correspondre la page 3 au cadre 7, l’adresse physique vaut 7 * 4096 + 57 = 28729. Si le processus B la fait correspondre au cadre 19, la même adresse virtuelle devient 77881.

Le code calcule aussi l’espace nécessaire à un tampon de 10000 octets qui commence à une frontière de page et occupe seul les pages requises. Il faut 3 pages, soit une capacité de 12288 octets, avec 2288 octets inutilisés dans la dernière page. À exécuter avec Python 3 :

page_size = 4096
va = 12345
vpn, offset = divmod(va, page_size)
print(f"VPN={vpn}, offset={offset}")
for process, frame in [("A", 7), ("B", 19)]:
print(f"{process}: physical address={frame * page_size + offset}")
size = 10000
pages = (size + page_size - 1) // page_size
print(f"buffer: pages={pages}, capacity={pages * page_size}, slack={pages * page_size - size}")
print(f"two resident pages: {2 * page_size} bytes")

Sortie :

VPN=3, offset=57
A: physical address=28729
B: physical address=77881
buffer: pages=3, capacity=12288, slack=2288
two resident pages: 8192 bytes

Si seules deux pages du tampon résident en RAM, ces données résidentes occupent 8192 octets. Ici, 10000 est la quantité de données demandée, 12288 la capacité de la région projetée dans le modèle, et 8192 sa taille résidente supposée. Le calcul ne compte ni la table des pages ni les surcoûts de l’allocateur. Il ne mesure pas non plus la mémoire de véritables objets Python. Quantité demandée, espace virtuel projeté et mémoire physique résidente répondent à des questions différentes.

Lorsqu’un défaut de page se produit​

L’accès à une page valide mais non résidente déclenche un défaut de page et passe le contrôle au noyau. Beyond Physical Memory: Mechanisms décrit le cas d’une page transférée sur disque : la relire, mettre à jour la table des pages, puis réessayer l’instruction. Pendant l’attente du disque, le système peut exécuter d’autres tâches prêtes. Un accès sans correspondance valide, ou contraire aux permissions, ne peut pas être traité simplement en relisant une page.

Un défaut de page peut aussi ne nécessiter aucune lecture sur disque. Complete Virtual Memory Systems décrit la mise à zéro à la demande, qui alloue et initialise une page physique au premier accès, ainsi que la copie à l’écriture (copy-on-write, COW), qui partage d’abord une page puis la copie pour le processus qui veut y écrire. Le manuel Linux de fork() confirme l’emploi de COW : créer un enfant n’oblige pas à copier immédiatement toutes les pages physiques privées. Une écriture COW ne devient pas une modification partagée visible à la fois par le parent et par l’enfant.

Descripteurs de fichiers, tubes et héritage​

Sous Unix, les descripteurs de fichiers sont des entiers non négatifs qu’un processus utilise pour désigner des ressources ouvertes. L’entrée standard, la sortie standard et la sortie d’erreur standard utilisent habituellement 0, 1 et 2 au démarrage du programme. Après fork(), les copies de descripteurs de l’enfant désignent les mêmes descriptions de fichiers ouverts que celles du parent, avec notamment une position partagée dans le fichier. Une mémoire privée peut donc coexister avec des ressources ouvertes partagées.

Le remplacement du programme a aussi ses règles d’héritage. Le manuel Linux de execve() indique que les descripteurs ouverts sont normalement conservés, sauf ceux marqués close-on-exec, qui sont fermés. Un enfant créé par fork() hérite d’une copie de l’environnement de son parent et de son répertoire de travail. execve() conserve ce répertoire, mais l’appelant fournit l’environnement du nouveau programme. Un lanceur comme subprocess de Python peut aussi fermer les descripteurs inutiles ou choisir un autre environnement et un autre répertoire. Démarrer un processus séparé ne révoque pas à lui seul les accès accordés par ces ressources.

Un tube relie une extrémité de lecture à une extrémité d’écriture par un flux d’octets unidirectionnel. Ce n’est pas une variable manipulée des deux côtés, et il n’a pas de frontières de messages intégrées : les données structurées nécessitent un format convenu. Après épuisement des données en attente, le lecteur ne reçoit la fin de fichier que lorsque tous les descripteurs de l’extrémité d’écriture sont fermés. Une copie inutile laissée ouverte peut prolonger son attente indéfiniment.

Lancer un enfant et récupérer ses données et son état de sortie​

Cet exemple utilise uniquement la bibliothèque standard de Python. Le parent encode une liste en JSON et l’envoie par un tube d’entrée standard à un nouveau processus Python. L’enfant décode sa propre liste, ajoute 7, écrit le résultat sur la sortie standard et la valeur d’environnement de l’exemple sur la sortie d’erreur, puis se termine volontairement avec l’état 3. Les données sont sérialisées et transmises ; la liste du parent n’est pas directement partagée.

La documentation de subprocess précise que communicate() envoie l’entrée, lit les sorties et attend la terminaison. Cette méthode convient aux petites données de l’exemple ; elle conserve les sorties dans la mémoire du parent et ne convient pas à des flux illimités.

import os
import subprocess
import sys
import json

numbers = [2, 3, 5]
child_code = r'''
import json, os, sys
numbers = json.load(sys.stdin)
numbers.append(7)
print(json.dumps({"numbers": numbers, "sum": sum(numbers)}))
print("mode=" + os.environ["DEMO_MODE"], file=sys.stderr)
raise SystemExit(3)
'''
env = dict(os.environ, DEMO_MODE="worker")
child = subprocess.Popen(
[sys.executable, "-c", child_code],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env,
)
out, err = child.communicate(json.dumps(numbers))
print("child stdout:", out.strip())
print("child stderr:", err.strip())
print("exit status:", child.returncode)
print("parent numbers:", numbers)

Sortie (Python 3.13.15, macOS) :

child stdout: {"numbers": [2, 3, 5, 7], "sum": 17}
child stderr: mode=worker
exit status: 3
parent numbers: [2, 3, 5]

Le résultat 17 correspond à 2 + 3 + 5 + 7 ; la liste du parent garde ses valeurs initiales. L’état 3 est un état non nul choisi volontairement. La présence d’un résultat sur la sortie standard ne suffit pas à conclure au succès. Le parent récupère les données et attend la fin de l’enfant avant d’afficher les quatre lignes : leur ordre d’affichage ne dépend donc pas de l’ordonnancement.

Appeler uniquement wait() sans lire les tubes peut provoquer un interblocage si la sortie est volumineuse : l’enfant remplit un tube et attend un lecteur, tandis que le parent attend l’enfant. communicate() traite les tubes ensemble pour éviter cette attente. Python peut lancer l’enfant par des mécanismes comme posix_spawn() ; cet exemple n’est pas une trace prouvant qu’un appel à fork() a eu lieu en dessous.

Frontières des processus, conteneurs et machines virtuelles​

L’explication de Docker sur les conteneurs et les VM distingue les conteneurs qui partagent un noyau des machines virtuelles qui exécutent le leur. Les conteneurs Linux partagent le noyau Linux qui les héberge. Si cet environnement Linux tourne dans une VM, ils partagent le noyau de cette VM.

FrontièreCe qu’elle isole ou organiseRapport au noyau
ProcessusÉtat d’exécution et espace d’adressage ; des ressources comme les fichiers peuvent rester partagéesUtilise le même noyau du système par des appels système
Conteneur LinuxUn ou plusieurs processus et leur vue des fichiers, des identifiants de processus et d’autres ressourcesLes conteneurs partagent le noyau Linux qui les héberge
Machine virtuelle (VM)Du matériel virtuel sur lequel un système invité exécute ses propres processusL’invité exécute son propre noyau sur une couche de virtualisation

La documentation d’architecture de Docker explique l’emploi des espaces de noms (namespaces) pour établir des vues isolées des ressources. La documentation sur les limites de ressources traite séparément les limites de mémoire et de CPU. Par défaut, les conteneurs Docker n’ont pas de limites de ressources, même si leur utilisation effective reste soumise au noyau hôte ; les limites correspondantes nécessitent aussi son support. Une vue distincte du système de fichiers n’apporte pas automatiquement une réserve de mémoire exclusive ou limitée.

Choisir la frontière dépend du problème à résoudre :

  • Les variables d’un notebook appartiennent au processus qui les exécute. Architecture de Jupyter explique le rapport entre fichiers et état d’exécution.
  • Pour les conteneurs de services et le stockage persistant, voir Docker Engine et Compose. Un budget mémoire doit aussi préciser s’il limite un processus ou l’ensemble des processus du conteneur.
  • La commande d’un outil d’agent peut tourner dans un processus séparé tout en conservant des valeurs d’environnement héritées, des ressources ouvertes et les permissions du compte. Agent Harness explique comment un système externe limite le périmètre d’exécution.
  • Les fondamentaux de l’informatique replacent ces concepts dans le parcours plus large consacré à la représentation, à l’exécution et aux ressources.
Explorer les liensOuvrir le réseau