Aller au contenu principal
genie-logicielThreads et synchronisation

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.
Où on va
Deux processus ne partagent pas leur mémoire, ce qui les isole. Deux fils d'exécution d'un même processus, si. Ce partage rend la communication immédiate et introduit une classe entière de défauts qui n'existent pas en programmation séquentielle : des résultats qui varient d'une exécution à l'autre, des blocages sans message d'erreur, des tests qui passent sur le poste du développeur et échouent en production. Ce chapitre décrit ces défauts et les mécanismes qui les évitent.

Thread et processus

Définition

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.

ProcessusThreads d'un même processus
Espace mémoireséparépartagé
Fichiers ouvertsproprespartagés
Pile d'exécutionproprepropre à chaque thread
Registresproprespropres
Coût de créationélevéfaible
Coût d'une commutationélevéfaible
Isolation en cas de défautfortenulle

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.

L'absence d'isolation
Un processus qui plante n'entraîne pas les autres, le système le termine et récupère ses ressources. Un thread qui provoque une exception non traitée fait généralement tomber le processus entier, avec tous ses threads.
C'est le contrepoint du faible coût : on gagne en performance ce qu'on perd en robustesse.

La ressource critique

Définitions

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.

main.py
Sortie
>_ Prêt à exécuter…
Pourquoi ces défauts échappent aux tests
Une situation de compétition ne se produit que pour certains entrelacements, et la probabilité qu'ils surviennent dépend de la charge, du nombre de cœurs et de l'ordonnanceur. Sur un poste de développement peu chargé, le mauvais entrelacement peut ne jamais se produire.
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écanismePrincipeEmploi typique
Verrou d'exclusion mutuelleun seul détenteur à la foisprotéger une section critique
Sémaphoreun compteur d'autorisationslimiter à N accès simultanés
Moniteurobjet dont les méthodes sont mutuellement exclusivesencapsuler la synchronisation
Variable de conditionattendre qu'une condition devienne vraieproducteur et consommateur
Opération atomiqueindivisible par constructioncompteur 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.

C#
private readonly object _verrou = new object();
private int _compteur;
public void Incrementer()
{
lock (_verrou)
{
_compteur++;
}
}

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.

Sur quoi verrouiller
Le verrou doit porter sur un objet privé et dédié. Verrouiller sur 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

Définition

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.

  1. Exclusion mutuelle : au moins une ressource ne peut être détenue que par un seul thread.
  2. Détention et attente : un thread détient une ressource et en demande une autre.
  3. Pas de préemption : une ressource ne peut être retirée de force à celui qui la détient.
  4. Attente circulaire : il existe un cycle de threads attendant chacun le suivant.

Briser une seule de ces conditions suffit à rendre l'interblocage impossible.

main.py
Sortie
>_ Prêt à exécuter…
La parade la plus employée
Imposer un ordre global sur les ressources, et exiger que tout thread les acquière dans cet ordre. Si tout le monde prend toujours le verrou A avant le verrou B, aucun cycle ne peut se former.
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.

FamilleMécanismeCaractéristique
Mémoire partagéesegment de mémoire partagéele plus rapide, synchronisation à la charge du programmeur
fichier projeté en mémoirepartage via le système de fichiers
Passage de messagestube, anonyme ou nomméflux d'octets, unidirectionnel ou non
file de messagesmessages délimités, avec priorités
socketfonctionne aussi entre machines
signalnotification minimale, sans données
Le compromis entre les deux familles
La mémoire partagée est la plus rapide puisqu'aucune copie n'a lieu. Elle laisse en revanche toute la synchronisation au programmeur, avec les risques décrits plus haut.
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

  1. Liste les données partagées avant d'écrire du code concurrent. Ce sont elles, et elles seules, qui posent problème.
  2. Délimite les sections critiques aussi étroitement que possible, sans y inclure d'attente ni d'entrée-sortie.
  3. Verrouille sur un objet privé et dédié, jamais sur this ni sur un type.
  4. Impose un ordre global d'acquisition des verrous, et documente-le.
  5. Préfère une opération atomique à un verrou quand l'opération le permet.
  6. 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.

Threads et synchronisation | Plateforme ETS