cursus.

Cours 2 · ProcessusLeçon 1 sur 2

Notion de processus

6 h de lecture9 sections Version PDF

À la fin de cette leçon, vous saurez

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 basses

Le 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 BCPPourquoi
Identifiant (PID), propriétairel'identifier, décider de ses droits
Étatprêt, élu, bloqué (section suivante)
Compteur ordinal et registresreprendre exactement où il en était
Informations mémoiretables de pages du chapitre 6
Fichiers ouvertsles 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.

Quiz · vérifiez votre compréhension Sans réponse

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.

Quiz · vérifiez votre compréhension Sans réponse

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.

Exercice · JavaScript · à vous de jouer

Complétez fork, exit et wait : arbre des processus, zombie et adoption par le PID 1.

En attente
// 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();

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 3 · sur machine

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.

3 h
Avant de commencer
  • Les TP 1 et 2
  • gcc, et la page de manuel de fork au chapitre 2
  1. 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. 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. 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. 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. 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. 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. 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. 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.

C'est réussi quand
  • 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

Flashcards · 1 / 5Toucher pour retourner
Fin de la leçon

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.