Nous travaillons sur le programme
prog03_synchronisation.cpp.
Ce programme nous montrera que orsque plusieurs
threads manipulent des
données communes des opérations de synchronisation sont nécessaires
pour en garantir l'intégrité.
Dans cet exercice, nous reprenons la démarche qui consiste à lancer plusieurs
threads en parallèle et à attendre qu'ils aient tous terminé leur
exécution.
À cette nouvelle occasion, ils utiliseront à la fois des données qui leur
sont propres et des données partagées.
La distinction entre les données propres à chaque
thread (qu'il est seul
à manipuler donc) et celles qui sont partagées par l'ensemble est justement
un point crucial dans toute démarche de parallélisation.
Pour des raisons pédagogiques, à partir de maintenant nous nous efforcerons
de distinguer systématiquement ces différentes catégories de données en
les regroupant dans des structures de données explicitement définies à
cet effet dans le programme fourni.
Jusqu'alors, chaque
thread manipulé dans nos exemples était entièrement
désigné par un objet
std::thread ; désormais, notre structure
ThreadData contient bien entendu un tel objet mais peut également
contenir toutes les informations que nous souhaitons associer à un
thread en particulier (l'entier
result ici).
De la même façon, la structure
SharedData représente l'ensemble des
données qui peuvent être accédées par les
threads du problème.
En particulier son vecteur de structures
ThreadData joue le rôle
du vecteur de
std::thread que nous utilisions dans les exemples
précédents.
Les autres membres sont spécifiques au problème traité (des entiers et des
objets de synchronisation ici).
En suivant cette démarche il suffit de transmettre à chaque
thread une
référence sur la structure
SharedData et un indice entier pour qu'il
puisse avoir à la fois accès aux données partagées du problème et aux
données qui lui sont propres (l'indice lui permet de retrouver dans le
vecteur la structure
ThreadData qui le concerne).
En complétant le point
{1}
du programme fourni, vous démarrerez tous
les
threads du problème en leur transmettant les arguments
attendus.
Attention, la structure
SharedData doit être passée par référence et non
par recopie afin qu'elle reste bien unique dans le problème.
Remarquez comment au début de la fonction
threadTask() on s'empresse
d'accéder à la structure
ThreadData qui est propre au
thread
courant.
En complétant le point
{2}
du programme fourni, vous réaliserez
l'opération d'attente sur tous les
threads.
La nouveauté ici vient du fait qu'après l'attente de chacun des
threads
nous récupérons une information (l'entier
result) qu'il a mise à jour
dans sa propre structure
ThreadData.
L'accès à cette information à ce point du programme principal n'est pas
problématique dans la mesure où nous venons justement de nous assurer que
le
thread concerné a terminé son exécution ; l'information
recueillie n'est donc plus susceptible d'être modifiée par le
thread
concerné.
Remarquez que pour l'instant les
macros USE_MUTEX et
USE_ATOMIC
situées en haut du fichier source valent toutes les deux
0.
Veuillez alors examiner attentivement les structures
SharedData et
ThreadData, la fonction
threadTask() et la fin du programme
principal en considérant que nous sommes dans le cas
“unsynchronised”.
Vous devriez comprendre que chaque
thread effectue les mêmes
accumulations de valeurs sur le membre
count de la structure partagée
que sur sa variable
count locale.
Puisque cette variable locale est finalement recopiée dans le membre
result de la structure qui est propre à chaque
thread et que le
programme principal calcule dans une variable
localCount la somme
de tous ces membres
result, nous en déduisons que la valeur de
localCount doit être égale à celle du membre
count de la
structure partagée
Ce membre qui est manipulé par plusieurs threads à la fois est
qualifié avec le mot clef volatile.
Ce mot clef signifie que le membre en question peut changer de valeur
“spontanément” (à cause des autres threads qui le manipulent
ici).
Le compilateur se garde alors d'effectuer une optimisation qui consiste
à ne pas relire la valeur de cette variable dans la mémoire lorsqu'il
suppose qu'elle n'a pas pu évoluer depuis le dernier accès (si,
justement ! à cause des autres threads).
L'emploi du mot clef volatile est une pratique à adopter
systématiquement dans le cas où nous savons qu'une même variable
est manipulée par plusieurs traitements en parallèle ; nous nous
assurons ainsi de la visibilité de ses divers changements de valeur.
.
Pour tester ce programme il suffit de le lancer sans plus d'arguments.
Il détectera alors le nombre de CPUs sur la machine pour utiliser autant de
threads (vous pouvez toutefois choisir le nombre de
threads sur
la ligne de commande).
Quelle surprise n'aurez-vous pas de constater que ces calculs ne donnent
pas des résultats identiques !
L'explication vient du fait que les opérations même les plus élémentaires
à écrire (de simples
+= ici) ne sont pas
“atomiques” : c'est
à dire qu'elles sont constituées de plusieurs étapes.
Dans le cas présent, l'incrémentation revient à lire la valeur depuis la
mémoire, lui faire subir une addition dans un registre du processeur et
enfin réécrire dans la mémoire la nouvelle valeur.
Si plusieurs
threads lisent la même variable simultanément, ils
obtiennent la même valeur, la modifient tous dans un registre de leur
CPU et écrivent tous une nouvelle valeur.
Parmi toutes ces valeurs écrites nous ne savons pas laquelle le sera en
dernier et celle-ci n'aura subi qu'une seule des multiples
additions !
Le but de ce contre-exemple était d'attirer votre attention sur le fait
qu'il est indispensable de synchroniser l'accès aux données partagées
par de multiples traitements simultanés ; sans ces précautions nous
constatons que nos calculs deviennent incohérents !
Nous envisageons désormais de rendre cohérents ces calculs.
Pour ceci, nous devons nous assurer que la séquence d'étapes qui constitue
l'opération d'accumulation sur le membre
count partagé sera toujours
terminée lorsqu'une autre commencera.
Ainsi la valeur lue par un
thread juste avant l'accumulation ne sera pas
lue ni modifiée par un autre
thread tant que l'écriture de la valeur
modifiée n'aura pas eu lieu dans ce même
thread.
Ceci revient à
“sérialiser” ces opérations sensibles qui, sinon, auraient
lieu simultanément (avec les conséquences déjà constatées).
Un moyen classique d'assurer cette sérialisation consiste à réaliser une
“section critique” à l'aide d'un
“sémaphore d'exclusion mutuelle”.
En d'autres termes, il s'agit d'encadrer l'accès à une ressource par l'usage
d'un verrou (comme sur une porte) qu'on verrouille avant d'accéder à la
ressource (comme entrer dans la salle de bain) et qu'on déverrouille
lorsqu'on a fini d'utiliser la ressource.
On s'interdit d'utiliser directement la ressource sans se soumettre à l'usage
du verrou :
- pour entrer dans la salle de bain le verrou doit être ouvert
sinon on attend à la porte,
- le fait d'entrer ferme le verrou ainsi on est seul dans la salle de bain,
- lorsqu'on quitte la salle de bain on ouvre le verrou et la salle de bain
devient disponible pour la personne suivante.
Pour revenir au code, nous devons tout d'abord passer à
1 la
macro
USE_MUTEX en haut du fichier source ; cela a pour conséquence de
modifier le contenu de la structure
SharedData.
Désormais le membre
count est accompagné d'un membre
mtx de type
std::mutex : il s'agit d'un sémaphore d'exclusion mutuelle
(
mutual exclusion).
Le point
{3}
du programme fourni doit alors être complété pour réaliser
l'accumulation dans le membre
count partagé sous le contrôle du verrou
mtx : il suffit de le verrouiller (fonction membre
lock())
avant l'opération et de le déverrouiller (fonction membre
unlock())
après
Les bonnes pratiques de C++ recommandent de recourir à un objet
utilitaire std::lock_guard pour manipuler un tel verrou (pour ne
jamais oublier de le déverrouiller).
Dans un but pédagogique nous manipulons directement le verrou ici afin
de rendre explicites les opérations de dé/verrouillage.
.
En relançant le programme vous devriez constater que désormais les comptes
sont à coup sûr cohérents.
En revanche la durée d'exécution a été considérablement allongée.
En effet, les sémaphores d'exclusion mutuelle sont des objets de
synchronisation implémentés dans le noyau du système d'exploitation.
Leur manipulation invoque systématiquement des appels système et l'usage de
l'ordonnanceur (suspension/relance des tâches) ce qui est infiniment plus
lent que les simples opérations sur les entiers qui nous servent de
prétexte.
Toutefois, dans le cas présent, la portion de code sensible que nous
protégeons par une section critique est extrêmement courte :
l'accumulation sur une unique variable entière.
Même si, comme constaté plus haut, de telles opérations ne sont pas atomiques
en temps normal, les processeurs disposent toutefois d'une version atomique
de quelques opérations élémentaires (lecture, écriture, addition,
soustraction , et, ou...).
De telles opérations utilisent une synchronisation matérielle pour assurer
qu'une variable donnée n'est pas modifiée simultanément par plusieurs
CPUs.
Elles ne sont pas utilisées par défaut car cette synchronisation matérielle
les rend plus lentes que les opérations classiques mais peuvent toutefois
être invoquées à la demande ; le langage
C++ en propose notamment
l'usage de manière standard à travers les types
std::atomic.
Pour en faire usage dans notre programme, il faut tout d'abord restaurer à
0 la
macro USE_MUTEX et passer à
1 la
macro
USE_ATOMIC en haut du fichier source ; cela a pour conséquence de
modifier à nouveau le contenu de la structure
SharedData.
Cette fois, le membre
count est de type
std::atomic_int ; il
s'utilise en apparence comme un simple entier mais
C++ utilise des
opérations atomiques lorsqu'on le manipule.
Le point
{4}
du programme fourni doit alors être complété pour réaliser
l'accumulation sur le membre
count partagé : ceci a strictement la
même forme que l'accumulation sur le simple entier initial mais
C++
traduira le code différemment
Il y a moyen de contrôler très finement l'ordre mémoire de ces opérations
atomiques (std::memory_order) mais ceci sort largement du cadre de
cette initiation au parallélisme et n'a que peu d'effet sur les modèles
de processeurs utilisés pour ces exercices (x86/x86_64).
.
En relançant le programme vous devriez constater que les comptes restent à
coup sûr cohérents mais que cette fois-ci le temps d'exécution est bien
moins pénalisant que dans le cas de l'usage d'un sémaphore d'exclusion
mutuelle.
Toutefois, comme il était prévisible, cette durée est tout de même plus
longue que lors de l'usage d'un simple entier (donnant des résultats
incorrects).
C'est bien pour cette raison que les opérations atomiques des processeurs
ne sont pas utilisées pour les calculs d'usage général et ne le sont que
de manière très localisée sur quelques variables que nous partageons
intentionnellement.
Il est important de comprendre que les opérations atomiques n'assurent
l'atomicité que pour une unique opération sur une unique variable simple
(un entier par exemple) ; il n'est pas possible de modifier en une
seule opération atomique plusieurs variables, et si plusieurs opérations
atomiques sont utilisées séquentiellement, l'ensemble n'est bien entendu
pas atomique.
Dans ce cas, il est nécessaire de recourir à un sémaphore d'exclusion
mutuelle (comme vu précédemment) ou à de l'attente active (abordée dans
un prochain sujet).
Documentation utile :