← Tous les cours / Synchronisation & Interblocages Cours
00 — VUE D'ENSEMBLE

Ce que le partage te prend

Le problème n'est pas de poser des verrous. C'est de savoir lesquels, et où.

Le cours Processus & Threads s'arrêtait au bord de ce précipice. On y a vu que plusieurs threads d'un même processus partagent la mémoire, qu'ils sont légers, qu'ils basculent vite. Tout cela reste vrai. Ce cours parle du prix.

Et ce prix n'est pas celui qu'on croit. On imagine que la difficulté est technique — apprendre des primitives, poser des verrous aux bons endroits, connaître trois ou quatre pièges. La difficulté réelle est ailleurs, et elle est plus profonde : tu perds une façon de raisonner à laquelle tu ne savais même pas que tu tenais.

Ce que tu perds exactement
En mono-thread, tu peux affirmer : « entre ces deux lignes, personne n'a touché à cet objet ». Cette phrase est la base tacite de presque tout ce que tu sais sur ton code. Dès qu'un second thread partage la mémoire, elle devient fausse.

Poser un verrou est facile. Savoir quel invariant protéger, et à quelles frontières il doit tenir, l'est beaucoup moins. C'est pour ça que ce cours consacre sa première page à un raisonnement plutôt qu'à une primitive.

Le trajet

On part de ce que tu perds, puis on regarde le phénomène de très près — une incrémentation, ralentie jusqu'à voir chacune de ses trois étapes. De là on remonte vers les outils, du plus simple au plus subtil, avant d'aborder les impasses : ces situations où le remède devient le mal, et où tout s'arrête pour de bon.

🧪Les démonstrations de ce cours ne sont pas scénarisées. Quand tu verras un incrément se perdre, c'est parce que deux tâches auront réellement lu la même case au même moment — et le résultat changera d'un essai à l'autre. Quand tu verras un interblocage, il aura été détecté, pas annoncé.

Par où commencer

01 — CE QUE TU PERDS

La perte du raisonnement local

Avant les verrous, avant les primitives : comprendre quelle certitude vient de disparaître.

Lis ces deux lignes et dis-moi ce qu'elles font.

// solde vaut 100 en entrant
if (solde >= 50) {
    solde = solde - 50;   // on ne peut pas passer sous zéro… si ?
}

En mono-thread, ce code est irréprochable, et tu peux le démontrer. Le test garantit qu'il reste au moins cinquante ; la soustraction retire cinquante ; donc le solde ne passe jamais sous zéro. La démonstration tient parce qu'elle s'appuie sur une prémisse que tu n'as même pas formulée : entre le test et la soustraction, rien ne change.

Mets deux threads là-dedans et la prémisse tombe. Les deux peuvent passer le test alors que le solde vaut cent, puis retirer cinquante chacun. Le solde tombe à zéro — ou pire, avec des montants différents, il devient négatif. Le code n'a pas changé. Ta démonstration, elle, ne vaut plus rien.

Le raisonnement local
La faculté de raisonner sur un fragment de code en ne regardant que lui. Elle repose entièrement sur une hypothèse d'exclusivité : pendant que j'exécute ces lignes, l'état que je manipule n'est modifié par personne d'autre.

Les invariants changent de statut

Un invariant, c'est une propriété que ton code s'engage à maintenir vraie. « Le solde n'est jamais négatif. » « La liste et son compteur de taille sont d'accord. » « Tout élément retiré de la file a bien été traité. »

En mono-thread, il suffit que tes invariants soient vrais au repos — entre deux opérations complètes. À l'intérieur d'une opération, tu as le droit de les casser temporairement : tu ajoutes un élément à la liste, puis tu incrémentes le compteur, et pendant l'instant qui sépare les deux, la liste ment sur sa taille. Personne ne le voit, donc personne n'en souffre.

🧠Voilà ce qui change, et c'est tout le cours en une phrase : à plusieurs threads, tes invariants doivent tenir à chaque frontière entre deux instructions, et non plus seulement au repos. Car à chaque frontière, un autre thread peut regarder.

C'est un changement d'exigence brutal. Ton code ne comporte pas deux ou trois points d'observation, il en comporte autant qu'il a d'instructions machine — et tu n'écris pas des instructions machine, tu écris des lignes qui s'en traduisent en plusieurs. La page suivante montre à quel point cette traduction est trompeuse.

Pourquoi c'est là que réside la difficulté

