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.
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.
Par où commencer
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.
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.
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 ?
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
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.
| Instant | Thread A | Thread B | En mémoire |
|---|---|---|---|
| 1 | lit 7 | 7 | |
| 2 | lit 7 | 7 | |
| 3 | calcule 8 | 7 | |
| 4 | calcule 8 | 7 | |
| 5 | écrit 8 | 8 | |
| 6 | écrit 8 | 8 |
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.
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.
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
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ées | Course critique | |
|---|---|---|
| Nature | Deux accès concurrents à la même case, dont au moins un en écriture, sans synchronisation | Le résultat dépend de l'ordre dans lequel les threads s'exécutent |
| Se détecte | outillé — des détecteurs le trouvent mécaniquement | non outillé — il faut connaître l'intention du code |
| Exemple | Deux compteur++ sans verrou | Vérifier un solde puis le débiter, chaque étape correctement verrouillée mais séparément |
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.
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.
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 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.
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.
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 mutex | Reprise par son propre détenteur |
|---|---|
| Normal | interblocage — le thread s'attend lui-même, pour toujours |
| Avec vérification | erreur retournée — tu es prévenu |
| Récursif | compteur incrémenté — il faudra rendre autant de fois |
| Par défaut | comportement indéfini — la spécification autorise n'importe lequel des trois |
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
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.
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.
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.
Choisir l'outil
| La question que tu poses | L'outil |
|---|---|
| Un seul thread à la fois dans cette section | Mutex |
| Pas plus de N threads sur cette ressource | Sémaphore |
| Attendre que cet état devienne vrai | Variable de condition |
| Une seule opération, indivisible | Opération atomique — page 06 |
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.
« 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));
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.
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.
Une instruction qui interdit certains réordonnancements autour d'elle, et impose donc un ordre observable par les autres threads.
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.
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.
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.
| Condition | Ce qu'elle veut dire | |
|---|---|---|
| 1 | Exclusion mutuelle | La ressource ne peut être tenue que par un seul à la fois |
| 2 | Détention et attente | Un thread garde ce qu'il a déjà tout en réclamant autre chose |
| 3 | Non-préemption | On ne peut pas confisquer un verrou à celui qui le détient |
| 4 | Attente circulaire | La 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
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.
É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
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.
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.
Les deux autres prises, pour être complet
| Condition cassée | Comment | Verdict |
|---|---|---|
| Exclusion mutuelle | Supprimer le partage : donnée immuable, copie par thread, opération atomique | excellent quand c'est possible |
| Détention et attente | Prendre tous les verrous nécessaires d'un coup, ou aucun | rigide, et il faut tous les connaître d'avance |
| Non-préemption | Timeout puis abandon | détecte, ne résout pas |
| Attente circulaire | Ordre total d'acquisition | la réponse de référence |
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
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
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 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.
Reconnaître, en un tableau
| Processeur consommé | Cycle d'attente | Remède | |
|---|---|---|---|
| Interblocage | nul | oui | Ordre total d'acquisition |
| Famine | normal | non | Verrou équitable |
| Livelock | élevé | non | Casser la symétrie, délai aléatoire |
| Inversion de priorité | normal | non | Héritage de priorité |
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.
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.
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égie | Ce qu'elle coûte | Quand l'employer |
|---|---|---|
| Immutabilité | Allocations supplémentaires | Dès que possible — le meilleur premier réflexe |
| Cloisonnement par thread | Mémoire proportionnelle au nombre de threads, agrégation finale | Compteurs, tampons, accumulateurs |
| Passage de messages | Latence, sérialisation | Composants faiblement couplés, travail asynchrone |
| Verrous | Contention, risque d'interblocage, charge mentale | Quand le partage est réellement inévitable |
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.
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 local | Raisonner 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 critique | Le fragment qui doit s'exécuter sans entrelacement. Une exigence, pas une propriété du code. |
| Course de données | Deux accès concurrents non synchronisés. Détectable mécaniquement. |
| Course critique | Le résultat dépend de l'ordre. Non détectable sans connaître l'intention. |
| Mutex | Un jeton unique, avec un propriétaire. Presque gratuit sans contention. |
| Contention | Plusieurs threads veulent le même verrou. C'est elle qui coûte, pas le verrou. |
| Sémaphore | Un compteur d'autorisations. Pour plafonner, pas pour exclure. |
| Variable de condition | Attendre passivement qu'un état devienne vrai, en libérant le verrou. |
| Interblocage | Un cycle d'attente. Zéro processeur consommé. |
| Ordre total | Toujours prendre les verrous dans le même ordre. Rend le cycle impossible. |
Les réflexes, dans l'ordre
- Demande-toi d'abord si le partage est nécessaire. Immutabilité, cloisonnement par thread, passage de messages : trois façons de supprimer le problème au lieu de le gérer.
- Protège la décision, pas l'accès. La section critique va de la lecture qui fonde le choix à l'écriture qui l'applique. La couper en deux morceaux bien verrouillés ne protège rien.
- Ne tiens jamais un verrou pendant une attente lente. Réseau, disque, base : sors-les de la section critique.
- Fixe un ordre d'acquisition et écris-le. C'est la seule parade qui élimine l'interblocage au lieu de le rendre moins probable.
- Devant une application figée, regarde le processeur. À zéro c'est un interblocage ; élevé c'est un livelock. Le diagnostic change le remède.
- Ne fais pas confiance à tes tests sur ce sujet. Ces défauts dépendent de fenêtres temporelles que ton poste n'ouvre pas et que la production ouvrira.