Synchronisation

Voir Progression.htmlpour en savoir plus sur les garanties de progression telles que Wait-Free, Lock-Free et Obstruction-Free.

Il arrive que l'on ait besoin de synchronisation dans un programme multiprogrammé, entre autres pour éviter les très vilaines Data Races qui entraînent le fruit de notre travail dans des situations de comportement indéfini.

La synchronisation est principalement caractéristique de la concurrence car dans le parallélisme, du moins dans sa déclinaison puriste, l'absence d'états mutables partagés permet d'escamoter cette problèmatique.

La synchronisation a un coût, que l'on préfère généralement éviter lorsque cela s'avère possible.

En ce sens, examinez l'image proposée à droite; il s'agit d'une photographie d'une diapositive électronique de Kevlin Henney, prise par Cory House (voir https://twitter.com/housecor/status/864482919099752450 pour l'original).

Portez une attention particulière aux axes :

Cette représentation met de l'avant une réalité de la programmation, en particulier de la programmation concurrente :

Conséquemment :

Votre programme ne s'en portera que mieux.

Synchronisation avec verrous

La synchronisation avec verrous a été localisée dans un document à part entière : verrous.html

Synchronisation sans verrous

Il est possible, mais très périlleux, d'exprimer des algorithmes et des structures de données synchronisées sans passer par des verrous. On utilise alors typiquement des variables et opérations atomiques.

La synchronisation sans verrous repose fortement sur les variables atomiques et sur Compare-Exchange. Elle peut éviter des interblocages, mais mène typiquement à des programmes plus difficiles à tester, à écrire et à comprendre.

Plusieurs exemples sont proposés dans la section Lectures complémentaires, plus bas.

Considérations clés

Plusieurs envisagent de programmer à l'aide de structures de données sans verrous, et le font typiquement dans une optique de « performance », au sens où ne pas se suspendre sur un verrou peut aider à améliorer la fluidité et la vitesse d'exécution d'un programme. Notez, car c'est important, que « sans verrou » ne signifie pas nécessairement rapide : si plusieurs threads accèdent concurremment et fréquemment à une structure de données sans verrous, causant de la contention, il est possible qu'un programme soit plus lent qu'un équivalent reposant sur des structures de données avec verrous.

La raison pour cet état de fait est qu'un algorithme sans verrous est actif, et boucle typiquement jusqu'à ce que l'opération qu'il tente d'accomplir ait réussi. Que d'autres threads r réalisent des actions susceptibles d'interférer avec lui peut le faire boucler plus longtemps, alors qu'un algorithme avec verrous verrait les threads se suspendre volontairement et réaliser leurs opérations à la queue-leu-leu, évacuant en quelque sorte les considérations résultant de la contention.

Exprimé autrement, sans verrous signifie souvent, mais pas nécessairement, plus rapide que « avec verrous ». Ce qui est presque toujours vrai, en retour, est qu'un algorithme (ou une structure de données) sans verrous est délicat à construire, à valider et à tester.

Prenez la maxime de Donald E. Knuth en considération si vous envisagez vous lancer dans la conception d'algorithmes et de structure de données sans verrous : l'optimisation prématurée est la source de tous les maux. Si vous pensez aller dans cette direction, identifiez d'abord les goulots d'étranglement et les facteurs de ralentissement réel de votre programme. Si vos mesures montrent que le recours à des verrous est la source de vos ennuis, alors seulement lancez-vous dans des stratégies plus délicates.

L'approche RCU

Cette approche est une cousine très légère de ce que l'on trouve parfois dans les langages fonctionnels avec structures de données immuables, sans reposer sur un moteur de collecte d'ordures.

Une approche de synchronisation sans verrous parmi plusieurs, mais avec un rôle particulier dans l'écosystème de C++ est l'approche Read-Copy-Update, ou RCU, qui sert entre autres dans le cas bien connu du noyau de Linux.

L'approche RCU est surtout utilisée pour fins d'accès concurrent à des structures de données chaînées (des listes, par exemples) pour lesquelles la majorité des accès sont en lecutre; pour ce cas d'utilisation bien précis, synchroniser par RCU est souvent presque gratuit.

Pour arriver à cette caractéristique sympathique de coût presque nul, il faut faire en sorte que les threads souhaitant modifier les éléments de la structure de données accédée concurremment ne puissent pas ralentir les threads qui se limitent à y lire des éléments.

Conséquemment, un thread réalisant une écriture par RCU doit en quelque sorte « prendre en charge » les lecteurs qui traversaient cette structure de données à tout moment avant, pendant et après l'écriture. Pour y arriver, les threads modifiant la structure de données doivent tenir à jour de multiples versions de cette structure de données, et doivent attendre que tous les lecteurs susceptibles d'accéder à une version donnée l'aient quitté avant de la libérer.

Les opérations clés de synchronisation par RCU, par exemple pour pointer sur un élément d'une structure ou faire une mise à jour de la structure, passent par des fonctions spécialisées. La beauté est que certaines (pas toutes, évidemment) de ces opérations sont en fait des noop, et servent à titre d'« ingénierie sociale » pour discipliner les gens à agir correctement, ce qui explique le coût faible ou nul de parties de cette approche.

L'exemple qui suit est pris d'une présentation de 2012 par Paul E. McKenney, et montre comment RCU affecte l'écriture du code :

  Sans RCU Avec RCU
Mise à jour (écriture)
p = malloc(sizeof(*p));
p->a = 1;
p->b = 2;
p->c = 3;
gptr = p;
p = malloc(sizeof(*p));
p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gptr, p);
Consultation (lecture)
p = gptr;
foo(p->a, p->b, p->c);
p = rcu_dereference(gptr);
foo(p->a, p->b, p->c);