On présente souvent la programmation concurrente comme un catalogue de primitives à apprendre. C'est une erreur de perspective. Les primitives sont peu nombreuses, bien documentées, et leur usage s'apprend en une après-midi. Ce qui est difficile, c'est de répondre à la question qu'aucune primitive ne pose à ta place : quel invariant, exactement, suis-je en train de protéger, et entre quels points précis doit-il tenir ?

💡Le corollaire encourageant : un état qu'on ne partage pas n'a aucun invariant à protéger. Toutes les stratégies qu'on verra à la fin de ce cours — l'immutabilité, le cloisonnement, les files de messages — consistent à réduire la surface de partage plutôt qu'à mieux la garder. C'est presque toujours le meilleur investissement.
02 — UNE SEULE LIGNE, TROIS ÉTAPES

L'atomicité

Incrémenter un compteur n'est pas une opération. C'en est trois, et l'intervalle entre elles est béant.

Tu écris compteur++. Une ligne, un caractère de plus qu'un nom de variable, et l'impression très nette d'un geste unique. Le processeur, lui, ne connaît pas ce geste. Il connaît trois opérations distinctes, qu'il exécute l'une après l'autre.

// ce que tu écris
compteur++;

// ce que la machine fait
LOAD  r0, compteur   // lire la valeur en mémoire
ADD   r0, 1          // la modifier dans un registre
STORE compteur, r0   // la réécrire en mémoire
Opération atomique
Une opération qu'aucun autre thread ne peut observer à moitié faite. Elle a eu lieu, ou elle n'a pas eu lieu — jamais d'état intermédiaire visible. Une incrémentation ordinaire n'est pas atomique.

Le scénario qui perd une écriture

Entre le LOAD et le STORE, la valeur lue vit dans un registre — et les registres, on l'a vu, appartiennent au thread. Chaque thread a donc sa copie privée d'une valeur qu'il croit à jour. Si l'ordonnanceur intervient au mauvais moment, voici ce qui arrive.

InstantThread AThread BEn mémoire
1lit 77
2lit 77
3calcule 87
4calcule 87
5écrit 88
6écrit 88

Deux incrémentations demandées, une seule effectuée. La seconde écriture n'a pas été perdue en route ni corrompue : elle a écrit exactement ce qu'on lui avait dit d'écrire. Le problème est qu'elle s'appuyait sur une lecture devenue périmée entre-temps.

🔑Ce défaut a un nom, la lost update, et il ne demande aucune malchance exceptionnelle. Il suffit que deux threads touchent la même donnée sans coordination : l'entrelacement fautif finira par arriver, et d'autant plus vite que la machine est chargée.

Vois-le se produire

Deux threads incrémentent cinq fois le même compteur. Le total devrait faire dix. Lance, puis relance — et note que la valeur finale change.

Surveille les registres r0 des deux threads. Quand ils affichent la même valeur en même temps, la perte est déjà inévitable : les deux vont écrire ce même nombre.

Ce n'est pas une malchance, c'est une distribution

Un essai isolé prouve peu de chose. Le bouton ci-dessous exécute deux cents fois la même situation, sans affichage, et compte les résultats obtenus. C'est la même machine que la démo ci-dessus, dépouillée de son rendu.

total attendu : 10
📊Ce que la distribution t'apprend : le bogue n'a pas un comportement, il en a une gamme. C'est exactement pour cette raison qu'un défaut de concurrence résiste aux tests. Il ne se produit pas toujours, il ne se produit pas pareil, et il se produit d'autant plus que la machine est chargée — donc en production plutôt que sur ton poste.
03 — NOMMER LE PROBLÈME

Sections critiques et courses

Deux termes qu'on emploie l'un pour l'autre, et qui ne désignent pourtant pas la même chose.

Maintenant qu'on a vu le phénomène, il faut le nommer correctement — parce que deux notions voisines se cachent derrière le même mot, et les distinguer change la façon de chercher les bogues.

La section critique

Section critique
Un fragment de code qui manipule un état partagé et qui doit s'exécuter sans être entrelacé avec un autre fragment touchant le même état. C'est l'unité que tu vas protéger.

Repère bien le mot doit. Une section critique n'est pas une propriété que le code possède ; c'est une exigence que tu formules. Le compilateur ne la voit pas, la machine ne la devine pas. Toi seul sais que ces trois lignes forment un tout indivisible, et c'est pour ça que la concurrence ne s'automatise pas.

Deux fautes, souvent confondues

La première est mécanique et se détecte à la machine. La seconde est logique et ne se détecte qu'en comprenant l'intention.

