Nous travaillons sur le programme 
prog04_lock_free.cpp.
Ce programme nous montrera que les opérations atomiques permettent de
  réaliser des synchronisation sans quitter le mode utilisateur.
Parmi les motifs d'insatisfaction relevés dans le programme précédent il y
  avait les créations et destructions incessantes des 
threads.
Une solution beaucoup plus raisonnable consiste à démarrer une fois pour
  toutes l'ensemble des 
threads pour qu'ils exécutent en boucle leur
  traitement.
Il faut bien entendu des moyens de synchronisation pour qu'ils effectuent
  une nouvelle itération de leur traitement lorsque le programme principal le
  requiert.
Le second défaut évoqué concernait le recours incessant aux appels systèmes
  et à l'ordonnanceur pour assurer la synchronisation des 
threads.
Une solution alternative consiste à se passer complètement des objets de
  synchronisation en mode noyau (sémaphores d'exclusion mutuelle et variables
  conditions) et à utiliser des opérations atomiques pour assurer la
  synchronisation entièrement en mode utilisateur
  (
“lock-free algorithms”).
Au delà des détails de réalisation de ces opérations de synchronisation, ce
  procédé introduit une modification de l'architecture de l'application.
En effet, lorsque nous invoquions des appels bloquants (attente des
  
threads, synchronisation en mode noyau) nous utilisions explicitement
  
N threads pour occuper 
N CPUs ; le programme principal
  étant bloqué il n'utilisait aucun CPU pendant l'attente.
Si maintenant l'attente a lieu en mode utilisateur, il s'agit d'une
  
“attente active” c'est à dire une boucle qui teste une variable atomique
  de manière incessante jusqu'à ce qu'elle change d'état ; cela occupe
  pleinement le CPU !
Dans ces conditions, il n'est pas question de confier le travail utile à
  
N threads pendant que le programme principal les attend de
  manière active.
Il faut au contraire utiliser 
N-1 threads et confier au programme
  principal une partie du travail utile ; ainsi le temps d'attente active
  après les phases de travail utile seront très courtes puisque le travail
  est équitablement découpé en parties.
Les barrières de synchronisation à base de variables atomiques sont assez
  similaires à celles que nous venons de réaliser : une partie consiste à
  attendre qu'une variable change d'état tandis que l'autre met fin à cette
  attente en modifiant la variable en question.
Toutefois, puisqu'il n'y a plus de section critique assurée par un verrou, il
  faut coordonner les modifications de multiples variables autour de la
  barrière de synchronisation : modifier les variables 
avant la
  variable atomique qui sert à la synchronisation (opération 
“release”),
  et consulter ces variables 
après la boucle qui attend le changement
  de la variable atomique (opération 
“acquire”)
Il y a moyen de contrôler très finement l'ordre mémoire de ces opérations
      atomiques 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).
    (voir
      
std::memory_order)
.
Pour revenir au problème qui nous sert de prétexte, au delà de la
  synchronisation qui permet de relancer le traitement d'une prochaine image,
  il nous faut assurer les synchronisations avant et après l'étape
  intermédiaire qui consiste à fusionner et accumuler les histogrammes
  partiels.
La première de ces deux synchronisation est indispensable et sera similaire
  dans son principe à celle que nous avions déjà réalisée.
En revanche nous pouvons trouver une alternative à la seconde.
En effet, les 
threads qui ne réalisent pas le cumul de l'histogramme
  n'ont rien d'autre à faire pendant qu'un seul travaille.
Dans ces conditions, il est finalement plus efficace de laisser chaque
  
thread faire le même travail de cumul (de manière redondante donc)
  dans des variables locales plutôt que leur faire subir une nouvelle
  barrière de synchronisation qui attendrait que l'unique 
thread ait
  fini le cumul.
Les points 
{1} et 
{2} du programme fourni devront être complétés
  de manière semblable à ce que vous avez déjà réalisé pour lancer et
  attendre de multiples 
threads si ce n'est que, contrairement aux deux
  solutions précédentes, ces deux étapes ne seront exécutées qu'une seule
  fois et non de manière répétée pour chacune des images à traiter.
Toutefois, le tout premier 
thread (numéro 
0) du vecteur 
sd.td
  ne sera pas explicitement créé (ni attendu) car c'est le programme principal
  qui effectuera sa part de travail.
