Un programme est un fichier : l’exécutable du chapitre précédent, posé sur le disque. Un processus est un programme en cours d’exécution : l’unité d’exécution du système d’exploitation, avec sa propre mémoire, son propre état du CPU sauvegardé et sa propre vue de la machine. Lancer deux fois un programme donne deux processus qui exécutent le même code sans se connaître.
Le livre le dit dans les termes des niveaux de ce site : un processus est caractérisé par son état et son espace d’adressage, et son état comprend au moins le compteur de programme, les indicateurs, le pointeur de pile et les registres généraux. Tout ce que fournissent les niveaux inférieurs — registres, mémoire, instructions —, le système en donne à chaque processus une copie privée, ou l’illusion d’une copie.
De quoi est fait un processus
- Un espace d’adressage : l’étendue des adresses que le processus peut utiliser, et ce qui se trouve à chacune. Chaque processus a le sien, de presque 0 jusqu’à 128 Tio sous Linux x86-64 (256 Tio sur ARM64), et la même adresse désigne une mémoire différente dans deux processus différents. Le chapitre sur la mémoire virtuelle et la pagination montre comment le matériel y parvient.
- Un état du CPU : le compteur de programme, le pointeur de pile, les indicateurs et tous les autres registres. Tant que le processus s’exécute, ils sont dans le CPU ; quand il attend, le noyau les garde en mémoire.
- Des ressources du noyau : un identifiant de processus, un propriétaire et des droits, des fichiers ouverts, un répertoire courant, des gestionnaires de signaux, des limites. Le noyau range tout cela dans un bloc de contrôle de processus —
task_structsous Linux, plusieurs kilo-octets de champs par processus.
Le processus ne voit jamais son bloc de contrôle. De son point de vue, il a un CPU et une mémoire pour lui seul.
L’espace d’adressage, pour de vrai
L’espace d’adressage n’est pas un bloc unique : c’est un ensemble de régions, chacune projetée depuis une source, avec ses propres droits. Sous Linux, /proc/self/maps les liste. Voici un petit programme C qui affiche où se trouvent son code, ses globales, son tas, un gros malloc, une fonction de bibliothèque et une variable locale, suivi de sa propre carte, sous Linux ARM 64 bits (légèrement allégé) :
main (code) 0xaaaaad0b0914
initialized 0xaaaaad0d0060
zeroed 0xaaaaad0d0068
malloc(16) 0xaaaad7d2d2a0
malloc(1 MiB) 0xffff90a0f010
printf (libc) 0xffff90b5cfe0
local (stack) 0xfffff4aa3754
aaaaad0b0000-aaaaad0b1000 r-xp /w/as ← code (.text)
aaaaad0cf000-aaaaad0d0000 r--p /w/as ← données en lecture seule
aaaaad0d0000-aaaaad0d1000 rw-p /w/as ← .data et .bss
aaaad7d2d000-aaaad7d4e000 rw-p [heap] ← petits malloc
ffff90a0f000-ffff90b10000 rw-p ← le malloc de 1 Mio (son propre mmap)
ffff90b10000-ffff90c9b000 r-xp …/libc.so.6 ← le code de la bibliothèque C
ffff90cb0000-ffff90cb2000 rw-p …/libc.so.6 ← …et ses données
ffff90ccf000-ffff90cf6000 r-xp …/ld-linux-aarch64.so.1 ← le chargeur dynamique
ffff90d0b000-ffff90d0d000 r-xp [vdso] ← du code du noyau projeté dans chaque processus
fffff4a83000-fffff4aa4000 rw-p [stack]
Chaque ligne est une plage d’adresses, ses droits (lecture r, écriture w, exécution x, p pour privé), et ce qu’elle projette. Les sections de l’exécutable deviennent des régions séparées : le code est lisible et exécutable mais pas modifiable, les données modifiables mais pas exécutables — la règle W^X. Le tas grandit juste au-dessus du programme ; les bibliothèques, les grosses allocations et la pile sont près du haut de l’espace d’adressage, et la pile descend. La région [vdso] est le morceau de code du noyau qui répond à des appels comme clock_gettime sans appel système, évoqué dans le chapitre sur les déroutements. La mémoire propre au noyau occupe la moitié haute de l’espace des adresses 64 bits, projetée dans chaque processus mais inaccessible en mode utilisateur.
Relancez le même programme, et toutes les adresses changent :
main (code) 0xaaaaaf4f0914 (était 0xaaaaad0b0914)
malloc(16) 0xaaaab8ea92a0 (était 0xaaaad7d2d2a0)
printf (libc) 0xffffa6c9cfe0 (était 0xffff90b5cfe0)
local (stack) 0xffffdb996b64 (était 0xfffff4aa3754)
C’est l’ASLR, la randomisation de l’espace d’adressage : le noyau place chaque région à un décalage aléatoire à chaque lancement, si bien qu’un attaquant qui exploite un bug mémoire ne peut pas savoir où sont le code, la pile ou les bibliothèques. Seuls les décalages à l’intérieur d’une région restent fixes : main se termine toujours par 914.
Le simulateur montre les mêmes régions pour ses propres programmes. Avancez pas à pas dans celui-ci au niveau du système et regardez l’espace d’adressage se remplir :
À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.
- int initialized = 42;
- int zeroed;
- int main() {
- int local = 1;
- int *heap = malloc(sizeof(int));
- *heap = initialized + zeroed + local;
- return *heap;
- }
- int initialized = 42;
- int zeroed;
- int main() {
- int local = 1;
- int *heap = malloc(sizeof(int));
- *heap = initialized + zeroed + local;
- return *heap;
- }
Il renvoie 43. La disposition du simulateur est plus simple que celle de Linux — ni bibliothèques ni randomisation —, mais les régions sont les mêmes : code, données, tas qui monte, pile qui descend.
fork, exec et wait
UNIX crée les processus en deux étapes, ce qui est inhabituel mais dure depuis cinquante ans :
fork()fait une copie exacte du processus appelant : même code, même contenu mémoire, mêmes fichiers ouverts. Il revient deux fois — dans le parent, il renvoie l’identifiant du fils ; dans le fils, il renvoie 0. C’est ainsi que chaque copie sait laquelle elle est.exec()remplace le programme du processus appelant par un nouveau, lu dans un fichier exécutable : nouveau code, nouvelles données, nouvelle pile, même identifiant de processus et mêmes fichiers ouverts.wait()endort le parent jusqu’à ce qu’un fils se termine, et récupère son code de sortie.
Quand vous tapez ls dans un shell, le shell fait un fork, le fils fait un exec de /bin/ls, et le parent attend. Entre le fork et l’exec, le fils peut réorganiser ses fichiers ouverts — c’est ainsi que ls > out.txt redirige la sortie sans que ls n’en sache rien.
Ce programme fait un seul fork. Le fils modifie une variable globale et se termine avec le code 7 :
int x = 1;
int main(void) {
pid_t pid = fork();
if (pid == 0) {
x = 2;
printf("child: fork() returned 0, x = %d at %p\n", x, &x);
exit(7);
}
int status;
waitpid(pid, &status, 0);
printf("parent: fork() returned %d, x = %d at %p, child exit status %d\n",
pid, x, &x, WEXITSTATUS(status));
}
Sous Linux :
child: fork() returned 0, x = 2 at 0xaaaabf030068
parent: fork() returned 14, x = 1 at 0xaaaabf030068, child exit status 7
Le fils a mis x à 2 à l’adresse 0xaaaabf030068, et le parent lit toujours 1 à la même adresse. Même adresse virtuelle, mémoire différente : chaque processus a son propre espace d’adressage.
Copier tout un espace d’adressage à chaque fork serait lent, d’autant que le fils appelle en général exec aussitôt et jette la copie. Le noyau ne copie donc rien. Parent et fils partagent toutes les pages, marquées en lecture seule, et une page n’est copiée que lorsque l’un des deux y écrit — c’est la copie sur écriture (copy-on-write). Ici, le x = 2 du fils a déclenché un défaut de page, le noyau a copié cette seule page, et l’écriture est allée dans la copie du fils.
Un processus terminé mais pas encore attendu est un zombie : sa mémoire a disparu, mais le noyau garde son entrée dans la table des processus pour que le parent puisse encore lire le code de sortie. Quand un parent meurt le premier, ses fils sont adoptés par le processus 1, init (systemd sur la plupart des systèmes Linux, launchd sous macOS), qui les attend. Tous les processus du système en descendent et forment un arbre de processus.
Windows n’a pas de fork : CreateProcess crée un nouveau processus et y charge un programme en un seul appel. POSIX a ajouté posix_spawn pour faire de même sous UNIX, et macOS le préfère.
États et ordonnancement
Une machine fait tourner bien plus de processus qu’elle n’a de cœurs : ce Mac avait 1 297 processus pour 24 cœurs pendant l’écriture de ce chapitre. Presque tous attendent à un instant donné. Un processus est dans l’un de trois états principaux :
- en cours d’exécution sur un cœur ;
- prêt : capable de s’exécuter, en attente d’un cœur ;
- bloqué (endormi) : en attente de quelque chose — une touche, une lecture disque, un paquet réseau, une minuterie, la fin d’un fils.
L’ordonnanceur choisit quel processus prêt s’exécute sur chaque cœur. Un processus quitte le CPU quand il se bloque, en général dans un appel système, ou quand l’interruption de minuterie signale que sa tranche de temps est épuisée : c’est ce qui rend le multitâche préemptif — aucun processus ne peut garder un cœur indéfiniment. Chaque bascule est un changement de contexte : sauvegarder les registres du processus en cours dans son bloc de contrôle, choisir le suivant, changer d’espace d’adressage, restaurer ses registres, revenir en mode utilisateur. Sous Linux, l’ordonnanceur est EEVDF depuis le noyau 6.6 ; il donne à chaque processus prêt une part équitable du temps CPU, pondérée par sa priorité.
Ce que cela coûte
Mesuré sur le M2 où ce chapitre a été écrit, sous macOS et dans une machine virtuelle Linux :
| macOS | VM Linux | |
|---|---|---|
fork + exit dans le fils + wait | 0,7 à 1 ms | environ 0,12 ms |
lancer /usr/bin/true avec posix_spawn et attendre | 1,3 à 2 ms | environ 0,22 ms |
| faire l’aller-retour d’un octet entre deux processus par des tubes (deux changements de contexte) | 7 à 28 µs | 24 à 36 µs |
Ces chiffres varient beaucoup d’une exécution à l’autre et d’un système à l’autre : le fork de macOS est réputé plus lent que celui de Linux, et une machine virtuelle ajoute son propre surcoût à chaque bascule. Mais ce sont les ordres de grandeur qui comptent. Un appel de fonction prend environ une nanoseconde, un appel système une centaine de nanosecondes, un changement de contexte des microsecondes, et la création d’un processus à partir d’un exécutable des centaines de microsecondes ou davantage. C’est pourquoi les serveurs gardent des processus et des threads en réserve plutôt que d’en créer un par requête, et pourquoi les threads — plusieurs flots d’exécution partageant un même espace d’adressage, sujet d’un chapitre ultérieur — existent.
À retenir
- Un processus est un programme en cours d’exécution : un espace d’adressage, un état du CPU sauvegardé et des ressources du noyau, consignés dans son bloc de contrôle.
- L’espace d’adressage est un ensemble de régions aux droits propres : code (r-x), données (rw-), tas, fichiers et bibliothèques projetés, pile.
/proc/self/mapsles liste. - L’ASLR place chaque région à une adresse aléatoire à chaque lancement.
- UNIX crée les processus avec fork (copier l’appelant, revenir deux fois), exec (charger un nouveau programme) et wait (récupérer le code de sortie). La copie sur écriture rend fork peu coûteux : les pages ne sont copiées que lorsqu’on y écrit.
- Les processus sont en cours d’exécution, prêts ou bloqués ; l’ordonnanceur et l’interruption de minuterie se partagent les cœurs entre eux, avec un changement de contexte à chaque fois.
- Les coûts s’étalent sur des ordres de grandeur : un changement de contexte prend des microsecondes, lancer un programme des centaines de microsecondes à quelques millisecondes.