Course de donnéesCourse critique
NatureDeux accès concurrents à la même case, dont au moins un en écriture, sans synchronisationLe résultat dépend de l'ordre dans lequel les threads s'exécutent
Se détecteoutillé — des détecteurs le trouvent mécaniquementnon outillé — il faut connaître l'intention du code
ExempleDeux compteur++ sans verrouVérifier un solde puis le débiter, chaque étape correctement verrouillée mais séparément
⚠️Le piège de raisonnement à éviter absolument : supprimer toutes les courses de données ne supprime pas les courses critiques. Tu peux protéger parfaitement chaque accès individuel et garder un bogue entier — c'est même le cas le plus fréquent en pratique, et le plus difficile à trouver.

L'illustration qui dessille

Reprends le solde de la page 01, et suppose que tu aies fait les choses « bien » : chaque accès à la variable partagée est encadré par un verrou.

// Chaque accès est protégé. Et pourtant c'est faux.
verrouiller();  s = solde;  déverrouiller();   // lecture protégée

if (s >= 50) {
    verrouiller();  solde = solde - 50;  déverrouiller();  // écriture protégée
}

Aucune course de données ici : tous les accès sont synchronisés, et un détecteur automatique ne signalera rien. Le bogue est intact. Entre le déverrouillage de la lecture et le verrouillage de l'écriture, un autre thread a tout le loisir de débiter le compte — et la décision prise sur s s'appuie désormais sur une information périmée.

🎯La leçon, et elle vaut pour tout ce cours : ce n'est pas l'accès qu'il faut protéger, c'est la décision. La section critique commence à la lecture qui fonde le choix et se termine à l'écriture qui l'applique. Découper cette section en deux morceaux correctement verrouillés ne protège rien.
04 — LE MUTEX

Le mutex, et ce qu'il coûte

Un seul à la fois. L'outil le plus simple, et deux réglages qui décident de tout.

L'outil de base porte un nom qui dit exactement ce qu'il fait : exclusion mutuelle. Un thread le prend, les autres attendent, il le rend, un autre le prend. Tant qu'un thread le détient, personne d'autre n'entre dans la section qu'il protège.

Mutex
Un jeton unique. Prendre le jeton avant d'entrer dans une section critique, le rendre en sortant. Un thread qui trouve le jeton pris attend qu'il se libère.

Le coût réel, qui n'est pas celui qu'on imagine

On répète souvent que « les verrous coûtent cher », ce qui est vrai et faux à la fois — et la nuance est technique. Prendre un mutex libre ne demande pas de passer par le noyau : l'opération se fait entièrement en espace utilisateur, avec une instruction atomique du processeur qui bascule l'état du verrou de « libre » à « pris ». C'est court, quelques nanosecondes.

Le coût apparaît quand le verrou est déjà pris. Là, le thread doit s'endormir, et endormir un thread est une opération du noyau. Il faut le sortir de la file d'exécution, le réveiller plus tard quand le verrou se libère, le remettre en file. C'est bien plus long que la prise du verrou elle-même.

💡La formulation juste : ce n'est pas le verrou qui coûte cher, c'est la contention — le fait que plusieurs threads le veuillent en même temps. Un verrou peu disputé est presque gratuit. Le même verrou, disputé par vingt threads, devient le goulot de ton programme.

La granularité, ou l'arbitrage central

De cette observation découle la seule décision qui compte vraiment quand tu poses un verrou : jusqu'où l'étendre ?

Un verrou large, qui couvre toute une fonction, est facile à écrire et facile à démontrer correct. Il sérialise en revanche tout ce qu'il couvre — y compris les parties qui n'avaient pas besoin de protection, comme une attente réseau. Pendant ce temps, tous les autres threads patientent pour rien.

Un verrou étroit, qui n'entoure que la lecture-modification-écriture, laisse les threads travailler en parallèle sur tout le reste. Il demande en échange de réfléchir davantage : chaque sortie de section critique est un point où l'état peut changer sous tes pieds, et c'est exactement le piège de la page précédente.

⚠️Le réflexe dangereux : ne jamais tenir un verrou pendant une opération lente — une requête réseau, une lecture disque, un appel à une API. Le thread qui dort avec le jeton en poche bloque tous les autres pendant toute la durée de son attente.

Mesure la contention

Le bouton ci-dessous exécute la même charge de travail avec un nombre croissant de threads, dans deux variantes : un verrou qui couvre aussi une attente, et un verrou réduit à la seule incrémentation. Les deux donnent un résultat correct — c'est le temps total qui les sépare.