Remarquez notamment que le code exécuté par les 
threads est maintenant
  découpé en deux fonctions : la première 
threadTask() se contente de
  respecter la synchronisation pour le passage d'une image à la suivante et
  elle invoque à chaque fois la seconde 
threadWork() qui effectue une
  part du traitement sur l'image courante.
L'intérêt de ce découpage vient du fait que le programme principal peut alors
  invoquer la seconde fonction 
threadWork() en se faisant passer pour le
  
thread qui n'a pas été explicitement créé (voir l'appel entre les
  points 
{3} et 
{4}).
Les points 
{3}, 
{4} et 
{5} du programme fourni seront
  complétés pour assurer la synchronisation globale du procédé (passage
  d'une image à l'autre et fin de la séquence d'images).
Bien entendu, les points 
{7} et 
{8} doivent être complétés de
  manière complémentaire.
Remarquez que, puisque les 
threads restent désormais tourner en arrière
  plan, il est maintenant nécessaire de leur signifier explicitement qu'ils
  doivent se terminer (membre 
done de la structure partagée 
sd)
  lorsqu'il n'y a plus d'image à traiter ; dans les deux solutions
  précédentes le programme principal arrêtait simplement de créer de
  nouveaux 
threads.
Le point 
{8} doit assurer la synchronisation qui intervient entre les
  deux phases du traitement selon un principe similaire à celui du programme
  précédent : nous comptons les 
threads qui sont encore dans la
  première phase, chacun décrémente le compteur lorsqu'il a fini cette phase
  et attend que ce compteur atteigne 
0 pour passer à la phase
  suivante.
Bien entendu, le point 
{3} doit initialiser les compteurs
  
inHistogram et 
inCurrentStep au nombre total de 
threads
  qui participent au traitement (
threadCount) afin que le nombre
  de décrémentation coïncide.
Ce nouveau programme peut être testé comme les précédents afin de relever
  les nouvelles performances obtenues.
Cette fois elles doivent être meilleures et plus stables que dans la version
  précédente.
En particulier, le gain de performances doit être plus appréciable dans le cas
  de petites images que dans le cas de grandes.
Effectivement, lorsque les images deviennent petites, le temps de calcul
  devient court alors que le temps de synchronisation ne change pas (donc
  augmente en proportion) ; si nous utilisons comme ici un procédé qui
  allège le coût des barrières de synchronisation, l'effet de cette
  modification est très sensible
Ceux d'entre vous qui utilisent des machines utilisant la technologie SMT
      peuvent à nouveau l'expérimenter en passant l'option smt sur la
      ligne de commande.
    Il se peut que ça apporte un surcroît de performances mais ce n'est
      pas systématique (notamment en fonction de la taille des images).
.
Il est connu que l'attente active est en général à éviter car elle a tendance
  à gaspiller les ressources de calcul et l'énergie.
Elle ne doit être employée qu'à bon escient, dans des conditions maîtrisées,
  quand les performances sont un critère déterminant ; c'est le cas
  ici.
En effet, dans ce scénario, les attentes actives sont toujours extrêmement
  courtes :
- la charge de travail est équitablement répartie entre tous les
    threads (y compris le programme principal),
 
- à peine une image est-elle traitée que le traitement de la suivante doit
    immédiatement commencer.
 
Ce n'est pas toujours le cas ; s'il fallait attendre entre chaque image
  (pour produire un affichage par exemple) la barrière de synchronisation
  qui assure leur enchaînement devrait reposer sur une variable condition
  afin de laisser les CPUs inactifs pendant cette longue attente.
La barrière de synchronisation qui intervient entre les deux phases du
  traitement mérite quant à elle de conserver l'usage des opérations
  atomiques puisqu'il n'y a aucune raison d'attendre longuement entre ces
  deux phases.
Dans tous les cas, il faut privilégier la stratégie qui consiste à lancer les
  
threads une fois pour toutes et à les synchroniser par le moyen qui
  convient au problème (attente active ou bloquante).
En effet, même si exceptionnellement sous Linux les créations et destructions
  incessantes de 
threads ne sont pas extrêmement pénalisantes, ce n'est
  généralement pas le cas avec d'autres systèmes.
Documentation utile :