Prérequis : notions de base en Python, utilisation élémentaire d’un terminal Linux
À l’issue de ce chapitre, vous saurez :
ps, top, kill) ;Un système d’exploitation (OS, Operating System) est le logiciel fondamental qui fait le lien entre le matériel (processeur, mémoire, disque) et les programmes de l’utilisateur. Parmi ses fonctions principales : la gestion des processus, la gestion des fichiers, la gestion de la mémoire, la gestion des périphériques et la sécurisation du système.
Dans ce chapitre, nous nous intéressons à la gestion des processus : comment le système crée, organise et fait cohabiter les programmes en cours d’exécution.
Un programme est un fichier stocké sur le disque. C’est une description statique : un ensemble d’instructions qui ne fait rien tant qu’il n’est pas lancé.
Un processus est ce qui se passe quand on exécute ce programme. C’est une entité dynamique : le programme en cours d’exécution, avec ses données, son état d’avancement et ses ressources.
Analogie. Un programme, c’est une recette de cuisine écrite dans un livre. Un processus, c’est une personne en train de réaliser cette recette, avec ses ingrédients devant elle et un état d’avancement propre. Plusieurs personnes peuvent réaliser la même recette en parallèle : chacune constitue un processus distinct.
Cette distinction est essentielle, car elle permet de désigner chaque tâche individuellement (même si plusieurs exécutent le même programme) et d’agir sur chacune de façon indépendante : en mettre une en pause, en terminer une autre, gérer des priorités.
Pour gérer chaque processus, le système lui attribue :
Un processus peut en créer d’autres : on parle de processus fils. Certains processus fonctionnent en permanence en arrière-plan dès le démarrage de la machine : ce sont les services (Windows) ou les daemons (Linux).
Le système alloue à chaque processus un espace en mémoire virtuelle contenant :
Quand un processus est interrompu pour laisser la place à un autre, le système sauvegarde son contexte en mémoire. Quand il reprend la main, le contexte est restauré dans les registres du processeur et l’exécution reprend exactement là où elle s’était arrêtée.
Ces changements de contexte sont très rapides (de l’ordre de la microseconde), ce qui donne à l’utilisateur l’impression que tous les programmes s’exécutent simultanément. C’est le pseudo-parallélisme. Avec un processeur multi-cœurs, certains processus peuvent aussi s’exécuter en vrai parallèle.
Au cours de son existence, un processus passe par plusieurs états :
Les transitions entre ces états suivent une logique précise :
L’ordonnanceur peut aussi préempter (interrompre) un processus élu pour donner la main à un autre : le processus interrompu repasse alors de élu à prêt.
| Commande | Rôle |
|---|---|
ps -ef |
Tous les processus avec détails (UID, PID, PPID…) |
ps aux |
Tous les processus avec utilisation mémoire et CPU |
ps -eo pid,ppid,stat,command |
Colonnes personnalisées |
pstree |
Processus sous forme d’arbre (relations père-fils) |
top |
Suivi en temps réel (classé par % CPU) |
pidof <nom> |
PID d’un processus par son nom |
kill <PID> |
Envoyer un signal de terminaison |
killall <nom> |
Terminer tous les processus d’un programme |
Ouvrir un terminal et réaliser les manipulations suivantes.
Certaines commandes utilisent le symbole |, appelé tube : il envoie ce qu’affiche la commande de gauche à la commande de droite. Ainsi, head -20 ne garde que les 20 premières lignes reçues, et grep python3 ne garde que les lignes qui contiennent python3.
Étape 1 — observer les processus existants.
ps -eo pid,ppid,stat,comm | head -20
Repérer le PID du processus init (ou systemd). Quel est son PPID ? Pourquoi ?
Étape 2 — lancer un processus et l’observer.
Dans un premier terminal, lancer :
python3 -c "import time; [time.sleep(1) for _ in range(60)]"
Dans un second terminal :
ps -ef | grep python3
Relever le PID et le PPID du processus Python. À quel processus père correspond ce PPID ?
Étape 3 — visualiser l’arbre des processus.
pstree -p | head -30
Retrouver le processus Python lancé à l’étape 2. Identifier sa place dans l’arbre.
Étape 4 — terminer un processus.
kill <PID du processus Python>
Vérifier avec ps que le processus a bien disparu. Que se passe-t-il si on utilise kill -9 au lieu de kill ?
Tout processus est créé par un autre processus, son père : le shell crée un processus pour chaque commande tapée, et le système lui attribue un nouveau PID. Depuis un programme Python, le module subprocess lance une commande dans un processus fils et attend sa fin ; le module os donne les identifiants.
import os
import subprocess
print(f"Je suis le processus {os.getpid()} créé par {os.getppid()}")
resultat = subprocess.run(["ls", "-l"], capture_output=True, text=True)
print(resultat.stdout)
Dans un terminal, le caractère & lance une commande en arrière-plan et rend la main tout de suite : le shell affiche le PID du processus créé, que l’on retrouve avec ps et que l’on arrête avec kill.
sleep 300 &
[1] 4821
ps -o pid,ppid,stat,comm -p 4821
kill 4821
La commande top affiche en continu l’état du système et des processus, classés par consommation de processeur :
top - 10:42:07 up 3 days, 2:15, 2 users, load average: 0.52, 0.38, 0.30
Tasks: 213 total, 1 running, 212 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.1 us, 1.0 sy, 0.0 ni, 95.6 id, 0.3 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7852.4 total, 2213.9 free, 3106.0 used, 2532.5 buff/cache
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4821 eleve 20 0 512340 184216 96520 S 2.7 2.3 1:12.45 firefox
2210 eleve 20 0 38792 22140 16324 R 0.7 0.3 0:03.10 python3
1 root 20 0 168540 13252 8420 S 0.0 0.2 0:04.62 systemd
Tasks : nombre de processus par état (en exécution, en attente, arrêtés, zombies) ;%Cpu(s) : part du temps processeur passée en mode utilisateur (us), en mode noyau (sy), inactive (id) ou en attente d’entrées-sorties (wa) ;Mem : mémoire totale, libre, utilisée et cache ;PID, propriétaire, priorité (PR, NI), mémoire virtuelle et résidente (VIRT, RES), état S (R en exécution, S en attente, D en attente d’entrée-sortie, T arrêté, Z zombie), pourcentages de processeur et de mémoire, temps processeur cumulé et nom de la commande.Dans top, la touche k suivie d’un PID envoie un signal à un processus, q quitte.
Plusieurs processus peuvent être dans l’état prêt simultanément, mais un processeur (mono-cœur) ne peut en exécuter qu’un seul à la fois. Comment choisir lequel ? C’est le rôle de l’ordonnanceur (scheduler), qui décide de l’ordre de passage.
Pour comparer les algorithmes, on mesure les grandeurs suivantes pour chaque processus :
| Grandeur | Définition |
|---|---|
| Temps d’arrivée | Instant où le processus entre dans la file d’attente |
| Durée | Nombre de quanta nécessaires à son exécution complète |
| Temps de terminaison | Instant où le processus achève son exécution |
| Temps de séjour | Temps de terminaison − temps d’arrivée |
| Temps d’attente | Temps de séjour − durée du processus |
Le temps d’attente moyen permet de comparer globalement deux algorithmes.
Considérons cinq processus :
| Processus | P1 | P2 | P3 | P4 | P5 |
|---|---|---|---|---|---|
| Durée (quanta) | 3 | 6 | 4 | 2 | 1 |
| Temps d’arrivée | 0 | 1 | 4 | 6 | 7 |
Avec SJF, à chaque instant où le processeur se libère, on élit le processus prêt de plus courte durée. Chaque processus s’exécute entièrement sans interruption (SJF non préemptif).
Frise chronologique :
| 0–3 | 3–9 | 9–10 | 10–12 | 12–16 |
| P1 ✓ | P2 ✓ | P5 ✓ | P4 ✓ | P3 ✓ |
Résultats :
| Processus | P1 | P2 | P3 | P4 | P5 |
|---|---|---|---|---|---|
| Durée (quanta) | 3 | 6 | 4 | 2 | 1 |
| Temps d’arrivée | 0 | 1 | 4 | 6 | 7 |
| Temps de terminaison | 3 | 9 | 16 | 12 | 10 |
| Temps de séjour | 3 | 8 | 12 | 6 | 3 |
| Temps d’attente | 0 | 2 | 8 | 4 | 2 |
Temps d’attente moyen : \(\frac{0+2+8+4+2}{5} = 3{,}2\) quanta.
Reprenons les mêmes processus, avec un quantum de temps = 2.
L’ordonnanceur accorde 2 quanta à chaque processus. Si le processus n’a pas terminé à l’issue de son quantum, il retourne en bout de file d’attente. Un processus qui n’a besoin que d’un quantum (ou moins) libère le processeur avant la fin du quantum.
Principe de gestion de la file : lorsqu’un processus est préempté, les nouveaux processus arrivés pendant son exécution sont d’abord ajoutés à la file, puis le processus préempté est placé en bout de file. Un processus qui arrive à l’instant exact où un quantum se termine est lui aussi placé avant le processus préempté.
Frise chronologique :
| 0–2 | 2–4 | 4–5 | 5–7 | 7–9 | 9–11 | 11–12 | 12–14 | 14–16 |
| P1 | P2 | P1 ✓ | P3 | P2 | P4 ✓ | P5 ✓ | P3 ✓ | P2 ✓ |
Le symbole ✓ indique la terminaison du processus.
Résultats :
| Processus | P1 | P2 | P3 | P4 | P5 |
|---|---|---|---|---|---|
| Temps de terminaison | 5 | 16 | 14 | 11 | 12 |
| Temps de séjour | 5 | 15 | 10 | 5 | 5 |
| Temps d’attente | 2 | 9 | 6 | 3 | 4 |
Détail des calculs :
Temps d’attente moyen : \(\frac{2+9+6+3+4}{5} = 4{,}8\) quanta.
Avec SJF, le temps d’attente moyen est de 3,2 quanta (meilleur), mais SJF suppose de connaître à l’avance la durée de chaque processus. Le tourniquet est plus équitable : chaque processus reçoit régulièrement un quantum de temps, ce qui évite la famine.
En pratique, les systèmes d’exploitation modernes combinent tourniquet et priorités dynamiques. L’ordonnanceur de Linux, EEVDF (Earliest Eligible Virtual Deadline First), en service depuis la version 6.6 du noyau (2023) où il a remplacé CFS (Completely Fair Scheduler), attribue à chaque processus une part du processeur proportionnelle à son poids, puis élit celui qui a pris le plus de retard sur cette part. Les processus interactifs, qui dorment souvent, sont ainsi servis rapidement, et aucun processus n’est privé indéfiniment du processeur : il n’y a pas de famine.
Considérons un jeu multijoueur avec une variable globale nb_pions représentant le nombre de pions disponibles. Il ne reste qu’un seul pion et deux processus P1 et P2 (un par joueur) veulent en prendre un :
nb_pions.C’est un problème d’accès concurrent (race condition). La solution consiste à protéger les sections critiques du code avec des verrous (mutex) : tant qu’un processus exécute la section critique, aucun autre ne peut y entrer.
Considérons deux processus P1 et P2, et deux ressources R1 et R2, initialement libres.
Les deux processus sont bloqués, chacun attendant une ressource détenue par l’autre. C’est une situation d’interblocage (deadlock) : un cycle d’attente sans issue.
Edward Coffman a montré en 1971 que l’interblocage ne peut survenir que si quatre conditions sont réunies simultanément :
Si l’on supprime une seule de ces conditions, l’interblocage devient impossible. C’est le principe de la prévention. En pratique, certaines conditions sont plus faciles à supprimer que d’autres :
La détection est une autre approche : on laisse l’interblocage se produire, puis on l’identifie (en détectant un cycle dans le graphe d’allocation des ressources) et on le résout en terminant un des processus impliqués.
Nous avons vu que chaque processus possède son propre espace mémoire, isolé des autres. Cette isolation est un atout pour la sécurité, mais elle a un coût : quand deux processus ont besoin de collaborer (par exemple, un navigateur web qui télécharge une page tout en affichant une animation de chargement), la communication entre eux est lente et complexe.
Les threads répondent à ce besoin : ils permettent à plusieurs tâches de s’exécuter au sein d’un même processus, en partageant directement les mêmes données, sans le surcoût de la communication inter-processus.
Un thread (ou processus léger) est un fil d’exécution à l’intérieur d’un processus. Plusieurs threads d’un même processus partagent le même espace mémoire (variables globales, code) mais possèdent chacun leur propre pile d’exécution. Cette légèreté les rend très utiles, mais le partage de mémoire impose une synchronisation rigoureuse pour éviter les accès concurrents.
| Processus | Thread | |
|---|---|---|
| Espace mémoire | Isolé | Partagé |
| Communication | Coûteuse (IPC) | Directe (variables partagées) |
| Création | Lourde | Légère |
| Risque | Faible (isolation) | Problèmes de synchronisation |
En Python 3, le module threading permet de créer des threads. On instancie un objet Thread avec une fonction cible, puis on appelle start() :
import threading
def exemple():
print('Test')
threads = []
for i in range(5):
t = threading.Thread(target=exemple)
threads.append(t)
t.start()
Deux autres éléments servent dans la suite : t.join() attend que le thread t soit terminé avant de continuer, et threading.Thread(target=f, args=(300,)) crée un thread qui exécutera f(300) (le paramètre args est un tuple, d’où la virgule après 300).
Complément hors programme. Les threads et les verrous ne sont pas exigibles au baccalauréat ; ils illustrent concrètement la notion d’accès concurrent.
Le programme suivant lance 10 threads qui incrémentent chacun un compteur partagé 10 000 fois. On s’attend à obtenir 100 000 à la fin.
import threading
import time
compteur = 0
def incrementer():
global compteur
for _ in range(10_000):
valeur = compteur
time.sleep(0) # rend la main à un autre thread
compteur = valeur + 1
threads = []
for _ in range(10):
t = threading.Thread(target=incrementer)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Résultat : {compteur}")
En exécutant ce programme plusieurs fois, le résultat est très inférieur à 100 000 (souvent autour de 10 000). C’est une race condition : deux threads lisent la même valeur, l’incrémentent chacun de leur côté, puis écrivent le même résultat — une incrémentation est « perdue ».
Solution avec un verrou (Lock) :
import threading
import time
compteur = 0
verrou = threading.Lock()
def incrementer():
global compteur
for _ in range(10_000):
with verrou:
valeur = compteur
time.sleep(0)
compteur = valeur + 1
threads = []
for _ in range(10):
t = threading.Thread(target=incrementer)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Résultat : {compteur}")
Avec le verrou, un seul thread à la fois peut exécuter les trois lignes du bloc with verrou (lecture du compteur, pause, écriture). Le résultat est toujours exactement 100 000.
Exercice 1 — États et transitions
Exercice 2 — Ordonnancement FIFO
Trois processus arrivent dans l’ordre suivant :
| Processus | P1 | P2 | P3 |
|---|---|---|---|
| Durée (quanta) | 5 | 3 | 2 |
| Temps d’arrivée | 0 | 1 | 3 |
| Processus | P1 | P2 | P3 |
|---|---|---|---|
| Temps de terminaison | |||
| Temps de séjour | |||
| Temps d’attente |
Exercice 3 — Ordonnancement Round Robin
On reprend les mêmes processus, avec un quantum de temps = 2.
Exercice 4 — Interblocage
Trois processus P1, P2 et P3 utilisent trois ressources R1, R2 et R3. Le tableau ci-dessous indique l’ordre dans lequel chaque processus demande ses ressources :
| 1re demande | 2e demande | |
|---|---|---|
| P1 | R1 | R2 |
| P2 | R2 | R3 |
| P3 | R3 | R1 |
Exercice 5 — Threads et accès concurrent
On considère le programme Python suivant :
import threading
solde = 1000
def retirer(montant):
global solde
if solde >= montant:
solde = solde - montant
threads = []
for _ in range(5):
t = threading.Thread(target=retirer, args=(300,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(solde)
solde ? Justifier.solde devienne négatif ? Expliquer le scénario précis qui mène à ce résultat en utilisant les notions d’état élu et de préemption.retirer en utilisant un threading.Lock() pour empêcher ce problème.