de 2 à 6 threads, chaque variante exécutée sans rendu

Un piège de spécification qui surprend

Que se passe-t-il si un thread tente de reprendre un verrou qu'il détient déjà ? La réponse dépend du type de mutex, et le cas par défaut est le plus déroutant des quatre.

Type de mutexReprise par son propre détenteur
Normalinterblocage — le thread s'attend lui-même, pour toujours
Avec vérificationerreur retournée — tu es prévenu
Récursifcompteur incrémenté — il faudra rendre autant de fois
Par défautcomportement indéfini — la spécification autorise n'importe lequel des trois
📜La dernière ligne n'est pas une approximation de ma part : la spécification POSIX indique que le comportement du type par défaut peut correspondre à l'un des trois autres. Autrement dit, un code qui reprend son propre verrou peut fonctionner sur ta machine et s'interbloquer ailleurs. Si tu as besoin de récursivité, demande-la explicitement.
05 — COMPTER ET ATTENDRE

Sémaphore et variable de condition

Le mutex répond à « un seul à la fois ». Voici les outils pour « pas plus de N » et « pas avant que ».

Le mutex résout un problème précis et un seul : l'accès exclusif. Beaucoup de situations réelles posent une question différente, et forcer le mutex à y répondre produit du code tordu. Deux autres outils existent, et savoir lequel choisir tient à la question que tu poses.

Le sémaphore : pas plus de N à la fois

Sémaphore
Un compteur d'autorisations, dont la valeur ne descend jamais sous zéro. Prendre une autorisation la décrémente ; s'il n'y en a plus, on attend. La rendre incrémente le compteur et réveille éventuellement quelqu'un.

L'image la plus parlante est celle d'un parking dont l'entrée affiche le nombre de places libres. À zéro, la barrière reste fermée et les voitures font la queue ; chaque sortie décrémente la file d'attente d'un cran. Personne ne se soucie de savoir quelle place est prise — seul le compte importe.

C'est exactement ce qu'il te faut pour limiter un accès à une ressource dont tu as plusieurs exemplaires. Un pool de connexions à une base, un nombre maximal de requêtes sortantes simultanées, un lot de tampons réutilisables : dans tous ces cas, tu ne veux pas l'exclusivité, tu veux un plafond.

🔢Un sémaphore initialisé à un se comporte comme un mutex, ce qui explique qu'on les confonde. La différence est ailleurs : un mutex a un propriétaire — seul le thread qui l'a pris peut le rendre. Un sémaphore n'en a pas, n'importe qui peut le créditer. Cette absence de propriétaire est parfois exactement ce qu'on veut, et parfois la source d'un bogue difficile.

La variable de condition : pas avant que

Le troisième besoin est d'une autre nature. Il ne s'agit plus de protéger ni de plafonner, mais d'attendre qu'un état devienne vrai. Un consommateur veut attendre qu'une file cesse d'être vide. Un worker veut attendre qu'on lui donne du travail.

La tentation naturelle est d'écrire une boucle qui vérifie, et vérifie encore. C'est une très mauvaise idée : ce thread consomme du processeur à plein régime pour ne rien faire d'autre que constater que rien n'a changé. Il occupe un siège qu'il vole aux threads qui, eux, pourraient faire avancer la condition qu'il attend.

Variable de condition
Un mécanisme d'attente passive associé à un état. Le thread qui attend s'endort en libérant le verrou qui protège cet état ; celui qui modifie l'état signale le changement, ce qui réveille les dormeurs.

Le détail qui rend cet outil correct est le fait que l'attente libère le verrou. Sans ça, le dormeur garderait la protection de l'état qu'il attend, et personne ne pourrait le faire évoluer — un interblocage immédiat, et c'est justement le sujet de la page 07.

⚠️La règle que tout le monde oublie une fois : il faut toujours revérifier la condition après le réveil, dans une boucle. Un réveil ne garantit pas que la condition soit vraie — plusieurs dormeurs peuvent être réveillés pour une seule ressource disponible, et il existe même des réveils spontanés. Un réveil est une invitation à revérifier, pas une preuve.

Choisir l'outil

La question que tu posesL'outil
Un seul thread à la fois dans cette sectionMutex
Pas plus de N threads sur cette ressourceSémaphore
Attendre que cet état devienne vraiVariable de condition
Une seule opération, indivisibleOpération atomique — page 06
06 — SANS VERROU

