Programmation parallèle avancée (synchronisation)
Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar
Ce que la séance construit
Dès que plusieurs fils partagent une donnée, il faut organiser leurs accès. La séance montre comment une course critique perd des mises à jour, comment les verrous, les sémaphores et les barrières les ordonnent, ce que coûtent l’interblocage et le livelock, puis trois patrons (patterns) d’organisation (fork-join, pipeline, producteur-consommateur) et l’équilibrage de la charge.
Plan
Prérequis : Séance 1 (fils, processus, accélération) et Séance 2 (copies dans les caches, false sharing, ordre des écritures).
Lab associé : Lab 4 (synchroniser : verrous, sémaphores, files et interblocage).
Ensuite : Séance 4, architectures distribuées et NUMA.
D’où l’on vient, où l’on va
Amont. Séances 1 et 2 (cohérence, MSI/MESI, false sharing).
Cette séance. Les défis de la programmation parallèle, courses critiques et sections critiques, mécanismes de synchronisation (verrous, sémaphores, barrières), interblocage et livelock, patrons de parallélisation, ordonnancement et équilibrage de charge.
Aval. Séance 4 : architectures distribuées et NUMA. Lab associé : Lab 4.
Plusieurs fils (threads) qui s’exécutent en parallèle se partagent des ressources : variables, structures de données, fichiers, connexions. Il faut gérer l’accès concurrent à ces ressources et synchroniser les fils pour qu’ils collaborent. Lorsque des fils partagent des ressources, trois problèmes majeurs émergent :
Les fils de ce cours
Les exemples sont en Python, avec le module threading : tous les fils d’un programme partagent sa mémoire. Les attentes (time.sleep) y simulent une lecture de fichier ou une requête ; c’est le cas où les fils de CPython avancent vraiment ensemble (Lab 4). Les sorties et les durées montrées sont celles d’exécutions sous CPython 3.13.
from threading import Thread
compteur = 0
def incrementer():
global compteur
for i in range(1000000):
# PAS atomique : lire, ajouter 1, écrire
compteur = compteur + 1
thread1 = Thread(target=incrementer)
thread2 = Thread(target=incrementer)
thread1.start(); thread2.start()
thread1.join(); thread2.join()
print(f"Valeur finale : {compteur}")Résultat \(\rightarrow\)
Valeur finale : 2000000
Course critique
Race condition : le résultat d’un programme dépend de l’ordre d’exécution, non déterministe, de plusieurs fils qui accèdent à des ressources partagées.
Un résultat juste ne prouve rien
Obtenu avec CPython 3.13 : \(2\,000\,000\) ; il ne change de fil qu’aux sauts en arrière (fin de tour) et aux appels. Rien ne le garantit : CPython 3.9 donne \(1{,}42\) à \(1{,}55\) million, 3.13t sans GIL \(1{,}03\) à \(1{,}36\) million.
Lecture. Deux fils, \(n\) incréments chacun : un programme énumère tous les ordres des \(6n\) opérations (hauteurs en échelle logarithmique). La bonne valeur \(2n\) (en vert) est rare ; dès \(n=2\), la plus petite est \(2\), et non \(n\) : avec un million d’incréments par fil, le résultat peut aller de \(2\) à \(2\,000\,000\).
Énoncé. Identifier les sections critiques du code suivant.
solde_bancaire = 1000
historique = []
def retrait(montant):
global solde_bancaire
# Ligne A : Vérification
if solde_bancaire >= montant:
# Ligne B : Calcul local
nouveau_solde = solde_bancaire - montant
time.sleep(0.001) # Simule un délai
# Ligne C : Mise à jour
solde_bancaire = nouveau_solde
# Ligne D : Logging
historique.append(f"Retrait de {montant}")
return True
return Falseretrait(800) en même temps, solde \(1000\) F CFA : que se passe-t-il ?Correction
solde_bancaire ; D aussi : elle modifie historique, partagée.[True, True], solde \(200\).Verrou autour de A à D : [True, False].
Piège : sans global, l’appel lève UnboundLocalError.
Section critique
Portion de code qui accède à une ressource partagée et qui ne doit pas être exécutée par plusieurs fils simultanément.
Pour résoudre les courses critiques, il faut garantir l’exclusion mutuelle : un seul fil à la fois peut exécuter la section critique.
Vérifier puis agir
Dans retrait, la vérification (A) et l’écriture (C) doivent être dans la même section critique : protéger seulement l’écriture laisse deux fils passer la vérification avec le même solde (Lab 4, guichet_faux).
from threading import Thread, Lock
mutex = Lock() # Création du mutex
def incrementer_securise():
global compteur
for i in range(1000000):
mutex.acquire() # Acquérir le verrou
try:
compteur = compteur + 1
finally:
mutex.release() # TOUJOURS libérer
def incrementer_propre():
global compteur
for i in range(1000000):
with mutex: # acquire puis release
compteur = compteur + 1Verrou
Mutex, verrou d’exclusion mutuelle : mécanisme qui protège une section critique en garantissant qu’un seul fil peut l’exécuter à la fois.
lock() (en Python acquire()) : acquérir le verrou, en attendant s’il est déjà pris ;
unlock() (release()) : libérer le verrou.
Résultat \(\rightarrow\)
incrementer_securise : 2000000
incrementer_propre : 2000000
Lecture. Compte exact dans les deux versions. Le with libère le verrou même en cas d’exception.
Lecture. F1 exécute with verrou: : le verrou est libre, F1 le prend (cadre rouge) et entre dans la section critique.
Lecture. F2 demande le verrou : il est pris par F1. F2 est bloqué : il ne consomme pas de calcul, il attend dans la file du verrou.
Lecture. F3 demande à son tour : bloqué derrière F2. Un seul fil est dans la section critique : c’est l’exclusion mutuelle.
Lecture. F1 exécute compteur = compteur + 1 : il lit \(0\) et écrit \(1\). Aucun autre fil ne peut lire compteur entre-temps.
Lecture. F1 sort du bloc : le verrou est rendu (release) et un fil en attente le reçoit, ici F2. F2 entre dans la section critique.
Lecture. F2 a écrit \(2\) et rendu le verrou ; F3 l’a reçu et vient d’écrire \(3\). Le compte est exact. L’ordre de réveil n’est pas garanti par Python : la figure suit l’ordre d’arrivée.
1. Oublier de libérer
Tous les autres fils restent bloqués. Solution : try-finally ou with.
2. Verrouiller trop longtemps
Les autres fils attendent un calcul et une entrée-sortie qui ne touchent pas la ressource. Solution : ne protéger que l’accès partagé.
semaphore_bd = Semaphore(3) # 3 connexions max
def requete_base_donnees(i): # i : id_thread
print(f"Thread {i} : En attente de connexion...")
with semaphore_bd: # acquire
print(f"Thread {i} : Connecté à la BD")
time.sleep(2) # Simulation requête
print(f"Thread {i} : Requête terminée")
print(f"Thread {i} : Connexion fermée")
for i in range(10): # Lancer 10 threads
Thread(target=requete_base_donnees,
args=(i,)).start()Résultat \(\rightarrow\)
Thread 1 : En attente de connexion...Thread 0 : Connecté à la BD
Sémaphore
Compteur qui limite le nombre de fils qui accèdent en même temps à une ressource.
acquire() : décrémenter (attendre si \(0\)) ;
release() : incrémenter (réveiller un fil qui attend).
Binaire (\(0\) ou \(1\)) : comme un verrou, sans propriétaire ; compteur (\(>1\)) : limite les accès.
Lecture. ↪ : suite de la même ligne ; print écrit le texte, puis le saut de ligne, et un autre fil passe entre les deux.
Lecture. \(t=0\) : les dix fils démarrent ensemble. Les fils \(0\), \(1\), \(2\) font acquire : le compteur passe de \(3\) à \(2\), \(1\), puis \(0\). Ils sont connectés.
Lecture. \(t=0\) : les fils \(3\) à \(9\) font acquire avec un compteur à \(0\) : ils sont bloqués. Jamais plus de \(3\) connexions à la fois.
Lecture. \(t=2\) s : les fils \(0\), \(1\), \(2\) sortent (release). Chaque release réveille un fil en attente, qui prend la place : \(3\), \(4\), \(5\) entrent, le compteur reste à \(0\).
Lecture. \(t=4\) s : même relève : \(6\), \(7\), \(8\) entrent. Il ne reste que le fil \(9\) en attente.
Lecture. \(t=6\) s : trois releases, un seul fil en attente : \(9\) entre et le compteur remonte à \(2\) (deux places libres).
Lecture. \(t=8\) s : le fil \(9\) sort, le compteur revient à \(3\). Durée : \(\lceil 10/3\rceil\) vagues de \(2\) s, soit \(8\) s (mesuré : 8,00 s).
barriere = Barrier(4) # 4 threads
def travailleur(i): # i : id_thread
print(f"Thread {i} : Phase 1 - "
"Chargement des données")
time.sleep(random.uniform(0.5, 2))
print(f"Thread {i} : Données chargées")
barriere.wait() # attendre les 4 threads
print(f"Thread {i} : Phase 2 - Traitement")
time.sleep(random.uniform(0.5, 2))
print(f"Thread {i} : Traitement terminé")
barriere.wait() # deuxième barrière
print(f"Thread {i} : Phase 3 - Finalisation")Barrière
Point de synchronisation où chaque fil attend que tous les autres l’aient atteint avant de continuer.
Tous les fils terminent la phase 1 avant que l’un d’eux commence la phase 2 ; l’objet Barrier se réarme et sert une seconde fois. Résultat \(\rightarrow\)
Thread 3 : Données chargées
Thread 0 : Données chargées
Thread 1 : Données chargées
Thread 2 : Données chargées
Lecture. Ordre d’arrivée observé : \(3, 0, 1, 2\) ; le pas à pas reprend ces durées.
Lecture. \(t=0{,}52\) s : le fil 3, le plus rapide, finit sa phase 1 et appelle wait() : \(1\) fil sur \(4\) à la barrière. Il est bloqué (hachures).
Lecture. \(t=0{,}86\) s : le fil 0 arrive : \(2\) sur \(4\). Les fils arrivés attendent sans rien faire.
Lecture. \(t=1{,}05\) s : le fil 1 arrive : \(3\) sur \(4\). La barrière reste fermée tant qu’un fil manque.
Lecture. \(t=1{,}44\) s : le fil 2, le plus lent, arrive : \(4\) sur \(4\), la barrière s’ouvre et les quatre fils commencent la phase 2 ensemble.
Lecture. \(t=3{,}20\) s : même règle à la seconde barrière, réutilisée. Durée : la somme des phases les plus lentes, \(1{,}44+1{,}76=3{,}20\) s (mesuré : 3,20 s).
Énoncé
Une simulation a \(3\) fils qui doivent :
Implémenter ce programme avec des barrières. Combien de barrières sont nécessaires ?
Correction
wait() par fil (compté à l’exécution). Un seul objet pourrait servir aux deux, puisqu’il se réarme.Interblocage
Deux fils ou plus sont bloqués indéfiniment, chacun attendant qu’un autre libère une ressource.
Quatre conditions nécessaires (Coffman) :
Lecture. À l’exécution : après \(3\) s, les deux fils sont toujours vivants, mutex_A et mutex_B restent pris, aucun « Travail » affiché. t1.join() attendrait indéfiniment.
Lecture. T1 prend mutex_A : flèche A\(\to\)T1 (A est détenu par T1). Une ressource à usage exclusif : première condition de Coffman.
Lecture. T2 prend mutex_B : flèche B\(\to\)T2. Chaque fil tient un verrou ; aucun n’attend encore.
Lecture. T1 demande B, détenu par T2 : flèche pointillée T1\(\to\)B. T1 garde A en attendant (hold and wait). Graphe d’attente : T1\(\to\)T2, pas de cycle.
Lecture. T2 demande A, détenu par T1 : T2\(\to\)T1. Le graphe d’attente contient le cycle T1\(\to\)T2\(\to\)T1 : les quatre conditions sont réunies, interblocage.
Lecture. Correction : les deux fils prennent A, puis B (ordre global). Ici T1 tient A ; T2 attend A sans rien tenir. Pas de cycle : T1 prendra B, finira et libérera A.
Lecture. Analogie : il faut ses deux fourchettes pour manger ; si tous prennent leur gauche, personne ne mange. Flèche pleine : tenue ; pointillés : demandée. À l’exécution, à droite, chacun mange.
Résultat \(\rightarrow\)
Thread 1 : Travail
Thread 2 : Travail
Ce que l’ordre supprime
Les quatre conditions sont nécessaires : en empêcher une seule rend l’interblocage impossible. Si T1 a mutex_A, T2 attend mutex_A sans rien tenir : aucun cycle ne peut se former. L’ordre global supprime l’attente circulaire.
Plusieurs verrous : un ordre total (numéro, nom), le même pour tous les fils (Lab 4 : comptes triés par nom).
Lecture. À l’exécution, avec une attente de \(0{,}1\) s entre les deux prises : les deux fils travaillent, en \(0{,}20\) s.
Ce que le délai supprime
Après \(1\) s, le fil renonce et rend ce qu’il tient : le délai attaque la condition de non-préemption (ou de hold and wait, selon les auteurs) ; il borne l’attente.
Un abandon au moins
Ordres opposés, même délai : le délai du premier fil expire, il rend son verrou ; l’autre l’obtient juste avant son propre délai et travaille (80 exécutions sur 80). Si les deux expirent ensemble, les deux abandonnent : réessayer, pas au même rythme.
def thread_avec_backoff():
while True:
if mutex_A.acquire(blocking=False):
try:
if mutex_B.acquire(blocking=False):
try:
print("Travail effectué")
return # Succès
finally:
mutex_B.release()
# mutex_B pris : libérer A, réessayer
finally:
mutex_A.release()
# Attendre un peu avant de réessayer
time.sleep(random.uniform(0.001, 0.01))acquire(blocking=False) rend False tout de suite si le verrou est pris : le fil n’attend jamais en tenant un verrou. L’attente aléatoire évite que les deux fils recommencent au même instant.
Lecture. À l’exécution, deux fils en ordres opposés : les deux réussissent, en \(1\) et \(2\) essais.
Livelock
Des fils restent actifs mais ne progressent pas, car ils réagissent continuellement aux actions des autres sans avancer.
Analogie
Deux personnes se croisent dans un couloir étroit : A se déplace à gauche, B aussi pour éviter A ; A voit B et passe à droite, B aussi ; A retourne à gauche… Les deux bougent sans cesse, aucune n’avance.
Contrairement à l’interblocage, où tout est figé, dans un livelock les fils sont actifs mais improductifs.
Solution : un délai aléatoire (backoff exponentiel) pour briser la symétrie.
Mesure
Code de la séance, \(20\) exécutions : aucune reprise, le fil A finit avant que B démarre. Départ simultané et \(10\) ms de travail après la prise du premier verrou : un fil recommence une fois, puis passe (\(20\) fois sur \(20\)). Les écarts de temps entre fils brisent vite la symétrie ; rien ne garantit qu’ils le fassent.
Lecture. Tour 1 : A et B démarrent en même temps et avancent au même rythme. A prend mutex_A, B prend mutex_B (le fil B suit le même code, verrous échangés).
Lecture. Chacun teste l’autre verrou : A voit mutex_B pris, B voit mutex_A pris (cadres orange).
Lecture. Par « politesse », chacun rend son verrou et attend \(1\) ms. Les deux verrous sont libres : aucun fil n’est bloqué, mais aucun travail n’est fait.
Lecture. Tour 2 : même attente, même reprise : A reprend mutex_A, B reprend mutex_B, exactement l’état du tour 1. Tant que les pas restent égaux, la boucle ne finit jamais : c’est le livelock.
Lecture. Backoff aléatoire : A attend moins longtemps que B, reprend mutex_A et trouve mutex_B libre : travail fait. B passe ensuite. En pratique, les écarts de temps entre fils brisent vite la symétrie.
Fork-join
Un patron (pattern) est une organisation type du travail entre fils. Un pool de fils (ThreadPoolExecutor) garde ses workers d’une tâche à l’autre : il évite de créer un fil par sous-tâche.
Résultat \(\rightarrow\)
Début sous-tâche 1Début sous-tâche 2
Début sous-tâche 3
Début sous-tâche 4
Fin sous-tâche 2, résultat = 4
Début sous-tâche 5
Lecture. Lignes mêlées, comme au sémaphore : deux print simultanés.
Lecture. Fork : les \(8\) appels submit déposent les sous-tâches dans la file du pool et lui font créer jusqu’à \(4\) workers ; chaque appel rend la main tout de suite (un Future par tâche).
Lecture. \(t=0\) à \(1\) s : chaque worker prend une sous-tâche : \(1\), \(2\), \(3\), \(4\) s’exécutent en parallèle. Les tâches \(5\) à \(8\) attendent dans la file.
Lecture. \(t=1\) à \(2\) s : dès qu’un worker finit, il prend la tâche suivante : \(5\), \(6\), \(7\), \(8\). Le programme principal attend dans future.result().
Lecture. Join, puis fusion : les \(8\) résultats sont là (\(1, 4, \dots, 64\)), dans l’ordre des futures ; sum donne \(204\). Durée : \(2\) vagues de \(1\) s, \(2\) s (mesuré : 2,00 s) au lieu de \(8\) s.
Pipeline
Le traitement est divisé en étapes successives ; chaque étape a son fil, et les étapes travaillent en même temps, chacune sur un élément différent. Des files relient une étape à la suivante.
Durée
\(n\) éléments, étapes de durées \(d_1,\dots,d_k\) : \[T=(d_1+\dots+d_k)+(n-1)\times\max_i d_i\] Après la mise en route, l’étape la plus lente fixe le rythme.
Trois fils : chargement, traitement, sauvegarde. Les fonctions charger_image, appliquer_filtre, sauvegarder_image sont hypothétiques ; l’exécution les remplace par des attentes de \(0{,}1\), \(0{,}3\) et \(0{,}2\) s.
Lecture. \(t=0{,}1\) s : l’étape 1 a chargé img1 et l’a déposée dans queue_chargement ; l’étape 2 la prend aussitôt. L’étape 1 enchaîne avec img2.
Lecture. \(t=0{,}3\) s : l’étape 1 a fini les trois chargements. img2 et img3 attendent dans la file : l’étape 2, la plus lente (\(0{,}3\) s), fixe le rythme.
Lecture. \(t=0{,}4\) s : img1 est traitée et passe à l’étape 3 ; l’étape 2 prend img2. Les trois étapes travaillent en même temps, chacune sur une image différente.
Lecture. \(t=0{,}7\) s : img2 est traitée ; l’étape 2 prend img3, l’étape 3 sauvegarde img2. Une image sort toutes les \(0{,}3\) s.
Lecture. \(t=1{,}2\) s : tout est sauvegardé. \(T=(0{,}1+0{,}3+0{,}2)+(3-1)\times0{,}3=1{,}2\) s, contre \(3\times0{,}6=1{,}8\) s à la suite (mesuré : 1,20 s).
Producteur-consommateur
Une file partagée où les producteurs ajoutent des éléments et les consommateurs les retirent et les traitent ; la file gère elle-même la synchronisation.
Queue(maxsize=5) : put attend une place libre, get attend un élément ; task_done signale une tâche finie, join attend qu’elles le soient toutes.
Lecture. Une exécution (\(2\) producteurs de \(5\) tâches, \(3\) consommateurs) : \(10\) tâches traitées, réparties \(3\), \(4\), \(3\) ; la file n’a jamais dépassé \(2\) éléments. Les consommateurs s’arrêtent \(2\) s après la dernière tâche.
Arrêt par délai
Un producteur qui reste plus de \(2\) s sans déposer arrête les consommateurs trop tôt. Un signal de fin (None), un par consommateur, ne dépend d’aucune durée (Lab 4).
Lecture. Les trois consommateurs appellent get() sur une file vide : ils sont bloqués. Aucune tâche n’existe encore.
Lecture. Les producteurs déposent 0-0, 1-0, 0-1 : chaque dépôt réveille un consommateur, qui prend la tâche. File vide, \(3\) tâches non terminées.
Lecture. Les consommateurs sont occupés ; les producteurs déposent \(4\) tâches de plus. La file les garde dans l’ordre d’arrivée : \(4\) places sur \(5\).
Lecture. P1 dépose 1-3 : la file est pleine (\(5/5\)). \(8\) tâches déposées, aucune terminée.
Lecture. P0 appelle put(0-4) : file pleine, P0 est bloqué jusqu’à ce qu’une place se libère. La taille de la file ne dépasse jamais \(5\).
Lecture. C1 finit 1-0 (task_done : \(7\) non terminées), puis get() rend la plus ancienne, 1-1 : une place se libère, P0 dépose 0-4 : \(8\) non terminées.
Équilibrage de charge
Répartir le travail entre les fils ou les processus pour utiliser au mieux les ressources. Statique : répartition fixée à l’avance ; dynamique : répartition adaptée pendant l’exécution.
Équilibrage statique
Équilibrage dynamique
Lecture. Douze tâches de durées inégales (en secondes) pour \(4\) fils : \(5, 4, 3, 1, 1, 1, 1, 1, 1, 1, 2, 1\), soit \(22\) s de travail. Au mieux, \(22/4=5{,}5\) s.
Lecture. Statique : blocs fixés à l’avance, \(3\) tâches consécutives par fil. Le fil 0 reçoit \(5+4+3=12\) s, les autres \(3\) ou \(4\) s : fin à \(12\) s, trois fils inactifs les deux tiers du temps.
Lecture. Dynamique, \(t=2\) s : une file de tâches ; chaque fil libre prend la suivante. À \(t=0\), les fils prennent \(5\), \(4\), \(3\) et \(1\) ; le fil 3, libéré à \(t=1\), prend la tâche suivante.
Lecture. Dynamique, fin : les petites tâches remplissent les trous. Fin à \(6\) s au lieu de \(12\), proche de la borne \(5{,}5\) s. Le prix : un accès à la file partagée par tâche.
Énoncé
\(100\) fichiers dont la taille varie de \(1\) Ko à \(1\) Go, \(4\) processeurs.
ThreadPoolExecutor.Correction
executor.submit(traiter_fichier, f) pour chaque fichier, puis as_completed : les \(4\) workers prennent les fichiers au fur et à mesure, aucun n’est inactif tant qu’il en reste.Mesure (\(100\) fichiers fictifs, tailles log-uniformes de \(1\) Ko à \(1\) Go ; \(1\) Go \(\to0{,}5\) s) : statique 1,03 s, dynamique 0,93 s, borne \(0{,}85\) s.
Pièges : fichiers absents (FileNotFoundError) ; pour un calcul, les fils de CPython standard ne s’exécutent pas en parallèle (ProcessPoolExecutor ; 3.13t sans GIL, si).
Avant d’écrire le code
with).Organiser le travail
| Primitive | Usage | Surcoût | Quand l’utiliser ? |
|---|---|---|---|
| Verrou (Lock) | exclusion mutuelle | faible | protéger une ressource partagée |
| Sémaphore | limiter les accès | faible | pool de ressources limité |
| Barrière | synchronisation | moyen | phases parallèles |
| Queue | communication | moyen | producteur-consommateur |
| Event | signal binaire | faible | notification |
Règles d’or
Énoncé
Un serveur web :
Avec Thread, Lock, Semaphore, Barrier ; tous les fils se terminent.
Correction
Arrêt : un None par worker, puis fin.wait() dans le fil principal, qui attend les \(10\) workers. \(40\) requêtes de \(0{,}05\) s : compteur \(40\), au plus \(5\) actives, 0,40 s \(=\lceil 40/5\rceil\times0{,}05\).
À retenir (1/2)
À retenir (2/2)
Lab 4 : synchroniser
Prochaine séance
Séance 4 : architectures distribuées et NUMA. Mémoire locale et distante, répertoire, calcul distribué.
Références : A. Silberschatz et al., Operating System Concepts, chap. 6 à 8 ; E. W. Dijkstra, Cooperating Sequential Processes, EWD 123, 1965 (sémaphores, philosophes) ; C. A. R. Hoare, Communicating Sequential Processes, 1985 (les philosophes) ; E. G. Coffman, M. J. Elphick, A. Shoshani, « System Deadlocks », ACM Computing Surveys 3(2), 1971 ; M. McCool et al., Structured Parallel Programming, 2012 (fork-join, pipeline).
Architecture et Programmation Parallèles — Séance 3 Dr. El Hadji Bassirou TOURÉ • DMI/FST/UCAD
Lecture. Le fil 1 lit
compteur: son registre local \(r\) reçoit \(0\) (la mémoire est lue, cadre orange). Rien n’est encore écrit.Lecture. Le fil 2 lit
compteuravant que le fil 1 ait écrit : il lit aussi \(0\). Les deux fils travaillent maintenant sur la même valeur.Lecture. Le fil 1 ajoute \(1\) à son registre : \(r=1\). Ce calcul est local :
compteurvaut toujours \(0\).Lecture. Le fil 1 écrit \(r\) :
compteurpasse à \(1\) (cadre rouge). Le registre du fil 2 contient encore l’ancienne valeur \(0\).Lecture. Le fil 2 ajoute \(1\) à son registre : \(r=0+1=1\). Il ignore l’écriture du fil 1.
Lecture. Le fil 2 écrit \(1\) : il écrase le \(1\) du fil 1. Deux incréments,
compteur\(=1\) au lieu de \(2\) : un incrément est perdu.