Probleme – Un compteur juste, et ce qu'il coûte
Exercice · niveau 3 (difficile) · informatique (MP2I/MPI), chapitre 22 — Concurrence et synchronisation
Énoncé
Quatre fils incrémentent de fois un compteur partagé.
- Écrire quatre versions : sans protection ; avec un mutex à chaque incrément ; avec un mutex tous les incréments ; avec une somme locale et une seule prise de verrou par fil.
- Mesurer valeur et temps.
- Que conclure sur le coût de la correction ?
- Prouver la correction de la quatrième version.
Corrigé
1. Les quatre versions.
long compteur = 0;
pthread_mutex_t verrou = PTHREAD_MUTEX_INITIALIZER;
/* 1. aucune protection : FAUSSE */
void* v1(void* a) {
for (int i = 0; i < TOURS; i++) { compteur = compteur + 1; }
return NULL;
}
/* 2. un verrou à chaque incrément : juste, et cher */
void* v2(void* a) {
for (int i = 0; i < TOURS; i++) {
pthread_mutex_lock(&verrou);
compteur = compteur + 1;
pthread_mutex_unlock(&verrou);
}
return NULL;
}
/* 3. un verrou tous les PAQUET incréments : granularité choisie */
void* v3(void* a) {
for (int i = 0; i < TOURS; i += PAQUET) {
pthread_mutex_lock(&verrou);
for (int k = 0; k < PAQUET; k++) { compteur = compteur + 1; }
pthread_mutex_unlock(&verrou);
}
return NULL;
}
/* 4. somme locale, UNE seule prise de verrou par fil */
void* v4(void* a) {
long local = 0; /* sur la pile : privé */
for (int i = 0; i < TOURS; i++) { local = local + 1; }
pthread_mutex_lock(&verrou);
compteur = compteur + local; /* la SEULE section critique */
pthread_mutex_unlock(&verrou);
return NULL;
}
2. Les mesures, trois exécutions concordantes, attendus :
| version | valeur obtenue | temps |
|---|---|---|
| 1. aucune protection | à s | |
| 2. mutex à chaque incrément | s | |
| 3. mutex tous les | s | |
| 3. mutex tous les | s | |
| 4. somme locale, une prise | s |
3. Trois conclusions, et la troisième démolit une idée reçue.
(a) Le verrou fin coûte cher : la version 2 est fois plus lente que la version 1. Ce n'est pas le lock lui-même qui coûte — c'est la contention. Quatre fils se disputent un verrou quatre millions de fois : chaque prise raté endort un fil, et le réveiller passe par le noyau.
(b) La granularité est un réglage, pas une propriété : passer d'un verrou par incrément à un verrou tous les cent divise le temps par , et rend exactement le même résultat. On ne choisit pas entre juste et rapide : on choisit la taille de la section critique.
(c) Et voici le point. La version 4, correcte, est plus rapide que la version 1, qui est fausse — s contre à s. Non pas malgré la protection, mais grâce à elle : chaque fil travaille sur sa propre variable de pile, que le processeur garde dans un registre et dont la ligne de cache n'est partagée avec personne. La version 1, elle, fait rebondir la même ligne de cache entre quatre cœurs à chaque incrément.
Quand la protection semble coûteuse, la réponse n'est pas de la retirer : c'est de réduire ce qui est partagé. Ici, on est passé de quatre millions d'accès partagés à quatre, et le programme est devenu à la fois juste et le plus rapide de tous.
C'est le patron dit de réduction : chaque fil calcule un résultat partiel en privé, et l'on combine les résultats à la fin. Il vaut pour toute opération associative — somme, maximum, comptage, concaténation — et c'est la façon dont on parallélise en pratique. Le chapitre chap:diviser en donne le schéma général ; la concurrence n'y ajoute que le verrou final.
4. La correction de la version 4.
Spécification. Entrée : fils, un entier . Sortie : compteur vaut après le dernier pthread_join.
Invariant de la boucle locale. Après tours, local vaut . La variable est sur la pile du fil : aucun autre fil ne peut y accéder, l'invariant ne peut donc pas être cassé de l'extérieur. C'est ce qui rend cette boucle triviale à prouver, là où celle de la version 1 est infalsifiable.
Invariant global. On note l'ensemble des fils ayant achevé leur section critique. L'invariant est : compteur . Il est vrai au départ (). Chaque exécution de la section critique ajoute et fait passer un fil dans : l'invariant est préservé. Le mutex garantit que ces exécutions sont sérialisées, donc qu'aucune n'observe un état intermédiaire d'une autre.
Conclusion. Après les pthread_join, tous les fils sont dans , donc compteur . Les join font partie de la preuve : sans eux, on lirait compteur à un instant où n'est pas complet, et l'exercice 22.3 dit ce qu'on obtiendrait.
Complexité. par fil en temps, prises de verrou au total — contre pour la version 2. C'est le rapport contre , et c'est exactement ce que le chronomètre a mesuré.
Les autres exercices de ce chapitre Le cours du chapitre
Un blocage sur cet exercice ? Le tuteur d'Adloun guide par questions, sans donner la réponse.