Opérations atomiques et visibilité

Se passer de verrou quand l'opération est assez simple — et pourquoi « déclarer volatile » ne suffit jamais.

Verrouiller pour incrémenter un compteur peut sembler disproportionné, et l'intuition est bonne : le processeur sait faire certaines opérations de façon indivisible, sans qu'on ait à endormir personne.

La compare-and-swap

L'instruction fondatrice ne se contente pas d'écrire. Elle écrit à condition que la valeur soit encore celle qu'on croit — et le tout en un geste que rien ne peut interrompre.

Compare-and-swap (« comparer-et-échanger »)
« Si cette case contient encore la valeur que j'ai lue, remplace-la par celle-ci ; sinon ne fais rien et dis-le-moi. » Indivisible, garantie par le matériel.

Ce conditionnel résout exactement le problème de la page 02. Le thread qui avait lu une valeur périmée voit sa tentative refusée, et il peut recommencer avec la nouvelle valeur. D'où le motif habituel : lire, calculer, tenter d'échanger, et boucler tant que l'échange est refusé.

// le motif, en pseudo-code
do {
    ancienne = compteur;              // lire
    nouvelle = ancienne + 1;        // calculer
} while (!comparerEtEchanger(compteur, ancienne, nouvelle));
💡Ce qu'on gagne, ce qu'on perd : aucun thread ne s'endort jamais, donc aucun appel au noyau et aucun risque qu'un thread endormi bloque les autres. En échange, sous forte contention, les threads tournent en boucle à refaire un calcul qui échoue — ils brûlent du processeur au lieu d'attendre tranquillement. Ce n'est pas gratuit, c'est un autre profil de coût.

Le piège du mot-clé qui ne synchronise rien

Il existe dans plusieurs langages un mot-clé qui promet de « rendre une variable visible entre threads », et il est massivement mal compris. En C et en C++, volatile demande au compilateur de ne pas garder la valeur dans un registre et de relire la mémoire à chaque accès. C'est tout.

⚠️Ce que volatile ne donne pas : l'atomicité. Un compteur++ sur une variable volatile reste trois opérations distinctes, donc reste vulnérable à la lost update exactement comme avant. Le mot-clé traite un problème de compilation, pas un problème de concurrence.

Attention toutefois : ce mot s'écrit pareil et signifie autre chose selon les langages. En Java, il porte en plus des garanties d'ordonnancement mémoire qu'il n'a pas en C. Ne transpose donc jamais une intuition d'un langage à l'autre sur ce point précis — vérifie ce que ton langage promet.

L'ordonnancement mémoire, en survol assumé

Il reste une couche sous celle-ci, et je vais être franc sur les limites de ce que ce cours peut en dire utilement. Ni le compilateur ni le processeur n'exécutent tes écritures dans l'ordre où tu les as écrites : ils réordonnent tant que le résultat reste identique pour un seul thread. À plusieurs threads, cette liberté devient observable — un thread peut voir tes écritures dans un ordre différent de celui de ton code.

Barrière mémoire
Une instruction qui interdit certains réordonnancements autour d'elle, et impose donc un ordre observable par les autres threads.
🎓La bonne nouvelle, et le bon conseil : tu n'as presque jamais à manipuler ces barrières directement. Les mutex et les opérations atomiques des bibliothèques standard les posent déjà pour toi, et c'est précisément une des choses qu'on achète en les utilisant. Écrire du code sans verrou en plaçant ses propres barrières est un domaine d'expertise à part entière, où l'intuition se trompe presque toujours. Si tu t'y aventures, appuie-toi sur la spécification de ton langage, pas sur un raisonnement.
07 — L'IMPASSE

L'interblocage

Deux threads polis, chacun attendant que l'autre finisse. Plus rien n'avance, et rien ne plante.

Le remède devient le mal. Tu as mis des verrous pour protéger tes invariants, et voilà que ton programme s'arrête — sans erreur, sans trace, sans consommer de processeur. Tout le monde attend quelqu'un.

Interblocage
Un ensemble de threads dont chacun attend une ressource détenue par un autre du même ensemble. Aucun ne peut avancer, aucun ne peut renoncer, et la situation ne se résoudra jamais d'elle-même.

Le scénario minimal

Il suffit de deux threads et de deux verrous acquis dans un ordre différent. T1 prend A puis veut B ; T2 prend B puis veut A. Chacun tient ce que l'autre attend, et attend ce que l'autre tient.

