cursus.

Cours 3 · ConcurrenceLeçon 1 sur 1

Communication et synchronisation

10 h de lecture10 sections Version PDF

À la fin de cette leçon, vous saurez

Section critique et exclusion mutuelle, conditions de concurrence, sémaphores et mutex ; producteur-consommateur et lecteurs-rédacteurs ; interblocage, conditions de Coffman, prévention et détection.

Entre 1985 et 1987, l'appareil de radiothérapie Therac-25 administre à six patients des doses de rayonnement des centaines de fois supérieures à la prescription. Trois en meurent. L'enquête établit que la machine était juste — la plupart du temps. Le défaut n'apparaissait que lorsque l'opérateur modifiait très vite les réglages sur le terminal : une tâche vérifiait la configuration pendant qu'une autre la modifiait, et le faisceau partait sur une valeur incohérente que ni l'une ni l'autre n'avait jamais écrite.

C'est une condition de concurrence, et c'est le sujet de ce chapitre. Elle a trois propriétés qui expliquent qu'elle soit redoutée : elle ne se produit que pour certains entrelacements, donc rarement ; elle disparaît souvent quand on ajoute un affichage pour la déboguer, parce que l'affichage change le minutage ; et elle ne se reproduit pas à la demande. Un programme concurrent qui « marche à l'exécution » ne prouve rien du tout.

Une addition qui n'est pas une opération

Prenons deux fils qui incrémentent une même variable, mille fois chacun. Le résultat attendu est 2000 ; on obtient régulièrement moins.

La raison est que compteur = compteur + 1 n'est pas une instruction machine. Le chapitre 6 d'architecture l'a montré : c'est une lecture, un calcul, une écriture.

        lw   $t0, compteur       ; lire        addi $t0, $t0, 1         ; calculer        sw   $t0, compteur       ; écrire

Et le chapitre 4 a montré que le processeur peut être retiré entre deux instructions quelconques. D'où cet entrelacement, parfaitement légal :

compteur vaut 5 Fil A                     Fil B                    compteurlw   $t0, compteur → 5                                 5                          lw   $t0, compteur → 5       5addi $t0, 1        → 6                                 5                          addi $t0, 1        → 6       5sw   $t0, compteur                                     6                          sw   $t0, compteur           6   ← une incrémentation perdue

Deux incréments, un seul effet. Le bogue n'est pas dans le code — chaque fil est correct — il est dans l'interaction, et il n'apparaît que pour un entrelacement sur des milliers.

La leçon générale : dès que plusieurs exécutions partagent une donnée modifiable, il faut identifier les portions de code qui la manipulent. Ces portions s'appellent des sections critiques, et la règle est qu'une seule exécution à la fois peut se trouver dans une section critique portant sur la même donnée. C'est l'exclusion mutuelle.

Ce qu'une solution doit garantir

Une solution correcte au problème de la section critique satisfait quatre exigences, et il faut les avoir en tête pour juger n'importe quel mécanisme.

L'exclusion mutuelle. Jamais deux exécutions simultanément dans la section critique.

La progression. Si la section est libre et que quelqu'un veut entrer, la décision de savoir qui entre ne peut pas être repoussée indéfiniment. Autrement dit, un processus qui ne demande rien ne doit pas pouvoir bloquer les autres.

L'attente bornée. Il existe une limite au nombre de fois où d'autres peuvent entrer avant qu'un demandeur soit servi. C'est l'exigence anti-famine, et c'est celle que les solutions naïves manquent le plus souvent.

Aucune hypothèse sur les vitesses. La correction ne doit dépendre ni du nombre de processeurs, ni de leur fréquence, ni de l'ordonnanceur. C'est ce qui condamne les solutions « qui marchent parce que le premier fil est toujours plus rapide ».

Une fausse bonne idée mérite d'être écartée tout de suite : masquer les interruptions pendant la section critique. Cela fonctionne, sur une machine à un seul cœur, et c'est ce que fait le noyau lui-même pour des portions très courtes. Mais c'est une instruction privilégiée au sens du chapitre 1 — hors de portée d'un programme utilisateur —, elle gèle tout le système pendant la durée de la section, et surtout elle ne protège rien sur une machine multicœur, où un autre cœur continue d'exécuter le fil concurrent.

