Aller au contenu principal

Architecture & systèmes · L1 · Section 4/6

Processus et threads

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#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.

Lecture auto
Nouveau

Créé par le processus mais pas encore planifié

UI
Prêt

En file d’attente, attend un quantum CPU

Batch
En exécution

Occupe un cœur CPU

Bloqué (I/O)

Attend un événement externe ou un verrou

I/O
Terminé

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é.

Latence attendue UIFaible
Quantum5 ms
À retenir
  • 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:

cc

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.

Chargement de l’éditeur...

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 :

code

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écution

Deux 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 :

code

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        ret

Second 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.

#Quiz

Laquelle n'est PAS une condition nécessaire à un interblocage (Coffman) ?
Laquelle n'est PAS une condition nécessaire à un interblocage (Coffman) ?
Pourquoi la version avec verrou affiche-t-elle exactement 200000 à chaque exécution ?
Pourquoi la version avec verrou affiche-t-elle exactement 200000 à chaque exécution ?