Les macros utilisées dans cet exemple correspondent essentiellement à ceci (il y a des variantes, et j'ai légèrement adapté l'écriture originale pour C++) :

// rcu_assign_pointer() publie un pointeur
#define rcu_assign_pointer(p, v) \
   ({ \
      smp_wmb(); /* SMP Write Memory Barrier */ \
      (p) = (v); \
   })
// rcu_dereference() permet à un lecteur de s'abonner à un pointeur
#define rcu_dereference(p) \
   ({ \
      decltype(p) _p1 = (*(volatile decltype(p)*)&(p)); \
      smp_read_barrier_depends(); \
      _p1; \
   })

Pour réaliser son travail, RCU repose sur quelques concepts :

Sur cette base, RCU (une version académique, pas commerciale!) peut s'exprimer comme suit (le code est de Paul E. McKenney, pas de moi) :

// primitives en lecture
#define rcu_read_lock() // eh oui, rien!
#define rcu_read_unlock() // idem; c'est pour nous discipliner!
#define rcu_dereference(p) \
({ \
   decltype(p) _p1 = (*(volatile decltype(p)*)&(p)); \
   smp_read_barrier_depends(); \
   _p1; \
})
// primitives en écriture
#define rcu_assign_pointer(p, v) \
({ \
   smp_wmb(); \
  (p) = (v); \
})
void synchronize_rcu() { // prenez ceci comme du pseudocode
   int cpu;
   for_each_online_cpu(cpu)
      run_on(cpu);
}

Événements, signaux et variables conditionnelles

Pour qu'un thread se mette en attente de l'occurrence d'un événement, il existe des mécanismes plus fin qu'une répétitive itérant sans arrêt sur une variable (et provoquant de la contention sur celle-ci tout en consommant des ressources sur le processeur).

Ces mécanismes demandent typiquement que le demandeur se suspende volontairement et soit réveillé par un tiers externe lorsque l'événement se sera produit. Avec C++, le mécanisme privilégié en ce sens est la condition_variable, bien que les future<void> soient aussi pertinents dans des cas ciblés.