Ce qu'il faut, c'est une opération atomique fournie par le matériel : un test-et-mise-à-un (test-and-set) ou une comparaison-et-échange (compare-and-swap) qui lit et écrit en un seul accès indivisible, même vue des autres cœurs. Toutes les primitives qui suivent sont construites là-dessus.

Quiz · vérifiez votre compréhension Sans réponse

Deux fils exécutent chacun 1000 fois compteur++ sur une variable partagée initialisée à 0. Quelles valeurs finales sont possibles ?

Les sémaphores

Un sémaphore est un entier accompagné d'une file d'attente, sur lequel deux opérations atomiques sont définies. Leurs noms viennent du néerlandais, langue de Dijkstra qui les a inventés en 1965.

P(s)   « puis-je passer ? »       s ← s − 1       si s < 0, le processus appelant est BLOQUÉ et mis dans la file de s V(s)   « je libère »       s ← s + 1       si s ≤ 0, un processus de la file est réveillé et repasse à l'état prêt

Deux détails comptent. La valeur du sémaphore, lorsqu'elle est négative, donne le nombre de processus en attente. Et le blocage est un vrai blocage au sens du chapitre 3 : le processus quitte l'état prêt, donc ne consomme plus de processeur. C'est ce qui distingue le sémaphore de l'attente active (spinlock), qui boucle en relisant une variable — inutilisable pour une attente longue, mais préférable pour une attente de quelques instructions sur une machine multicœur, où le coût de deux changements de contexte dépasserait celui de l'attente.

Deux usages, qu'il ne faut pas confondre.

Un sémaphore binaire, initialisé à 1, réalise l'exclusion mutuelle. On l'appelle alors un mutex, et il a un propriétaire : celui qui verrouille est celui qui déverrouille.

Un sémaphore compteur, initialisé à nn, gère un stock de nn ressources identiques : nn places dans un tampon, nn connexions à une base. Il n'a pas de propriétaire, et il est parfaitement normal qu'un processus fasse V sur un sémaphore qu'il n'a jamais P.

Le producteur-consommateur

C'est le problème d'école, et c'est aussi le tube du chapitre 2 : un producteur dépose dans un tampon de taille bornée, un consommateur retire. Il faut résoudre trois problèmes distincts, et c'est pourquoi il faut trois sémaphores.

mutex = 1        exclusion mutuelle sur le tamponvide  = N        nombre de places libresplein = 0        nombre d'éléments disponibles Producteur                     Consommateurrépéter                        répéter    produire un élément            P(plein)     ← attendre qu'il y ait à prendre    P(vide)   ← attendre           P(mutex)              une place            retirer du tampon    P(mutex)                       V(mutex)    déposer dans le tampon         V(vide)      ← une place s'est libérée    V(mutex)                       consommer    V(plein)  ← un de plus

Chaque sémaphore répond à une question différente : mutex protège la structure de données, vide empêche le producteur de déborder un tampon plein, plein empêche le consommateur de lire un tampon vide. Les confondre est l'erreur classique.

L'ordre des P n'est pas interchangeable. Si le producteur faisait P(mutex) avant P(vide), il pourrait prendre le verrou puis se bloquer sur un tampon plein — en gardant le verrou. Le consommateur, qui seul peut libérer une place, resterait bloqué sur P(mutex). Plus personne n'avance : c'est un interblocage, et il tient à l'ordre de deux lignes.

Lecteurs-rédacteurs

Second problème classique, avec une asymétrie utile. Plusieurs lecteurs peuvent consulter une donnée simultanément sans risque — la lecture ne modifie rien. Un rédacteur exige l'exclusivité, contre les autres rédacteurs comme contre les lecteurs.

La solution naïve — un compteur de lecteurs, le premier qui entre prend le verrou d'écriture, le dernier qui sort le rend — a un défaut immédiat : tant qu'il reste au moins un lecteur, le rédacteur n'entre jamais. C'est la famine du rédacteur, et sur une donnée très lue elle est permanente. Les variantes ajoutent une barrière qui bloque les nouveaux lecteurs dès qu'un rédacteur est en attente, ce qui déplace la famine vers les lecteurs — troisième variante équitable, servant les demandes dans l'ordre d'arrivée.