🔁Remarque l'ironie : les deux threads sont parfaitement corrects pris séparément. Chacun verrouille avant d'accéder, chacun déverrouille en sortant, chacun protège bien ses invariants. Le défaut n'est dans aucun des deux — il est dans leur rencontre. C'est pour ça qu'un interblocage résiste à la relecture d'une fonction isolée.

Les quatre conditions

Un interblocage exige quatre circonstances réunies simultanément. L'intérêt de les connaître est pratique : pour l'empêcher, il suffit d'en casser une.

ConditionCe qu'elle veut dire
1Exclusion mutuelleLa ressource ne peut être tenue que par un seul à la fois
2Détention et attenteUn thread garde ce qu'il a déjà tout en réclamant autre chose
3Non-préemptionOn ne peut pas confisquer un verrou à celui qui le détient
4Attente circulaireLa chaîne des attentes forme une boucle

Vois-le se refermer

Deux montages, les mêmes verrous, le même travail. À gauche l'ordre d'acquisition est croisé ; à droite les deux threads prennent les verrous dans le même ordre. Lance-les tous les deux.

Ordre croisé

Ordre total

🎯Rien n'est scripté ici. L'impasse est constatée par le moteur : plus aucune tâche n'est exécutable et au moins une attend un verrou, donc rien ne peut plus avancer. Le second montage, lui, termine — avec exactement le même code, à l'ordre d'acquisition près.

Pourquoi c'est si difficile à attraper

Regarde le programme du simulateur : entre la première prise et la seconde, il y a une attente. C'est elle qui laisse à l'autre thread le temps de s'installer dans le piège. Retire-la, et l'entrelacement fautif devient rare — mais pas impossible.

⏱️Voilà le vrai visage du problème : un interblocage dépend d'une fenêtre temporelle. Sur ton poste, avec deux requêtes de test, elle ne s'ouvre jamais. En production, avec des centaines de requêtes concurrentes et des latences variables, elle finit par s'ouvrir — un mardi à dix-huit heures. Le bogue n'est pas apparu, il était là depuis le début.
08 — CASSER UNE CONDITION

Éviter l'interblocage

Quatre conditions nécessaires, donc quatre prises possibles. Une seule est vraiment praticable.

Puisqu'il faut les quatre conditions réunies, il suffit d'en empêcher une. Elles ne se valent pourtant pas du tout en pratique, et l'une domine largement les autres.

L'ordre total d'acquisition — la bonne réponse

La règle
Choisis un ordre global sur tous tes verrous, et impose que chaque thread les prenne toujours dans cet ordre. L'attente circulaire devient alors impossible par construction.

La démonstration tient en une ligne, et c'est ce qui fait la force de cette approche : pour qu'un cycle existe, il faudrait qu'un thread attende un verrou situé plus bas dans l'ordre que celui qu'il détient. La règle l'interdit, donc le cycle ne peut pas se former. Ce n'est pas une réduction du risque, c'est une élimination.

L'ordre lui-même est arbitraire — par nom, par adresse mémoire, par numéro attribué à la main. Ce qui compte est qu'il soit total, documenté, et respecté partout. C'est exactement ce que fait le second montage de la page précédente.

📋La difficulté est organisationnelle, pas technique : l'ordre doit être connu de tout le code, y compris celui que tu n'as pas écrit. Un verrou pris dans une bibliothèque tierce, ou dans un rappel que tu appelles depuis une section critique, échappe à ta discipline — et c'est de là que viennent la plupart des interblocages réels.

Le timeout — le filet de sécurité

Plutôt que d'attendre indéfiniment, on peut demander un verrou avec une limite de temps. Passé ce délai, l'appel renonce, et le thread peut relâcher ce qu'il détenait avant de réessayer plus tard.

Cette technique casse la troisième condition en rendant la détention révocable — par le détenteur lui-même. Elle a un mérite décisif : elle transforme un blocage définitif et silencieux en une erreur visible, que tu peux journaliser et compter. C'est déjà une victoire énorme sur le plan de l'exploitation.

⚠️Mais ce n'est pas une solution, c'est une atténuation. Le cycle se reforme au prochain essai, et tu peux tourner longtemps entre échec et nouvelle tentative. Utilise le délai pour détecter et alerter, pas pour te dispenser d'un ordre d'acquisition.

Les deux autres prises, pour être complet

