Threads et synchronisation
Ce que ce chapitre apporte
- Distinguer un thread d'un processus et savoir ce que chacun partage.
- Définir ressource critique, section critique et exclusion mutuelle.
- Reconnaître une situation de compétition et expliquer pourquoi elle est intermittente.
- Énoncer les quatre conditions d'un interblocage et savoir en briser une.
- Comparer verrou d'exclusion mutuelle, sémaphore et moniteur.
- Nommer les principaux mécanismes de communication entre processus.
Thread et processus
Un thread, ou fil d'exécution, est une unité d'exécution à l'intérieur d'un processus. Un processus en contient au moins un et peut en contenir plusieurs, qui s'exécutent en parallèle.
| Processus | Threads d'un même processus | |
|---|---|---|
| Espace mémoire | séparé | partagé |
| Fichiers ouverts | propres | partagés |
| Pile d'exécution | propre | propre à chaque thread |
| Registres | propres | propres |
| Coût de création | élevé | faible |
| Coût d'une commutation | élevé | faible |
| Isolation en cas de défaut | forte | nulle |
Deux lignes de ce tableau expliquent tout le chapitre. La mémoire est partagée, ce qui rend la communication immédiate mais expose les données aux accès concurrents. Chaque thread garde sa propre pile, donc ses variables locales, qui restent privées.
C'est le contrepoint du faible coût : on gagne en performance ce qu'on perd en robustesse.
La ressource critique
Une ressource critique est une ressource partagée qui ne supporte pas les accès simultanés : une variable, une structure de données, un fichier, un périphérique.
Une section critique est la portion de code qui accède à cette ressource.
L'exclusion mutuelle est la garantie qu'un seul thread se trouve à la fois dans la section critique.
Le problème vient de ce qu'une opération qui paraît indivisible dans le code source ne l'est pas dans la machine.
compteur = compteur + 1
se decompose en trois etapes :
1. lire la valeur de compteur dans un registre
2. ajouter 1 au registre
3. ecrire le registre dans compteur
Un thread peut être interrompu entre l'étape 1 et l'étape 3. Si un second thread lit la même valeur pendant cet intervalle, les deux calculent le même résultat et l'écrivent : une incrémentation est perdue.
Le défaut apparaît en production, sous charge, et de façon non reproductible. Ajouter une trace pour le comprendre modifie souvent le rythme d'exécution et le fait disparaître.
La conclusion pratique : la correction d'un code concurrent se raisonne, elle ne se constate pas.
Les mécanismes de synchronisation
| Mécanisme | Principe | Emploi typique |
|---|---|---|
| Verrou d'exclusion mutuelle | un seul détenteur à la fois | protéger une section critique |
| Sémaphore | un compteur d'autorisations | limiter à N accès simultanés |
| Moniteur | objet dont les méthodes sont mutuellement exclusives | encapsuler la synchronisation |
| Variable de condition | attendre qu'une condition devienne vraie | producteur et consommateur |
| Opération atomique | indivisible par construction | compteur simple, sans verrou |
En C#, le mot-clé lock fournit la forme la plus courante. Il prend un objet en paramètre et garantit qu'un seul thread exécute le bloc à la fois.
Pour un compteur, la classe Interlocked offre une solution plus efficace, puisqu'elle repose sur une instruction atomique du processeur au lieu d'un verrou.
this ou sur un type expose le verrou à l'extérieur de la classe : un code appelant peut verrouiller le même objet et provoquer un blocage que rien dans la classe ne laisse prévoir.Un champ privé en lecture seule, créé pour cet unique usage, évite toute interférence.
L'interblocage
Un interblocage est une situation où plusieurs threads s'attendent mutuellement, chacun détenant une ressource que l'autre demande. Aucun ne peut progresser, et rien ne se débloquera.
Quatre conditions doivent être réunies simultanément. Elles portent le nom de conditions de Coffman.
- Exclusion mutuelle : au moins une ressource ne peut être détenue que par un seul thread.
- Détention et attente : un thread détient une ressource et en demande une autre.
- Pas de préemption : une ressource ne peut être retirée de force à celui qui la détient.
- Attente circulaire : il existe un cycle de threads attendant chacun le suivant.
Briser une seule de ces conditions suffit à rendre l'interblocage impossible.
Cette règle ne coûte rien à l'exécution et se vérifie à la relecture. Elle suppose seulement que l'ordre soit documenté et respecté, ce qui en fait une discipline d'équipe plus qu'un mécanisme technique.
Un phénomène voisin mérite d'être distingué. La famine décrit un thread qui n'obtient jamais la ressource parce que d'autres passent toujours devant. Contrairement à l'interblocage, le système progresse ; c'est seulement ce thread-là qui n'avance pas.
Communiquer entre processus
Les threads partagent la mémoire, ce qui règle la question de la communication. Deux processus, non. Plusieurs mécanismes existent, regroupés en deux familles.
| Famille | Mécanisme | Caractéristique |
|---|---|---|
| Mémoire partagée | segment de mémoire partagée | le plus rapide, synchronisation à la charge du programmeur |
| fichier projeté en mémoire | partage via le système de fichiers | |
| Passage de messages | tube, anonyme ou nommé | flux d'octets, unidirectionnel ou non |
| file de messages | messages délimités, avec priorités | |
| socket | fonctionne aussi entre machines | |
| signal | notification minimale, sans données |
Le passage de messages copie les données, ce qui coûte, et supprime en échange la question de l'accès concurrent : un message est reçu entier ou pas du tout. C'est ce qui explique sa popularité dans les architectures distribuées.
Exercices type
Que partagent deux threads d'un même processus ?
L'espace mémoire, donc les variables globales, les objets alloués sur le tas et les fichiers ouverts.
Chacun garde en propre sa pile d'exécution, donc ses variables locales et sa chaîne d'appels, ainsi que ses registres et son compteur ordinal.
Cette répartition explique une règle pratique : une variable locale n'a jamais besoin de protection, un champ d'objet accessible depuis plusieurs threads en a besoin dès qu'il est modifié.
Pourquoi compteur++ n'est-il pas sûr en environnement concurrent ?
Parce que l'opération se décompose en trois étapes machine : lire, ajouter, écrire.
Un thread peut être interrompu entre la lecture et l'écriture. Si un second lit la même valeur pendant cet intervalle, les deux calculent le même résultat et l'écrivent : une incrémentation disparaît.
Le défaut est intermittent, puisqu'il dépend d'un entrelacement précis. Deux corrections existent : entourer l'opération d'un verrou, ou employer une opération atomique fournie par la plateforme, plus efficace pour ce cas simple.
Deux threads prennent les verrous A et B dans un ordre différent. Que se passe-t-il ?
Un interblocage peut survenir. Le premier détient A et demande B, le second détient B et demande A. Les quatre conditions de Coffman sont réunies, dont l'attente circulaire.
Le mot important est « peut ». Si les deux threads ne se croisent pas, tout se passe bien, ce qui rend le défaut intermittent et difficile à reproduire.
La correction consiste à imposer un ordre global : tout thread prend toujours A avant B. L'attente circulaire devient impossible, et la règle se vérifie à la relecture du code.
Quelle différence entre interblocage et famine ?
Dans un interblocage, les threads concernés sont définitivement bloqués : aucun ne peut progresser, et la situation ne se résoudra pas d'elle-même.
Dans la famine, le système progresse. Un thread particulier n'obtient simplement jamais la ressource, parce que d'autres passent toujours devant, par exemple à cause d'une priorité plus élevée.
L'interblocage se détecte en cherchant un cycle dans le graphe d'attente. La famine ne se détecte pas ainsi, puisqu'il n'y a pas de cycle : elle se constate en mesurant des temps d'attente.
Les remèdes diffèrent aussi. Un ordre global d'acquisition contre l'interblocage, un vieillissement des priorités contre la famine.
Verrou ou sémaphore pour limiter à quatre transferts simultanés ?
Sémaphore, initialisé à quatre.
Un verrou d'exclusion mutuelle n'autorise qu'un seul détenteur : il donnerait une limite de un, pas de quatre.
Un sémaphore porte un compteur d'autorisations. Chaque thread en prend une avant de commencer et la rend à la fin. Le cinquième attend qu'une se libère.
C'est le mécanisme employé pour borner l'usage d'une ressource limitée : connexions à une base, transferts réseau simultanés, taille d'un ensemble de travailleurs.
Pourquoi les tests ne détectent-ils pas les défauts de concurrence ?
Parce que ces défauts dépendent d'un entrelacement que le programme ne contrôle pas, et qui varie avec la charge, le nombre de cœurs et les décisions de l'ordonnanceur.
Un test qui passe mille fois ne prouve pas l'absence du défaut : il prouve que le mauvais entrelacement ne s'est pas produit ces mille fois.
L'observation aggrave le problème. Ajouter une trace ou exécuter sous un débogueur modifie le rythme, et fait souvent disparaître le symptôme.
La conséquence méthodologique est nette : la correction d'un code concurrent se raisonne en identifiant les données partagées et les sections critiques. Les tests confirment, ils ne démontrent pas.
La méthode
- Liste les données partagées avant d'écrire du code concurrent. Ce sont elles, et elles seules, qui posent problème.
- Délimite les sections critiques aussi étroitement que possible, sans y inclure d'attente ni d'entrée-sortie.
- Verrouille sur un objet privé et dédié, jamais sur
thisni sur un type. - Impose un ordre global d'acquisition des verrous, et documente-le.
- Préfère une opération atomique à un verrou quand l'opération le permet.
- Ne conclus pas d'un test qui passe que le code concurrent est correct.
En résumé
- Les threads d'un processus partagent la mémoire et gardent chacun leur pile.
- Un thread qui échoue fait tomber le processus entier : aucune isolation.
- Une section critique accède à une ressource critique ; l'exclusion mutuelle garantit un seul occupant.
- Une opération apparemment indivisible se décompose en plusieurs étapes machine, d'où les situations de compétition.
- Ces défauts sont intermittents et échappent aux tests.
- Verrou pour un accès exclusif, sémaphore pour limiter à N, moniteur pour encapsuler.
- Les quatre conditions de Coffman : exclusion mutuelle, détention et attente, pas de préemption, attente circulaire. En briser une suffit.
- La parade courante est un ordre global d'acquisition des ressources.
- Interblocage : plus rien n'avance. Famine : tout avance sauf un.
- Communication entre processus par mémoire partagée, rapide et à synchroniser, ou par messages, copiés et sans accès concurrent.
Et ensuite ? Les mécanismes vus jusqu'ici supposent une seule machine. Le dernier chapitre franchit cette limite avec les sockets et la mise en conteneur.