Il n'y a pas de choix universel : c'est une décision de politique, comme au chapitre 4.

L'interblocage

Quatre conditions doivent être simultanément réunies pour qu'un interblocage survienne. Ce sont les conditions de Coffman, et leur intérêt pratique est qu'il suffit d'en casser une.

  1. Exclusion mutuelle — au moins une ressource est non partageable.
  2. Détention et attente — un processus garde ce qu'il a en attendant autre chose.
  3. Non-préemption — on ne peut pas retirer de force une ressource à son détenteur.
  4. Attente circulaire — il existe un cycle de processus attendant chacun le suivant.

Trois stratégies, et une quatrième assumée.

La prévention casse une condition par construction. On ne peut guère toucher à la première. On peut exiger qu'un processus demande toutes ses ressources d'un coup, ce qui casse la deuxième au prix d'une mauvaise utilisation. On peut autoriser la préemption des ressources reprenables. Et surtout, le plus employé : imposer un ordre total sur les ressources et exiger qu'on les prenne dans cet ordre croissant, ce qui rend le cycle impossible. C'est une règle de codage simple, vérifiable en relecture, qui suffit dans l'immense majorité des cas.

L'évitement examine chaque demande et ne l'accorde que si le système reste dans un état sûr, c'est-à-dire s'il existe encore un ordre d'exécution qui termine tout le monde. C'est l'algorithme du banquier, qui exige de connaître à l'avance les besoins maximaux de chaque processus — hypothèse rarement réaliste.

La détection et guérison laisse l'interblocage survenir, le détecte en cherchant un cycle dans le graphe d'allocation, puis en sort en tuant un processus ou en annulant une transaction. C'est ce que font les systèmes de gestion de bases de données.

L'autruche, enfin, consiste à ignorer le problème et à redémarrer si nécessaire. C'est le choix des systèmes d'exploitation à usage général, y compris Linux, et il est raisonné : les interblocages y sont rares, et le coût permanent de la prévention dépasserait celui des redémarrages occasionnels.

Quiz · vérifiez votre compréhension Sans réponse

Dans le producteur-consommateur, un étudiant écrit P(mutex) puis P(vide) dans le producteur. Que se passe-t-il quand le tampon est plein ?

À vous

L'exercice reproduit le TP en JavaScript, avec un ordonnanceur qui entrelace explicitement deux tâches — puisque le vrai parallélisme n'existe pas dans le navigateur, on le simule, ce qui a l'avantage de rendre l'entrelacement reproductible et donc étudiable.

Trois temps, dans cet ordre. D'abord le compteur partagé sans protection : faites-le perdre des incréments, et repérez dans la trace l'endroit exact où la valeur est écrasée. Ensuite le producteur-consommateur sans synchronisation : il produira des lectures de cases vides et des écrasements. Enfin les trois sémaphores, à placer correctement — et vous êtes invité à essayer l'ordre fautif du quiz pour voir l'interblocage se produire.

Exercice · JavaScript · à vous de jouer

Observez la course, puis écrivez le sémaphore et protégez le tampon dans le bon ordre.

En attente
// ── Un ordonnanceur à la main ─────────────────────────────────────────────
// Chaque tâche est un générateur : chaque « yield » est un point où le
// processeur peut lui être retiré. C'est exactement la préemption du ch. 4.
function entrelacer(taches, quantum = 1) {
  const files = taches.map((f) => f());
  let restantes = files.length;
  while (restantes > 0) {
    for (const g of files) {
      if (g.fini) continue;
      for (let i = 0; i < quantum; i++) {
        const { done } = g.next();
        if (done) { g.fini = true; restantes--; break; }
      }
    }
  }
}

// ── 1. Le compteur partagé ────────────────────────────────────────────────
let compteur = 0;

function* incrementer(nom, fois) {
  for (let i = 0; i < fois; i++) {
    const lu = compteur;        // lw
    yield;                      // ← le processeur peut être retiré ICI
    const calcule = lu + 1;     // addi
    yield;
    compteur = calcule;         // sw
    yield;
  }
}

compteur = 0;
entrelacer([() => incrementer("A", 50), () => incrementer("B", 50)]);
console.log("sans protection :", compteur, "au lieu de 100");