Condition casséeCommentVerdict
Exclusion mutuelleSupprimer le partage : donnée immuable, copie par thread, opération atomiqueexcellent quand c'est possible
Détention et attentePrendre tous les verrous nécessaires d'un coup, ou aucunrigide, et il faut tous les connaître d'avance
Non-préemptionTimeout puis abandondétecte, ne résout pas
Attente circulaireOrdre total d'acquisitionla réponse de référence
🔑La première ligne mérite plus d'attention qu'on ne lui en accorde. Supprimer le partage plutôt que l'encadrer règle le problème à la racine, et c'est presque toujours le meilleur investissement — c'est le sujet de la page 10.
09 — LES FAUX INTERBLOCAGES

Famine, livelock, inversion de priorité

Trois pannes qui ressemblent à un interblocage sans en être, et qu'on soigne autrement.

Toutes les paralysies ne sont pas des interblocages, et confondre les diagnostics conduit à appliquer le mauvais remède. Voici les trois autres, avec ce qui les distingue.

La famine

Famine
Un thread qui pourrait progresser n'obtient jamais la ressource qu'il demande, parce que d'autres passent systématiquement devant lui.

Aucun cycle ici, et aucun thread bloqué au sens strict : le système avance, il traite du travail, tes indicateurs globaux ont l'air corrects. Simplement, un thread précis attend indéfiniment. Le cas typique est un verrou attribué au hasard sous forte demande — statistiquement, un malchanceux peut attendre très longtemps.

Le remède ne relève pas de l'ordre d'acquisition mais de l'équité : un verrou équitable sert les demandeurs dans leur ordre d'arrivée. Il est un peu plus lent qu'un verrou ordinaire, et c'est le prix de la garantie.

Le livelock

Livelock
Les threads ne sont pas bloqués — ils travaillent activement, réagissent les uns aux autres, et n'aboutissent jamais.

L'image est celle de deux personnes dans un couloir étroit qui s'écartent en même temps, du même côté, indéfiniment. Chacune agit, chacune est courtoise, et pourtant personne ne passe.

🔍Le signe qui distingue les deux : un interblocage ne consomme aucun processeur — tout le monde dort. Un livelock en consomme beaucoup — tout le monde s'agite. Face à une application figée, ce seul indicateur t'oriente déjà vers le bon diagnostic.

Le livelock arrive volontiers avec les techniques sans verrou, ou avec un timeout suivi d'une nouvelle tentative immédiate. La parade est de casser la symétrie : ajouter un délai aléatoire avant de réessayer suffit généralement, parce que le problème vient de ce que tous réagissent pareil au même instant.

L'inversion de priorité

Le cas le plus contre-intuitif, et il demande un système où les threads ont des priorités différentes. Un thread peu prioritaire détient un verrou dont un thread très prioritaire a besoin. Le prioritaire attend — c'est normal et temporaire. Mais si un thread de priorité intermédiaire monopolise le processeur, le thread peu prioritaire ne tourne plus, ne libère donc jamais son verrou, et le plus prioritaire de tous se retrouve bloqué par le moins urgent.

🚀Le remède classique est l'héritage de priorité : le détenteur d'un verrou reçoit temporairement la priorité du plus prioritaire de ceux qui l'attendent. Il finit donc son travail vite et libère le verrou. C'est un problème de systèmes temps réel plus que d'applications web, mais il illustre bien que le partage de ressources interagit avec l'ordonnancement.

Reconnaître, en un tableau

Processeur consomméCycle d'attenteRemède
InterblocagenulouiOrdre total d'acquisition
FaminenormalnonVerrou équitable
LivelockélevénonCasser la symétrie, délai aléatoire
Inversion de prioriténormalnonHéritage de priorité
10 — NE PAS PARTAGER

Choisir un modèle plutôt qu'un verrou

La meilleure section critique est celle qui n'existe pas. Quatre façons de s'en passer.

Tout ce cours a porté sur la façon de garder un état partagé. Il reste la question qu'on aurait pu poser en premier : faut-il vraiment le partager ? Un état qu'un seul thread touche n'a aucun invariant à protéger, aucun verrou à ordonner, aucun interblocage possible.

L'immutabilité

Une donnée qu'on ne modifie jamais peut être lue par autant de threads qu'on veut, sans aucune protection. La lecture concurrente n'est un problème que si quelqu'un écrit. Modifier revient alors à produire une nouvelle valeur au lieu d'altérer l'ancienne, et il ne reste à synchroniser qu'une chose : la publication de la référence vers la nouvelle version.

🧊C'est le meilleur rapport entre sûreté et effort de tout ce cours. Le coût est un peu d'allocation mémoire ; le gain est la disparition d'une classe entière de bogues.

