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 :