// ── 2. Le producteur-consommateur ─────────────────────────────────────────
const N = 3;
const tampon = new Array(N).fill(null);
let entree = 0, sortie = 0;
const journal = [];

function* producteur(items) {
  for (const x of items) {
    // ← à protéger : et si le tampon est plein ?
    tampon[entree] = x;
    journal.push("produit " + x + " en " + entree);
    entree = (entree + 1) % N;
    yield;
  }
}

function* consommateur(combien) {
  for (let i = 0; i < combien; i++) {
    // ← à protéger : et si le tampon est vide ?
    const x = tampon[sortie];
    journal.push("consommé " + x + " en " + sortie);
    tampon[sortie] = null;
    sortie = (sortie + 1) % N;
    yield;
  }
}

entrelacer([() => producteur([1, 2, 3, 4, 5, 6]), () => consommateur(6)], 2);
console.log(journal.join("\n"));

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez un sémaphore : P bloque tant que la valeur est nulle, V la
//    relâche. Avec des générateurs, « bloquer » s'écrit : yield tant que la
//    condition n'est pas remplie.
// 2. Protégez le compteur avec un mutex, puis le tampon avec mutex, vide et
//    plein. Vérifiez qu'aucun élément n'est perdu ni lu deux fois.
// 3. Essayez P(mutex) AVANT P(vide) dans le producteur : observez l'arrêt.

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 5 · sur machine

Provoquer la course, puis l'interblocage

Voir de ses yeux un compteur partagé donner un résultat faux, le réparer, et fabriquer volontairement un interblocage pour apprendre à le reconnaître.

4 h
Avant de commencer
  • Le TP 3 : processus
  • gcc avec pthread, et l'option -fsanitize=thread
  1. 1. Le compteur qui ment

    Deux fils d'exécution incrémentent un million de fois la même variable. Exécutez dix fois et notez les résultats. Combien de fois obtenez-vous deux millions ?

  2. 2. Comprendre la perte

    Regardez l'assembleur produit pour l'incrémentation. Décomposez l'opération et construisez l'entrelacement précis qui perd une addition.

  3. 3. L'outil qui le dit

    Recompilez avec le détecteur de courses et exécutez. Lisez le rapport, et identifiez les deux lignes qu'il met en vis-à-vis.

  4. 4. Réparer, trois fois

    Corrigez avec un mutex, puis avec une opération atomique, puis en donnant à chaque fil son propre compteur additionné à la fin. Chronométrez les trois.

  5. 5. Producteur et consommateur

    Écrivez un tampon partagé de dix cases, un fil qui produit, un qui consomme. Faites-le d'abord avec de l'attente active, puis avec des sémaphores. Comparez l'usage du processeur.

  6. 6. Fabriquer l'interblocage

    Deux fils, deux mutex, pris dans l'ordre inverse. Lancez et observez le blocage. Confirmez-le avec un débogueur en regardant où chaque fil est arrêté.

  7. 7. Le réparer

    Corrigez en imposant un ordre global de verrouillage. Expliquez laquelle des quatre conditions de Coffman vous avez brisée.

  8. 8. Le cas sournois

    Écrivez une fonction protégée par un mutex qui en appelle une autre prenant le même mutex. Exécutez, et expliquez le blocage d'un seul fil sur lui-même.

C'est réussi quand
  • Votre compteur non protégé donne un résultat différent à presque chaque exécution
  • Vous savez écrire l'entrelacement qui perd une addition, ligne par ligne
  • La version par compteurs séparés est nettement plus rapide que celle par mutex
  • Vous savez nommer la condition de Coffman que vous avez brisée

Ce que la suite en fait

Le bloc IV change de ressource mais garde la logique. La mémoire du chapitre 6 est une ressource partagée entre processus, allouée et reprise par le système, et les mêmes questions y reviennent : qui obtient quoi, dans quel ordre, et que faire quand il n'y en a plus.

Le lien le plus direct est ailleurs. La table des pages du chapitre 6 est une structure de données du noyau, modifiée par plusieurs cœurs à la fois : elle est elle-même une section critique, protégée par les mécanismes de ce chapitre. Le système d'exploitation est le premier programme concurrent auquel s'appliquent les règles qu'il fournit aux autres.

À retenir

Flashcards · 1 / 5Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 10 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.