Le cloisonnement

Si chaque thread possède sa propre copie, il n'y a plus rien à partager. Un compteur par thread, agrégé à la fin, remplace un compteur partagé protégé — et sans aucune contention. C'est la mécanique du stockage par thread dont on a parlé pour ZTS, et c'est aussi ce que fait tout comptage par lots avant consolidation.

Le passage de messages

Plutôt que plusieurs threads touchant une donnée, un seul thread la possède, et les autres lui envoient des demandes. La file de messages devient le seul point de synchronisation, et elle est écrite une fois, correctement, par des gens dont c'est le métier.

Le déplacement
On ne supprime pas la synchronisation, on la concentre en un seul endroit éprouvé, au lieu de la disperser dans tout le code applicatif.

Le share-nothing

Le cas extrême, et celui que tu connais le mieux sans forcément le nommer : chaque unité de travail part d'un état neuf, qu'on jette après usage. Aucun état ne survit d'une requête à la suivante, donc aucun état n'est partagé, donc rien à protéger.

C'est le modèle historique de PHP, et le cours PHP — Concurrence & Runtimes montre comment il est réalisé de deux manières très différentes selon le runtime — par de vrais processus séparés, ou par une isolation reconstruite logiciellement à l'intérieur d'un processus unique.

Le tableau de décision

StratégieCe qu'elle coûteQuand l'employer
ImmutabilitéAllocations supplémentairesDès que possible — le meilleur premier réflexe
Cloisonnement par threadMémoire proportionnelle au nombre de threads, agrégation finaleCompteurs, tampons, accumulateurs
Passage de messagesLatence, sérialisationComposants faiblement couplés, travail asynchrone
VerrousContention, risque d'interblocage, charge mentaleQuand le partage est réellement inévitable
🎯Le classement n'est pas un hasard : les verrous sont en dernière ligne. Ce ne sont pas des outils à éviter — parfois le partage est inévitable et il faut savoir s'en servir. Mais ils devraient être le choix qu'on fait après avoir écarté les autres, pas le premier réflexe.

Et si le sujet t'intéresse par l'autre bout — gérer beaucoup de choses à la fois avec un seul thread, donc sans aucun partage à protéger —, c'est le modèle décrit dans Event loop & asynchrone. Pour les verrous distribués entre plusieurs machines, où le réseau ajoute ses propres pièges, vois Symfony Lock.

11 — TOUT RASSEMBLER

Synthèse

Ce qu'il faut avoir retenu, et l'ordre dans lequel se poser les questions.

Si un seul énoncé devait rester de ce cours, ce serait celui de la première page : dès que deux threads partagent une mémoire, tes invariants doivent tenir à chaque frontière d'instruction, et non plus seulement au repos. Tout le reste n'est qu'outillage pour honorer cette exigence.

Une phrase par notion

Raisonnement localRaisonner sur un fragment sans regarder le reste. Le partage te l'enlève.
AtomicitéUne opération qu'on ne peut pas observer à moitié faite. Une incrémentation ne l'est pas.
Lost update (mise à jour perdue)Deux threads lisent la même valeur, la modifient, l'écrivent. Un incrément disparaît.
Section critiqueLe fragment qui doit s'exécuter sans entrelacement. Une exigence, pas une propriété du code.
Course de donnéesDeux accès concurrents non synchronisés. Détectable mécaniquement.
Course critiqueLe résultat dépend de l'ordre. Non détectable sans connaître l'intention.
MutexUn jeton unique, avec un propriétaire. Presque gratuit sans contention.
ContentionPlusieurs threads veulent le même verrou. C'est elle qui coûte, pas le verrou.
SémaphoreUn compteur d'autorisations. Pour plafonner, pas pour exclure.
Variable de conditionAttendre passivement qu'un état devienne vrai, en libérant le verrou.
InterblocageUn cycle d'attente. Zéro processeur consommé.
Ordre totalToujours prendre les verrous dans le même ordre. Rend le cycle impossible.

Les réflexes, dans l'ordre

🧠Le piège de fin de parcours : avoir lu ce cours donne l'impression de maîtriser le sujet, parce que chaque notion prise isolément est simple. La difficulté ne réside dans aucune d'elles — elle réside dans leur combinaison à l'échelle d'un vrai programme, là où les verrous sont pris dans du code que tu n'as pas écrit. Reste modeste, et privilégie les architectures qui rendent la question sans objet.

Où aller ensuite