Programme contre processus, états et transitions, bloc de contrôle ; création et terminaison par fork, exec et wait ; première approche des threads.
Sur une machine de bureau qui « ne fait rien », ps aux | wc -l répond typiquement autour de
trois cents. Trois cents processus, pour un utilisateur qui regarde un fond d'écran. Aucun
n'occupe le processeur en permanence : la quasi-totalité attend quelque chose — une frappe, un
paquet réseau, une échéance.
Ce chapitre définit cet objet, le plus important du cours. Le chapitre 1 a dit que le système abstrait le matériel ; le processus est l'abstraction du processeur. Et le chapitre 4 en tirera la question suivante : quand plusieurs processus sont prêts, lequel élire ?
Un programme n'est pas un processus
La distinction paraît scolaire jusqu'à ce qu'on la manque, après quoi rien du chapitre ne tient.
Un programme est un fichier sur le disque : une suite d'octets, passive, qui ne fait rien. Un processus est une exécution de ce programme : un objet vivant, doté d'un état, de mémoire, de fichiers ouverts, d'un utilisateur propriétaire.
La relation n'est pas de un à un. Un même programme lancé trois fois donne trois processus
indépendants, avec chacun ses variables — trois fenêtres d'éditeur qui ne partagent rien.
Inversement, un processus peut au cours de sa vie exécuter successivement plusieurs programmes,
et c'est précisément ce que fait exec.
L'analogie qui fonctionne : la recette et le plat. La recette se recopie et se range dans un tiroir ; le plat est en train de cuire, occupe une casserole, et deux personnes qui suivent la même recette produisent deux plats distincts.
Ce qu'un processus possède
Un processus, c'est d'abord une image mémoire, découpée en quatre régions dont la disposition est presque universelle :
adresses hautes ┌────────────────────┐ │ pile (stack) │ variables locales, paramètres, adresses de retour │ ↓ │ croît vers le bas à chaque appel de fonction │ │ │ ↑ │ │ tas (heap) │ allocation dynamique (malloc), croît vers le haut ├────────────────────┤ │ données │ variables globales et statiques ├────────────────────┤ │ code (text) │ les instructions, en lecture seule └────────────────────┘ adresses bassesLe code est en lecture seule, ce qui permet à trois exécutions du même programme d'en partager une seule copie en mémoire — économie considérable, et l'une des raisons d'être de la pagination du chapitre 6. Pile et tas croissent l'un vers l'autre : c'est ce qui permet d'ajuster l'espace entre les deux sans le fixer à l'avance, et un débordement de pile est la collision de ces deux frontières.
Le système, lui, décrit chaque processus par un bloc de contrôle de processus (BCP), une structure du noyau qui contient tout ce qu'il faut pour le suspendre et le reprendre :
| Contenu du BCP | Pourquoi |
|---|---|
| Identifiant (PID), propriétaire | l'identifier, décider de ses droits |
| État | prêt, élu, bloqué (section suivante) |
| Compteur ordinal et registres | reprendre exactement où il en était |
| Informations mémoire | tables de pages du chapitre 6 |
| Fichiers ouverts | les descripteurs 0, 1, 2 du chapitre 2 |
| Priorité, temps consommé | l'ordonnancement du chapitre 4 |
Le changement de contexte est l'opération qui sauvegarde le BCP du processus sortant et restaure celui de l'entrant. C'est un pur surcoût — aucun travail utile n'est fait pendant ce temps — et il coûte de l'ordre de la microseconde, sans compter l'effet indirect le plus cher : le cache du chapitre 7 d'architecture est rempli des données de l'ancien processus, et le nouveau recommence à froid.
Cinq états, et une transition qui compte
élection ┌──────┐ ──────────► ┌──────┐ │ PRÊT │ │ ÉLU │ ──── exit ───► terminé └──────┘ ◄────────── └──────┘ ▲ préemption │ │ │ appel bloquant │ l'événement │ (lecture disque, attente…) │ attendu survient ▼ │ ┌────────┐ └───────────────────│ BLOQUÉ │ └────────┘Le point à ne jamais confondre : prêt et bloqué ne sont pas la même attente.
Un processus prêt attend seulement le processeur. Il a tout ce qu'il lui faut ; donnez-lui le processeur, il repart immédiatement. C'est parmi ces processus, et seulement parmi eux, que l'ordonnanceur choisira au chapitre 4.
Un processus bloqué attend autre chose : la fin d'une lecture disque, un paquet réseau, une saisie. Lui donner le processeur ne servirait à rien — il ne pourrait rien en faire. Il ne redevient prêt que lorsque l'événement attendu survient, ce qui est signalé par une interruption, exactement au sens du chapitre 8 d'architecture.
Deux transitions distinctes mènent donc hors de l'état élu, et il faut les nommer correctement. L'appel bloquant est volontaire : le processus demande une opération lente et se met de côté de lui-même. La préemption est subie : l'interruption d'horloge rend la main au noyau, qui décide de retirer le processeur à un processus pourtant capable de continuer. Sans préemption, un programme en boucle infinie gèlerait la machine — c'est exactement ce qui se passait sur les systèmes coopératifs des années 1990.
Un processus effectue une lecture de fichier de 5 ms. Pendant ce temps, l'ordonnanceur doit-il le considérer comme candidat au processeur ?
Créer un processus : fork, exec, wait
Unix crée les processus d'une façon qui déroute au premier abord et qui se révèle très économique en concepts.
fork duplique le processus appelant. L'enfant reçoit une copie de l'image mémoire du
parent, de ses variables, de ses descripteurs de fichier ouverts, de son environnement. Les
deux processus reprennent à la même instruction, celle qui suit le fork.
D'où la particularité qui surprend tout le monde : fork rend deux fois, une fois dans
chaque processus, et sa valeur de retour est le seul moyen de savoir où l'on est.
pid = fork();if (pid < 0) // échec : pas de nouveau processus perror("fork");else if (pid == 0) // ici, JE SUIS L'ENFANT ...else // ici, JE SUIS LE PARENT ; pid est le PID de l'enfant ...Le choix est délibéré : l'enfant n'a pas besoin qu'on lui dise son PID, il peut le demander par
getpid. C'est le parent qui a besoin de connaître celui de l'enfant, pour l'attendre ou le
tuer plus tard.
exec remplace l'image mémoire du processus courant par celle d'un autre programme. Le PID
ne change pas, les descripteurs ouverts restent ouverts — mais le code, les données et la pile
sont écrasés, et on ne revient jamais d'un exec réussi.
La combinaison des deux donne le schéma que le shell du chapitre 2 exécute à chaque commande :
fork pour se dupliquer, puis exec dans l'enfant pour devenir le programme demandé, pendant
que le parent attend. Séparer les deux opérations peut sembler tortueux ; c'est en réalité ce
qui rend les redirections possibles. Entre le fork et le exec, l'enfant est encore le
shell : il peut refermer son descripteur 1 et le rouvrir sur un fichier, puis lancer le
programme, qui écrira dans le fichier sans jamais savoir qu'il ne s'adresse pas à l'écran.
wait suspend le parent jusqu'à la terminaison d'un enfant et récupère son code de
retour — celui que exit a fourni, et que le shell teste dans un if. Deux situations
anormales portent des noms qu'il faut connaître :
Un processus zombie est terminé mais dont le parent n'a pas encore appelé wait. Son BCP
subsiste, uniquement pour conserver le code de retour. C'est normal et bref ; cela devient une
fuite si le parent n'attend jamais ses enfants.
Un processus orphelin a perdu son parent avant sa propre fin. Il est adopté par le
processus d'initialisation, de PID 1, qui appelle wait en permanence — ce qui garantit qu'un
orphelin ne deviendra pas un zombie éternel. Tous les processus forment ainsi un arbre
enraciné en PID 1.
Les fils d'exécution
Un processus a un seul fil d'exécution : un compteur ordinal, une pile. Un fil (thread) en ajoute d'autres à l'intérieur du même processus.
Ce qui est partagé entre les fils d'un processus : le code, les données globales, le tas, les fichiers ouverts. Ce qui reste propre à chacun : la pile, les registres, le compteur ordinal.
Deux conséquences, exactement opposées. La bonne : la communication est immédiate, puisqu'une
variable globale est visible de tous, et la création d'un fil coûte bien moins qu'un fork,
qui doit dupliquer une image mémoire. La mauvaise : cette même mémoire partagée est le terrain
des conditions de concurrence du chapitre 5. Deux processus séparés ne peuvent pas se
corrompre mutuellement ; deux fils, si, et ils le font.
Il en découle une règle de choix : des tâches indépendantes s'écrivent en processus, des tâches qui doivent échanger beaucoup de données s'écrivent en fils — et acceptent alors la synchronisation qui va avec.
Pourquoi Unix sépare-t-il fork et exec en deux appels système, plutôt que de fournir une seule primitive « lancer un programme » ?
À vous
L'exercice simule fork, exec et wait : chaque processus est un objet avec son PID, son
parent, son état et son code de retour, et l'appel à fork doit produire deux exécutions à
partir d'un seul appel.
Trois choses à obtenir. L'arbre des processus, affiché en indentant les enfants. Le zombie : un
enfant terminé dont le parent n'a pas appelé wait reste dans la table. Et l'adoption : un
enfant dont le parent meurt d'abord doit être rattaché au PID 1.
Complétez fork, exit et wait : arbre des processus, zombie et adoption par le PID 1.
// Table des processus du noyau. Chaque entrée est un BCP simplifié. let PROCHAIN_PID = 1; const TABLE = []; function creer(parent, nom) { const p = { pid: PROCHAIN_PID++, parent, nom, etat: "prêt", code: null, attend: false }; TABLE.push(p); return p; } const init = creer(null, "init"); // PID 1, la racine de l'arbre const trouver = (pid) => TABLE.find((p) => p.pid === pid); // fork : duplique le processus courant. Rend le PID de l'enfant au parent, // et 0 à l'enfant — ici on rend les deux, à l'appelant de simuler. function fork(courant, nomEnfant) { const enfant = creer(courant.pid, nomEnfant ?? courant.nom); return { auParent: enfant.pid, aLEnfant: 0, enfant }; } // exec : remplace le programme. Le PID ne change PAS. function exec(processus, programme) { processus.nom = programme; return processus; } // exit : le processus se termine. Son BCP survit tant que le parent n'a pas // récupéré son code de retour. function sortir(processus, code) { processus.etat = "zombie"; processus.code = code; // ← à écrire : les enfants de ce processus deviennent orphelins } // wait : le parent récupère un enfant zombie et libère son BCP. function attendre(parent) { const zombie = TABLE.find((p) => p.parent === parent.pid && p.etat === "zombie"); if (!zombie) return null; TABLE.splice(TABLE.indexOf(zombie), 1); // le BCP disparaît enfin return { pid: zombie.pid, code: zombie.code }; } function arbre(pid = 1, profondeur = 0) { const p = trouver(pid); if (!p) return; console.log(" ".repeat(profondeur) + "PID " + p.pid + " " + p.nom + " [" + p.etat + (p.code !== null ? ", code " + p.code : "") + "]"); for (const e of TABLE.filter((x) => x.parent === pid)) arbre(e.pid, profondeur + 1); } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Dans sortir(), rattachez les enfants du mourant au PID 1 (adoption). // 2. Écrivez le scénario du shell : fork, exec dans l'enfant, wait dans le // parent — et vérifiez qu'aucun zombie ne subsiste à la fin. const shell = creer(1, "bash"); const f = fork(shell, "bash"); exec(f.enfant, "grep"); sortir(f.enfant, 0); console.log("— avant wait —"); arbre(); console.log("wait rend :", attendre(shell)); console.log("— après wait —"); arbre();
En travaux pratiques
Premier noyau du mini-shell
Créer des processus, les remplacer, les attendre — et écrire les cinquante lignes qui font l'essentiel d'un interpréteur de commandes.
- Les TP 1 et 2
- gcc, et la page de manuel de fork au chapitre 2
- 1. Le premier fork
Écrivez un programme qui appelle fork une fois et affiche, dans les deux branches, son identifiant et celui de son père. Exécutez-le dix fois et notez si l'ordre des lignes varie.
- 2. Compter les processus
Écrivez un programme avec trois appels à fork successifs, sans aucun test. Prédisez le nombre de lignes affichées AVANT d'exécuter, puis vérifiez.
- 3. Remplacer le programme
Faites exécuter la commande ls par le fils, avec execvp. Vérifiez que la ligne écrite après l'appel n'est jamais atteinte, et expliquez pourquoi.
- 4. Attendre
Faites attendre le père avec wait, récupérez le code de sortie du fils, et affichez-le. Testez avec un fils qui réussit, un qui échoue, un qui est tué par un signal.
- 5. Fabriquer un zombie
Écrivez un programme dont le père n'attend pas et dort trente secondes. Observez le fils dans ps pendant ce temps, et notez son état.
- 6. Fabriquer un orphelin
Inversez : le fils survit au père. Affichez l'identifiant de son père avant et après la mort de celui-ci.
- 7. Le mini-shell
Assemblez : une boucle qui lit une ligne, la découpe en mots, crée un processus, exécute la commande et attend. Faites-le fonctionner avec ls, echo et une commande inexistante.
- 8. L'arrière-plan
Ajoutez la reconnaissance d'une esperluette finale : la commande s'exécute sans que le shell attende. Vérifiez ensuite ce qui arrive à vos processus terminés.
- Trois fork affichent huit lignes, et vous saviez le prédire
- Votre shell exécute ls, signale proprement une commande inconnue, et ne quitte pas
- Vous savez expliquer ce qu'est un zombie sans employer le mot « planté »
Ce que la suite en fait
Le chapitre 4 prend la suite immédiate. Nous avons maintenant des processus, dont certains sont prêts ; il faut choisir. Les états définis ici sont exactement les entrées de l'ordonnanceur, et le changement de contexte est le coût qu'il faut amortir — c'est lui qui interdit un quantum de temps trop court.
Le bloc III part de la remarque sur les fils : dès que deux exécutions partagent de la mémoire, elles peuvent se corrompre. Le chapitre 5 montrera comment, et comment l'empêcher.
À retenir
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.