Loquets (Latches)

Ce dont nous discutons ci-dessous fait partie de la spécification technique sur la concurrence de C++ telle que prévue pour expérimentation, probablement en vue d'une inclusion dans le standard à partir de C++ 17.

La gamme de primitives de synchronisation de C++ s'enrichit en vue de C++ 17 sur la base de la spécification technique sur la concurrence. Parmi les nouvelles primitives proposées, on trouve les loquets (en anglais : Latches).

Un loquet est une clôture à usage unique, pouvant servir de point de rendez-vous pour threads, la valeur de étant connue à la construction du loquet. Un loquet représente le concept de « point de rendez-vous », typiquement pour synchroniser le démarrage d'un travail collectif ou l'attente du passage à une autre étape.

Un loquet est incopiable. Il contient un compteur initialisé à la construction, décrémenté à chaque fois qu'un thread s'y suspend, et devient prêt (ready()) lorsque la valeur de ce compteur devient nulle. Les méthodes clés d'un loquet sont :

Avec un loquet, il existe une relation Synchronizes-With entre les wait et les décomptes.

Clôtures (Barriers)

Ce dont nous discutons ci-dessous fait partie de la spécification technique sur la concurrence de C++ telle que prévue pour expérimentation, probablement en vue d'une inclusion dans le standard à partir de C++ 17.

Comme mentionné plus haut, la gamme de primitives de synchronisation de C++ s'enrichit en vue de C++ 17 sur la base de la spécification technique sur la concurrence. Parmi les nouvelles primitives proposées, on trouve les clôtures (en anglais : Barriers).

Une clôture est un peu comme un loquet, mais est aussi réutilisable. Les clôtures proposées pour C++ 17 se déclinent en deux types : les barrier et les flex_barrier. La mécanique générale dans chaque cas est :

Les services clés offerts aux threads participants à une clôture sont :

Une clôture ne connaît pas ses participants a priori mais en connaît le nombre. Ce nombre est décrémenté lors de l'arrivée des participants au point de rendez-vous.

Pour le type barrier, la phase de complétion est vide.

Pour le type flex_barrier, la phase de complétion peut être prise en charge par une entité appelable de signature ptrdiff_t() qui retournera -1 ou plus. Cette entité appelable sera sollicitée lorsque s'amorcera la phase de complétion; si elle retourne -1, alors le nombre de participants restera le même à la prochaine utilisation de la clôture, mais si elle retourne un entier supérieur ou égale à, alors cette valeur sera le nombre de participants de la prochaine utilisation de la clôture.

Avec une barrier, il est possible de faire travailler threads en collaboration sur une tâche complexe, une étape à la fois, en synchronisant le passage d'une étape à l'autre. Avec une flex_barrier, le principe est le même mais la valeur de peut changer d'une étape à l'autre.

Mémoire transactionnelle

La synchronisation avec mémoire transactionnelle a été localisée dans un document à part entière : memoire_transactionnelle.html

Contention

On nomme contention sur un outil de synchronisation ou sur une ressource le fait que plusieurs unités d'exécution y accèdent concurremment. Une forte contention sur un objet survient quand deux threads ou plus y accèdent de manière successive et répétitive. La contention a pour effet type de bloquer la plupart des demandeurs d'accès, et influence les caractéristiques d'exécution d'un processus.

Herb Sutter a écrit quelques GOTW (Guru of the Week) en 2014 sur le sujet des conditions de course et de la synchronisation. Voir :

Lectures complémentaires

Quelques liens suivent pour enrichir le propos. J'ai essayé de les grouper par grandes thématiques.

Clôtures

Événements, signaux, variables conditionnelles

À propos des variables conditionnelles (CondVars) :

Programmation sans verrous

À propos de la programmation synchronisée sans verrous, sujet périlleux s'il en est un :

Approche RCU :

Structures de donnéess de données sans verrous :

Autres


Valid XHTML 1.0 Transitional

CSS Valide !