Architecture & systèmes · L1 · Section 4/6
Processus et threads
Progression
#Processus et threads
Les processus et les threads sont les deux abstractions fondamentales pour structurer l'exécution des programmes. Les processus offrent une isolation forte avec des espaces mémoire séparés, tandis que les threads permettent une exécution légère et parallèle au sein d'un même processus. Cette organisation permet de paralléliser les tâches et d'améliorer la réactivité des applications, le noyau se chargeant de planifier leur exécution et de gérer l'accès aux ressources partagées.
#Prérequis et objectifs
Prérequis: page « Systèmes d'exploitation » (appels système, ordonnancement), bases de Python.
Objectifs d'apprentissage:
- distinguer processus et thread du point de vue de l'espace d'adressage et des ressources possédées;
- expliquer pourquoi un thread défaillant peut emporter tout son processus, mais pas un autre processus;
- protéger une section critique avec un mutex, et reproduire une condition de course pour la constater;
- citer les quatre conditions de Coffman d'un interblocage et en déduire des stratégies de prévention.
#Processus vs threads
Un processus est un programme en cours d'exécution avec son propre espace mémoire isolé, ses descripteurs de fichiers et son PID. Cette séparation offre une protection forte: un processus défaillant n'affecte pas directement les autres. À l'inverse, les threads sont des unités d'exécution plus légères qui partagent l'espace mémoire de leur processus parent: tas, variables globales, descripteurs ouverts. Ce partage les rend rapides à créer et à commuter, mais impose une synchronisation explicite pour éviter les conflits sur les données communes.
Règle de décision pratique: l'isolation et la tolérance aux pannes appellent des processus (un crash reste local), le parallélisme sur des données partagées appelle des threads (au prix de la discipline de synchronisation). La frontière rejoint les mécanismes vus côté OS: fork/exec d'un côté, clone de l'autre.
Cycle de vie des threads
Visualisez les transitions prêt/exécution/blocage pour différents profils de charge. Chaque jeton représente un thread concret.
Créé par le processus mais pas encore planifié
En file d’attente, attend un quantum CPU
Occupe un cœur CPU
Attend un événement externe ou un verrou
A libéré ses ressources
Création des threads
L’interface utilisateur (UI) vient d’être lancée, un worker I/O attend des requêtes disque, la tâche batch est en attente.
Le scheduler place UI devant pour respecter la réactivité.
- La priorité adaptative garde l’interface fluide sans affamer le batch.
- Le worker I/O retourne au pool après avoir signalé l’UI.
#Ordonnancement
L'ordonnanceur décide quel thread ou processus s'exécute sur chaque cœur, et pour combien de temps. Les politiques se divisent en deux familles: préemptives, où le système peut interrompre une tâche en cours, et coopératives, où les tâches cèdent volontairement le contrôle. Les algorithmes classiques incluent le tourniquet (Round-Robin) qui alterne équitablement, l'ordonnancement par priorités qui favorise les tâches critiques, et les files multi-niveaux qui combinent plusieurs stratégies. Les systèmes modernes exploitent aussi l'affinité CPU pour maintenir un thread sur le même cœur et profiter de la localité des caches L1/L2: migrer un thread revient à rejouer des cache-miss.
#Synchronisation
Lorsque plusieurs threads accèdent à des données partagées, il faut synchroniser les accès pour préserver la cohérence. Les primitives classiques sont le mutex (exclusion mutuelle: un seul thread dans la section critique à la fois), le sémaphore (contrôle l'accès à un pool de N ressources) et les barrières ou moniteurs (coordination de groupes). Chaque section critique doit être protégée de manière à préserver les invariants des structures manipulées, et rester courte: un verrou trop long sérialise le programme et annule le bénéfice du parallélisme.
Exemple (pseudo-code C) d'une section critique protégée par mutex:
1mutex m;2int compteur = 0;3 4void travail() {5 lock(m);6 // section critique: invariant "compteur = nombre d'increments reussis"7 compteur += 1;8 unlock(m);9}#Expérience: condition de course observable
L'expérience suivante fait courir deux threads d'incrémentations en parallèle. Sans verrou, compteur += 1 n'est pas atomique (lire, incrémenter, écrire) et des mises à jour se perdent; avec verrou, le total est exact.
Vérification observable: la ligne « sans verrou » affiche presque toujours un total inférieur à 200000, et différent d'une exécution à l'autre; la ligne « avec verrou » affiche exactement 200000 à chaque exécution. Variantes à essayer: réduisez N à 1000 et constatez que l'écart peut disparaître (moins de fenêtres de chevauchement); exécutez le même code en un seul thread pour vérifier que le problème vient de la concurrence et non de l'arithmétique.
#Ce qu'un changement de contexte a de matériel
Le noyau « sauvegarde et restaure le contexte » : derrière cette phrase se cache un mécanisme très concret, le même que celui d'un simple appel de sous-programme. Un processeur en cours d'exécution a un état — le compteur programme, les registres de travail, le registre d'état — et changer de fil d'exécution consiste à mettre cet état de côté puis à installer celui d'un autre.
L'appel de sous-programme en donne la version minimale. L'instruction d'appel empile l'adresse de retour, l'instruction de retour la dépile :
1main: ldi r16, 0x202 ldi r18, 43 rcall addition ; empile l'adresse de retour, saute dans addition4 ...5addition: add r16, r18 ; r16 vaut désormais 246 ret ; dépile l'adresse de retour et reprend l'exécutionDeux enseignements à en tirer, tous deux fondamentaux pour la suite.
Premier point : le sous-programme doit sauvegarder ce qu'il abîme. Ici, addition modifie r16 sans prévenir : l'appelant retrouve r16 = 24 au lieu de 0x20. C'est la version matérielle d'un effet de bord, et la parade est la même qu'en langage de haut niveau — sauvegarder au début, restaurer à la fin :
1sous-programme:2 push r13 push r24 push r35 push r46 ; ... instructions qui utilisent r1 à r4 ...7 pop r48 pop r39 pop r210 pop r111 retSecond point : l'ordre de restauration est l'inverse de l'ordre de sauvegarde, parce que la pile est une structure LIFO — dernier entré, premier sorti. Empiler r1 puis r2 puis r3 puis r4 oblige à dépiler r4, r3, r2, r1. Inverser cet ordre ne provoque pas une erreur de compilation : le programme se contente de restaurer les mauvaises valeurs dans les mauvais registres, ce qui produit un bogue intermittent particulièrement difficile à traquer.
Le lien avec les processus est direct. Un appel de sous-programme empile une adresse de retour et quelques registres ; un changement de contexte empile tous les registres, le PC et le registre d'état, dans une structure dédiée, puis restaure ceux d'un autre fil d'exécution. Le noyau ne fait rien d'autre que ce que fait push/pop, à plus grande échelle et à ceci près qu'il décide quand commuter. C'est pourquoi la pile d'un processus est une ressource critique : elle doit être assez grande pour contenir toute la profondeur d'appels imbriqués — et c'est pourquoi un débordement de pile ne plante pas seulement la fonction en cours, mais corrompt tout ce qui suit.
#Pièges
La programmation concurrente expose à plusieurs pièges classiques. Les interblocages (deadlocks) surviennent lorsque des threads s'attendent mutuellement de manière circulaire, paralysant l'ensemble. Les conditions de course apparaissent quand le résultat dépend de l'ordre d'exécution non déterministe des threads. L'inversion de priorité se produit lorsqu'un thread de faible priorité détenant un verrou bloque indirectement un thread de haute priorité. Ces problèmes se traitent à la conception: ordre global des verrous, sections critiques minimales, et vérification par des outils dédiés (helgrind de Valgrind, thread sanitizers), car un programme qui « semble marcher » peut contenir une course latente qui se déclarera en production.
#Exercice observable (interblocage)
Écrivez deux threads: T1 prend verrou_A puis veut verrou_B; T2 prend verrou_B puis veut verrou_A. Lancez: le programme se bloque indéfiniment (interrompez-le avec Ctrl-C). Corrigez en imposant que les deux threads prennent les verrous dans le même ordre (A puis B): le blocage disparaît. Vous venez de mettre en œuvre la prévention de l'« attente circulaire », l'une des quatre conditions de Coffman.