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 :