web-dev-qa-db-fra.com

Le mot clé volatile C ++ introduit-il une barrière de mémoire?

Je comprends que volatile informe le compilateur que la valeur peut être modifiée, mais pour accomplir cette fonctionnalité, le compilateur doit-il introduire une barrière de mémoire pour le faire fonctionner?

D'après ma compréhension, la séquence des opérations sur les objets volatils ne peut pas être réorganisée et doit être préservée. Cela semble impliquer que certaines barrières de mémoire sont nécessaires et qu'il n'y a pas vraiment de moyen de contourner cela. Ai-je raison de dire cela?


Il y a une discussion intéressante à cette question connexe

Jonathan Wakely écrit :

... Les accès à des variables volatiles distinctes ne peuvent pas être réorganisés par le compilateur tant qu'ils se produisent dans des expressions complètes distinctes ... à droite que volatile est inutile pour la sécurité des threads, mais pas pour les raisons qu'il donne. Ce n'est pas parce que le compilateur peut réorganiser les accès aux objets volatils, mais parce que le processeur peut les réorganiser. Les opérations atomiques et les barrières de mémoire empêchent le compilateur et le CPU de se réorganiser

À quoi David Schwartz répond dans les commentaires :

... Il n'y a pas de différence, du point de vue de la norme C++, entre le compilateur faisant quelque chose et le compilateur émettant des instructions qui font que le matériel fait quelque chose. Si le CPU peut réorganiser les accès aux volatiles, la norme n'exige pas que leur ordre soit préservé. ...

... La norme C++ ne fait aucune distinction sur ce que fait la réorganisation. Et vous ne pouvez pas affirmer que le CPU peut les réorganiser sans effet observable, donc ça va - la norme C++ définit leur ordre comme observable. Un compilateur est conforme à la norme C++ sur une plate-forme s'il génère du code qui fait faire à la plate-forme ce que la norme requiert. Si la norme exige que les accès aux produits volatils ne soient pas réorganisés, alors une plate-forme qui les réorganise n'est pas conforme. ...

Mon point est que si la norme C++ interdit au compilateur de réordonner les accès à des volatiles distincts, sur la théorie que l'ordre de ces accès fait partie du comportement observable du programme, alors il exige également que le compilateur émette du code qui interdit au CPU de faire alors. La norme ne fait pas de différence entre ce que fait le compilateur et ce que le code de génération du compilateur fait faire au CPU.

Ce qui soulève deux questions: l'une ou l'autre a-t-elle "raison"? Que font réellement les implémentations réelles?

80
Nathan Doromal

Plutôt que d'expliquer ce que fait volatile, permettez-moi de vous expliquer quand vous devez utiliser volatile.

  • À l'intérieur d'un gestionnaire de signaux. Parce que l'écriture dans une variable volatile est à peu près la seule chose que la norme vous permet de faire depuis un gestionnaire de signaux. Depuis C++ 11, vous pouvez utiliser std::atomic à cet effet, mais uniquement si l'atomique est sans verrou.
  • Lorsque vous traitez avec setjmpselon Intel .
  • Lorsque vous traitez directement avec du matériel et que vous voulez vous assurer que le compilateur n'optimise pas vos lectures ou écritures.

Par exemple:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Sans le spécificateur volatile, le compilateur est autorisé à optimiser complètement la boucle. Le spécificateur volatile indique au compilateur qu'il ne peut pas supposer que 2 lectures suivantes renvoient la même valeur.

Notez que volatile n'a rien à voir avec les threads. L'exemple ci-dessus ne fonctionne pas s'il y avait un thread différent écrit dans *foo car aucune opération d'acquisition n'est impliquée.

Dans tous les autres cas, l'utilisation de volatile doit être considérée comme non portable et ne plus passer en revue le code, sauf lorsqu'il s'agit de compilateurs pré-C++ 11 et d'extensions de compilateur (tels que les ms [/volatile:ms switch, qui est activé par défaut sous X86/I64).

52
Stefan

Le mot clé volatile C++ introduit-il une barrière de mémoire?

Un compilateur C++ conforme à la spécification n'est pas requis pour introduire une clôture de mémoire. Votre compilateur particulier pourrait; dirigez votre question vers les auteurs de votre compilateur.

La fonction de "volatile" en C++ n'a rien à voir avec le threading. N'oubliez pas que le but de "volatile" est de désactiver les optimisations du compilateur afin que la lecture à partir d'un registre qui change en raison de conditions exogènes ne soit pas optimisée. Une adresse mémoire qui est en cours d'écriture par un thread différent sur un autre processeur est-elle un registre qui change en raison de conditions exogènes? Non. Encore une fois, si certains auteurs du compilateur ont choisi pour traiter les adresses mémoire écrites par différents threads sur différents processeurs comme s'il s'agissait de registres changeant en raison de conditions exogènes, c'est leur affaire; ils ne sont pas tenus de le faire. Ils ne sont pas non plus tenus - même si cela introduit une barrière de mémoire - de s'assurer, par exemple, que le thread every voit un ordre cohérent des lectures et écritures volatiles.

En fait, volatile est à peu près inutile pour le filetage en C/C++. La meilleure pratique consiste à l'éviter.

De plus: les clôtures de mémoire sont un détail d'implémentation d'architectures de processeur particulières. En C #, où volatile explicitement est conçu pour le multithreading, la spécification ne dit pas que des demi-clôtures seront introduites, car le programme peut fonctionner sur une architecture qui n'a pas de clôtures en premier lieu. Au contraire, encore une fois, la spécification offre certaines garanties (extrêmement faibles) sur les optimisations qui seront évitées par le compilateur, le runtime et le CPU pour imposer certaines contraintes (extrêmement faibles) sur la façon dont certains effets secondaires seront ordonnés. Dans la pratique, ces optimisations sont éliminées par l'utilisation de demi-clôtures, mais c'est un détail de mise en œuvre susceptible de changer à l'avenir.

Le fait que vous vous souciez de la sémantique de volatile dans n'importe quelle langue en ce qui concerne le multithreading indique que vous songez à partager la mémoire entre les threads. Pensez simplement à ne pas le faire. Cela rend votre programme beaucoup plus difficile à comprendre et beaucoup plus susceptible de contenir des bogues subtils et impossibles à reproduire.

21
Eric Lippert

Tout d'abord, les normes C++ ne garantissent pas les barrières mémoire nécessaires pour bien ordonner les lectures/écritures non atomiques. les variables volatiles sont recommandées pour une utilisation avec MMIO, la gestion du signal, etc. Sur la plupart des implémentations volatiles n'est pas utile pour le multi-threading et n'est généralement pas recommandé.

Concernant l'implémentation des accès volatils, c'est le choix du compilateur.

Cette article, décrivant gcc comportement montre que vous ne pouvez pas utiliser un objet volatile comme barrière de mémoire pour ordonner une séquence d'écritures dans la mémoire volatile.

Concernant le comportement icc , j'ai trouvé ceci source indiquant également que volatile ne garantit pas la commande des accès à la mémoire.

Le compilateur Microsoft VS2013 a un comportement différent. Ceci documentation explique comment volatile applique la sémantique Release/Acquire et permet aux objets volatils d'être utilisés dans les verrous/versions sur les threads multiples applications.

Un autre aspect qui doit être pris en considération est que le même compilateur peut avoir un comportement différent wrt. à volatile en fonction de l'architecture matérielle ciblée . Ceci post concernant le compilateur MSVS 2013 indique clairement les spécificités de la compilation avec volatile pour ARM = plates-formes.

Donc ma réponse à:

Le mot clé volatile C++ introduit-il une barrière de mémoire?

serait: Non garanti, probablement pas, mais certains compilateurs peuvent le faire. Vous ne devriez pas vous fier au fait que c'est le cas.

12
VAndrei

Ce que David néglige, c'est le fait que la norme c ++ spécifie le comportement de plusieurs threads interagissant uniquement dans des situations spécifiques et tout le reste entraîne un comportement non défini. Une condition de concurrence critique impliquant au moins une écriture n'est pas définie si vous n'utilisez pas de variables atomiques.

Par conséquent, le compilateur est parfaitement en droit de renoncer à toutes les instructions de synchronisation, car votre processeur ne remarquera que la différence dans un programme qui présente un comportement indéfini en raison d'une synchronisation manquante.

12
Voo

Le compilateur insère uniquement une barrière de mémoire sur l'architecture Itanium, pour autant que je sache.

Le mot clé volatile est vraiment mieux utilisé pour les modifications asynchrones, par exemple, les gestionnaires de signaux et les registres mappés en mémoire; ce n'est généralement pas le bon outil à utiliser pour la programmation multithread.

7
Dietrich Epp

Cela dépend de quel compilateur "le compilateur" est. Visual C++ le fait depuis 2005. Mais la norme ne l'exige pas, donc certains autres compilateurs ne le font pas.

6
Ben Voigt

Ce n'est pas nécessaire. Volatile n'est pas une primitive de synchronisation. Il désactive simplement les optimisations, c'est-à-dire que vous obtenez une séquence prévisible de lectures et d'écritures dans un thread dans le même ordre que celui prescrit par la machine abstraite. Mais les lectures et les écritures dans différents fils n'ont pas d'ordre en premier lieu, cela n'a aucun sens de parler de préserver ou de ne pas préserver leur ordre. L'ordre entre les têtes peut être établi par des primitives de synchronisation, vous obtenez UB sans elles.

Un peu d'explication sur les barrières mémoire. Un CPU typique a plusieurs niveaux d'accès à la mémoire. Il y a un pipeline de mémoire, plusieurs niveaux de cache, puis RAM etc.

Les instructions de la membrane rincent le pipeline. Ils ne changent pas l'ordre dans lequel les lectures et les écritures sont exécutées, cela oblige simplement les commandes en attente à être exécutées à un moment donné. Il est utile pour les programmes multithreads, mais pas beaucoup autrement.

Les caches sont normalement automatiquement cohérents entre les processeurs. Si l'on veut s'assurer que le cache est synchronisé avec la RAM, le vidage du cache est nécessaire. C'est très différent d'un membre.

5
n.m.

C'est en grande partie de la mémoire, et basé sur pré-C++ 11, sans threads. Mais après avoir participé aux discussions sur le filetage dans le comité, je peux dire que le comité n'a jamais voulu que volatile puisse être utilisé pour la synchronisation entre les fils. Microsoft l'a proposé, mais la proposition n'a pas été retenue.

La spécification clé de volatile est que l'accès à un volatile représente un "comportement observable", tout comme IO. De la même manière, le compilateur ne peut pas réorganiser ou supprimer des E/S spécifiques, il ne peut pas réorganiser ou supprimer les accès à un objet volatil (ou plus correctement, les accès via une expression lvalue avec un type qualifié volatile). L'intention initiale de volatile était, en fait, de prendre en charge les E/S mappées en mémoire. Le "problème" avec cela, cependant, est que c'est l'implémentation définie qui constitue un "accès volatil". Et de nombreux compilateurs l'implémentent comme si la définition était "une instruction qui lit ou écrit en mémoire a été exécutée". Ce qui est une définition légale, quoique inutile, si l'implémentation le spécifie. (Je n'ai pas encore trouvé la spécification réelle d'un compilateur.)

Sans doute (et c'est un argument que j'accepte), cela viole l'intention de la norme, car à moins que le matériel ne reconnaisse les adresses comme des entrées-sorties mappées en mémoire et n'inhibe toute réorganisation, etc., vous ne pouvez même pas utiliser volatile pour les entrées-sorties mappées en mémoire, au moins sur les architectures Sparc ou Intel. Néanmoins, aucun des compilateurs que j'ai examinés (Sun CC, g ++ et MSC) ne génère d'instructions de clôture ou de membre. (À propos du moment où Microsoft a proposé d'étendre les règles pour volatile, je pense que certains de leurs compilateurs ont mis en œuvre leur proposition et ont émis des instructions de clôture pour les accès volatils. Je n'ai pas vérifié ce que font les compilateurs récents, mais cela ne le serait pas ' Je ne serais pas surpris si cela dépendait d'une option de compilation. La version que j'ai vérifiée - je pense que c'était VS6.0 - n'émettait pas de clôtures, cependant.)

5
James Kanze

Le compilateur doit introduire une barrière de mémoire autour de volatile accès si et seulement si cela est nécessaire pour faire les utilisations de volatile spécifiées dans le travail standard (setjmp, signal gestionnaires, etc.) sur cette plate-forme particulière.

Notez que certains compilateurs vont bien au-delà de ce qui est requis par la norme C++ afin de rendre volatile plus puissant ou utile sur ces plateformes. Le code portable ne doit pas compter sur volatile pour faire autre chose que ce qui est spécifié dans la norme C++.

4
David Schwartz

J'utilise toujours volatile dans les routines de service d'interruption, par exemple l'ISR (souvent le code d'assemblage) modifie un emplacement mémoire et le code de niveau supérieur qui s'exécute en dehors du contexte d'interruption accède à l'emplacement mémoire via un pointeur vers volatile.

Je le fais pour RAM ainsi que les E/S mappées en mémoire.

Sur la base de la discussion ici, il semble que ce soit encore une utilisation valide de volatile mais n'a rien à voir avec plusieurs threads ou CPU. Si le compilateur d'un microcontrôleur "sait" qu'il ne peut pas y avoir d'autres accès (par exemple, tout est sur puce, pas de cache et qu'il n'y a qu'un seul cœur), je penserais qu'une clôture de mémoire n'est pas impliquée du tout, le compilateur doit juste empêcher certaines optimisations.

Alors que nous empilons plus de choses dans le "système" qui exécute le code objet, presque tous les paris sont désactivés, du moins c'est ainsi que j'ai lu cette discussion. Comment un compilateur pourrait-il jamais couvrir toutes les bases?

2
Andrew Queisser

Le mot clé volatile signifie essentiellement que la lecture et l'écriture d'un objet doivent être exécutées exactement comme écrit par le programme, et non optimisées en aucune façon. Le code binaire doit suivre le code C ou C++: une charge où cela est lu, un magasin où il y a une écriture.

Cela signifie également qu'aucune lecture ne devrait entraîner une valeur prévisible: le compilateur ne doit rien supposer d'une lecture même immédiatement après une écriture dans le même objet volatil:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatile peut être l'outil le plus important dans la boîte à outils "C est un langage d'assemblage de haut niveau".

Que la déclaration d'un objet volatile soit suffisante pour garantir le comportement du code qui traite des changements asynchrones dépend de la plate-forme: différents CPU donnent différents niveaux de synchronisation garantis pour les lectures et écritures de mémoire normales. Vous ne devriez probablement pas essayer d'écrire un tel code multithreading de bas niveau à moins que vous ne soyez un expert dans le domaine.

Les primitives atomiques fournissent une belle vue de niveau supérieur des objets pour le multithreading qui permet de raisonner facilement sur le code. Presque tous les programmeurs doivent utiliser des primitives atomiques ou des primitives qui fournissent des exclusions mutuelles comme des mutex, des verrous en lecture-écriture, des sémaphores ou d'autres primitives de blocage.

0
curiousguy

Je pense que la confusion autour des réorganisations volatiles et des instructions provient des 2 notions de réorganisation des CPU:

  1. Exécution dans le désordre.
  2. Séquence de lecture/écriture de la mémoire telle qu'elle est vue par les autres CPU (réorganisation en un sens que chaque CPU peut voir une séquence différente).

La volatilité affecte la façon dont un compilateur génère le code en supposant une exécution à thread unique (cela inclut les interruptions). Il n'implique rien sur les instructions de barrière de mémoire, mais il empêche plutôt un compilateur d'effectuer certains types d'optimisations liées aux accès à la mémoire.
Un exemple typique est la récupération d'une valeur de la mémoire, au lieu d'utiliser une mise en cache dans un registre.

Exécution dans le désordre

Les processeurs peuvent exécuter des instructions dans le désordre/à titre spéculatif à condition que le résultat final ait pu se produire dans le code d'origine. Les processeurs peuvent effectuer des transformations qui ne sont pas autorisées dans les compilateurs car les compilateurs ne peuvent effectuer que des transformations qui sont correctes en toutes circonstances. En revanche, les CPU peuvent vérifier la validité de ces optimisations et revenir en arrière si elles s'avèrent incorrectes.

Séquence de lecture/écriture en mémoire vue par les autres CPU

Le résultat final d'une séquence d'instructions, l'ordre effectif, doit correspondre à la sémantique du code généré par un compilateur. Cependant, l'ordre d'exécution réel choisi par la CPU peut être différent. L'ordre effectif tel que vu dans d'autres CPU (chaque CPU peut avoir une vue différente) peut être limité par des barrières mémoire.
Je ne sais pas dans quelle mesure l'ordre effectif et réel peut différer, car je ne sais pas dans quelle mesure les barrières de mémoire peuvent empêcher les processeurs d'effectuer une exécution dans le désordre.

Sources:

0
Paweł Batko

Pendant que je travaillais sur un didacticiel vidéo téléchargeable en ligne pour le développement de graphiques 3D et de moteur de jeu en collaboration avec OpenGL moderne. Nous avons utilisé volatile dans l'une de nos classes. Le site Web du didacticiel se trouve ici et la vidéo fonctionnant avec le mot clé volatile se trouve dans le Shader Engine série vidéo 98. Ces œuvres ne sont pas les miennes mais sont accréditées Marek A. Krzeminski, MASc et ceci est un extrait de la page de téléchargement vidéo.

"Puisque nous pouvons maintenant exécuter nos jeux sur plusieurs threads, il est important de synchroniser correctement les données entre les threads. Dans cette vidéo, je montre comment créer une classe de verrouillage volatile pour garantir que les variables volatiles sont correctement synchronisées ..."

Et si vous êtes abonné à son site Web et avez accès à ses vidéos dans cette vidéo, il fait référence à cette article concernant l'utilisation de Volatile avec multithreading programmation.

Voici l'article du lien ci-dessus: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile: le meilleur ami du programmeur multithread

Par Andrei Alexandrescu, 01 février 2001

Le mot clé volatile a été conçu pour empêcher les optimisations du compilateur qui pourraient rendre le code incorrect en présence de certains événements asynchrones.

Je ne veux pas gâcher votre humeur, mais cette colonne aborde le sujet redouté de la programmation multithread. Si - comme le dit le précédent volet de Generic - la programmation sans exception est difficile, c'est un jeu d'enfant par rapport à la programmation multithread.

Les programmes utilisant plusieurs threads sont notoirement difficiles à écrire, à prouver, à déboguer, à maintenir et à apprivoiser en général. Des programmes multithreads incorrects peuvent s'exécuter pendant des années sans problème, uniquement pour s'exécuter de manière inattendue car une condition de synchronisation critique a été remplie.

Inutile de dire qu'un programmeur qui écrit du code multithread a besoin de toute l'aide possible. Cette colonne se concentre sur les conditions de concurrence - une source courante de problèmes dans les programmes multithreads - et vous fournit des informations et des outils sur la façon de les éviter et, étonnamment, le compilateur travaille dur pour vous aider avec cela.

Juste un petit mot-clé

Bien que les normes C et C++ soient visiblement silencieuses en ce qui concerne les threads, elles font une petite concession au multithreading, sous la forme du mot-clé volatile.

Tout comme son homologue const plus connu, volatile est un modificateur de type. Il est destiné à être utilisé conjointement avec des variables accessibles et modifiées dans différents threads. Fondamentalement, sans volatilité, soit l'écriture de programmes multithread devient impossible, soit le compilateur gaspille de vastes opportunités d'optimisation. Une explication s'impose.

Considérez le code suivant:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

Le but de Gadget :: Wait ci-dessus est de vérifier la variable membre flag_ chaque seconde et de revenir lorsque cette variable a été définie sur true par un autre thread. Du moins, c'est ce que son programmeur avait prévu, mais, hélas, Wait est incorrect.

Supposons que le compilateur détermine que Sleep (1000) est un appel dans une bibliothèque externe qui ne peut pas modifier la variable membre flag_. Ensuite, le compilateur conclut qu'il peut mettre en cache flag_ dans un registre et utiliser ce registre au lieu d'accéder à la mémoire interne plus lente. Il s'agit d'une excellente optimisation pour le code à thread unique, mais dans ce cas, cela nuit à l'exactitude: après avoir appelé Wait for some Gadget object, bien qu'un autre thread appelle Wakeup, Wait bouclera pour toujours. Cela est dû au fait que le changement de flag_ ne sera pas reflété dans le registre qui met en cache flag_. L'optimisation est trop ... optimiste.

La mise en cache des variables dans les registres est une optimisation très précieuse qui s'applique la plupart du temps, il serait donc dommage de la gaspiller. C et C++ vous donnent la possibilité de désactiver explicitement une telle mise en cache. Si vous utilisez le modificateur volatile sur une variable, le compilateur ne mettra pas cette variable en cache dans les registres - chaque accès atteindra l'emplacement de mémoire réel de cette variable. Donc, tout ce que vous avez à faire pour que le combo Wait/Wakeup de Gadget fonctionne est de qualifier flag_ de manière appropriée:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

La plupart des explications sur la justification et l'utilisation de volatile s'arrêtent ici et vous conseillent de qualifier volatile les types primitifs que vous utilisez dans plusieurs threads. Cependant, vous pouvez faire beaucoup plus avec volatile, car il fait partie du merveilleux système de type de C++.

Utilisation de volatile avec des types définis par l'utilisateur

Vous pouvez qualifier volatile non seulement les types primitifs, mais aussi les types définis par l'utilisateur. Dans ce cas, volatile modifie le type d'une manière similaire à const. (Vous pouvez également appliquer simultanément const et volatile au même type.)

Contrairement à const, volatile fait la distinction entre les types primitifs et les types définis par l'utilisateur. À savoir, contrairement aux classes, les types primitifs prennent toujours en charge toutes leurs opérations (addition, multiplication, affectation, etc.) lorsqu'ils sont qualifiés de volatils. Par exemple, vous pouvez affecter un int non volatile à un int volatile, mais vous ne pouvez pas affecter un objet non volatil à un objet volatil.

Illustrons comment volatile fonctionne sur les types définis par l'utilisateur sur un exemple.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Si vous pensez que la volatilité n'est pas très utile avec des objets, préparez-vous à une certaine surprise.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

La conversion d'un type non qualifié en son homologue volatil est triviale. Cependant, tout comme avec const, vous ne pouvez pas faire le retour de volatile à non qualifié. Vous devez utiliser un casting:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Une classe qualifiée volatile donne accès uniquement à un sous-ensemble de son interface, un sous-ensemble qui est sous le contrôle de l'implémenteur de classe. Les utilisateurs ne peuvent accéder pleinement à l'interface de ce type qu'en utilisant un const_cast. De plus, tout comme constness, la volatilité se propage de la classe à ses membres (par exemple, volatileGadget.name_ et volatileGadget.state_ sont des variables volatiles).

volatile, sections critiques et conditions de course

Le dispositif de synchronisation le plus simple et le plus utilisé dans les programmes multithread est le mutex. Un mutex expose les primitives Acquire et Release. Une fois que vous appelez Acquire dans un thread, tout autre thread appelant Acquire se bloquera. Plus tard, lorsque ce thread appelle Release, précisément un thread bloqué dans un appel Acquire sera libéré. En d'autres termes, pour un mutex donné, un seul thread peut obtenir du temps processeur entre un appel à Acquire et un appel à Release. Le code d'exécution entre un appel à Acquire et un appel à Release est appelé une section critique. (La terminologie Windows est un peu déroutante car elle appelle le mutex lui-même une section critique, tandis que "mutex" est en fait un mutex inter-processus. Cela aurait été bien s'ils avaient été appelés mutex de thread et mutex de processus.)

Les mutex sont utilisés pour protéger les données contre les conditions de concurrence. Par définition, une condition de concurrence critique se produit lorsque l'effet de plusieurs threads sur les données dépend de la façon dont les threads sont planifiés. Les conditions de concurrence s'affichent lorsque deux threads ou plus se disputent l'utilisation des mêmes données. Parce que les threads peuvent s'interrompre à des moments arbitraires, les données peuvent être corrompues ou mal interprétées. Par conséquent, les modifications et parfois les accès aux données doivent être soigneusement protégés par des sections critiques. Dans la programmation orientée objet, cela signifie généralement que vous stockez un mutex dans une classe en tant que variable membre et l'utilisez chaque fois que vous accédez à l'état de cette classe.

Les programmeurs expérimentés multithread ont peut-être bâillé en lisant les deux paragraphes ci-dessus, mais leur objectif est de fournir un entraînement intellectuel, car nous allons maintenant établir un lien avec la connexion volatile. Pour ce faire, nous établissons un parallèle entre le monde des types C++ et le monde sémantique des threads.

  • En dehors d'une section critique, n'importe quel thread peut interrompre n'importe quel autre à tout moment; il n'y a pas de contrôle, donc les variables accessibles à partir de plusieurs threads sont volatiles. Cela est conforme à l'intention initiale de volatile - celle d'empêcher le compilateur de mettre en cache involontairement des valeurs utilisées par plusieurs threads à la fois.
  • À l'intérieur d'une section critique définie par un mutex, un seul thread a accès. Par conséquent, à l'intérieur d'une section critique, le code d'exécution a une sémantique monothread. La variable contrôlée n'est plus volatile - vous pouvez supprimer le qualificatif volatile.

En bref, les données partagées entre les threads sont conceptuellement volatiles à l'extérieur d'une section critique et non volatiles à l'intérieur d'une section critique.

Vous entrez dans une section critique en verrouillant un mutex. Vous supprimez le qualificatif volatile d'un type en appliquant un const_cast. Si nous parvenons à combiner ces deux opérations, nous créons une connexion entre le système de type C++ et la sémantique de threading d'une application. Nous pouvons faire vérifier par le compilateur les conditions de course pour nous.

LockingPtr

Nous avons besoin d'un outil qui collecte une acquisition mutex et un const_cast. Développons un modèle de classe LockingPtr que vous initialisez avec un objet volatile obj et un mutex mtx. Pendant sa durée de vie, un LockingPtr conserve le mtx acquis. En outre, LockingPtr offre un accès à l'obj dépouillé volatile. L'accès est proposé sous forme de pointeur intelligent, via opérateur-> et opérateur *. Le const_cast est effectué à l'intérieur de LockingPtr. La conversion est sémantiquement valide car LockingPtr conserve le mutex acquis pendant sa durée de vie.

Définissons d'abord le squelette d'une classe Mutex avec laquelle LockingPtr fonctionnera:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Pour utiliser LockingPtr, vous implémentez Mutex en utilisant les structures de données natives et les fonctions primitives de votre système d'exploitation.

LockingPtr est modélisé avec le type de la variable contrôlée. Par exemple, si vous souhaitez contrôler un Widget, vous utilisez un LockingPtr que vous initialisez avec une variable de type Widget volatile.

La définition de LockingPtr est très simple. LockingPtr implémente un pointeur intelligent non sophistiqué. Il se concentre uniquement sur la collecte d'un const_cast et d'une section critique.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

Malgré sa simplicité, LockingPtr est une aide très utile pour écrire du code multithread correct. Vous devez définir les objets partagés entre les threads comme volatils et ne jamais utiliser const_cast avec eux - utilisez toujours les objets automatiques LockingPtr. Illustrons cela avec un exemple.

Supposons que vous ayez deux threads qui partagent un objet vectoriel:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Dans une fonction de thread, vous utilisez simplement un LockingPtr pour obtenir un accès contrôlé à la variable membre buffer_:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

Le code est très facile à écrire et à comprendre - chaque fois que vous devez utiliser buffer_, vous devez créer un LockingPtr pointant vers lui. Une fois que vous avez fait cela, vous avez accès à toute l'interface de vector.

La partie agréable est que si vous faites une erreur, le compilateur le signalera:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Vous ne pouvez accéder à aucune fonction de buffer_ tant que vous n'avez pas appliqué un const_cast ou utilisé LockingPtr. La différence est que LockingPtr offre une manière ordonnée d'appliquer const_cast aux variables volatiles.

LockingPtr est remarquablement expressif. Si vous n'avez besoin d'appeler qu'une seule fonction, vous pouvez créer un objet LockingPtr temporaire sans nom et l'utiliser directement:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Retour aux types primitifs

Nous avons vu à quel point la volatilité protège les objets contre les accès non contrôlés et comment LockingPtr fournit un moyen simple et efficace d'écrire du code thread-safe. Revenons maintenant aux types primitifs, qui sont traités différemment par volatile.

Prenons un exemple où plusieurs threads partagent une variable de type int.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

Si Increment et Decrement doivent être appelés à partir de threads différents, le fragment ci-dessus est bogué. Tout d'abord, ctr_ doit être volatile. Deuxièmement, même une opération apparemment atomique telle que ++ ctr_ est en fait une opération en trois étapes. La mémoire elle-même n'a aucune capacité arithmétique. Lors de l'incrémentation d'une variable, le processeur:

  • Lit cette variable dans un registre
  • Incrémente la valeur dans le registre
  • Écrit le résultat en mémoire

Cette opération en trois étapes est appelée RMW (lecture-modification-écriture). Au cours de la partie Modifier d'une opération RMW, la plupart des processeurs libèrent le bus mémoire afin de permettre à d'autres processeurs d'accéder à la mémoire.

Si à ce moment un autre processeur effectue une opération RMW sur la même variable, nous avons une condition de concurrence: la deuxième écriture écrase l'effet de la première.

Pour éviter cela, vous pouvez à nouveau vous fier à LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Maintenant, le code est correct, mais sa qualité est inférieure par rapport au code de SyncBuf. Pourquoi? Parce qu'avec Counter, le compilateur ne vous avertira pas si vous accédez par erreur directement à ctr_ (sans le verrouiller). Le compilateur compile ++ ctr_ si ctr_ est volatile, bien que le code généré soit simplement incorrect. Le compilateur n'est plus votre allié, et seule votre attention peut vous aider à éviter les conditions de course.

Que devez-vous faire alors? Encapsulez simplement les données primitives que vous utilisez dans les structures de niveau supérieur et utilisez volatiles avec ces structures. Paradoxalement, il est pire d'utiliser directement volatile avec des inserts, malgré le fait qu'au départ c'était l'intention d'utilisation de volatile!

Fonctions membres volatiles

Jusqu'à présent, nous avons eu des classes qui regroupent les membres de données volatiles; pensons maintenant à la conception de classes qui à leur tour feront partie d'objets plus grands et partagées entre les threads. C'est là que les fonctions membres volatiles peuvent être d'une grande aide.

Lors de la conception de votre classe, vous qualifiez volatile uniquement les fonctions membres qui sont thread-safe. Vous devez supposer que le code de l'extérieur appellera les fonctions volatiles de n'importe quel code à tout moment. N'oubliez pas: volatile est égal à du code multithread gratuit et sans section critique; non volatile est égal à un scénario à thread unique ou à l'intérieur d'une section critique.

Par exemple, vous définissez un widget de classe qui implémente une opération en deux variantes - une thread-safe et une rapide, non protégée.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

Remarquez l'utilisation de la surcharge. L'utilisateur de Widget peut désormais invoquer Operation en utilisant une syntaxe uniforme soit pour les objets volatils et obtenir la sécurité des threads, soit pour les objets réguliers et obtenir la vitesse. L'utilisateur doit faire attention à ne pas définir les objets Widget partagés comme volatils.

Lors de l'implémentation d'une fonction membre volatile, la première opération consiste généralement à la verrouiller avec un LockingPtr. Ensuite, le travail est effectué en utilisant le frère non volatile:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Résumé

Lors de l'écriture de programmes multithread, vous pouvez utiliser volatile à votre avantage. Vous devez respecter les règles suivantes:

  • Définissez tous les objets partagés comme volatils.
  • N'utilisez pas de volatile directement avec des types primitifs.
  • Lors de la définition de classes partagées, utilisez des fonctions membres volatiles pour exprimer la sécurité des threads.

Si vous faites cela, et si vous utilisez le composant générique simple LockingPtr, vous pouvez écrire du code thread-safe et vous soucier beaucoup moins des conditions de concurrence, car le compilateur s'inquiétera pour vous et indiquera avec diligence les endroits où vous vous trompez.

Quelques projets auxquels j'ai participé avec l'utilisation de volatile et LockingPtr à grand effet. Le code est propre et compréhensible. Je me souviens de quelques blocages, mais je préfère les blocages aux conditions de course car ils sont tellement plus faciles à déboguer. Il n'y a eu pratiquement aucun problème lié aux conditions de course. Mais alors on ne sait jamais.

Remerciements

Un grand merci à James Kanze et Sorin Jianu qui ont aidé avec des idées perspicaces.


Andrei Alexandrescu est directeur du développement chez RealNetworks Inc. (www.realnetworks.com), basé à Seattle, WA, et auteur du célèbre livre Modern C++ Design. Il peut être contacté à www.moderncppdesign.com. Andrei est également l'un des instructeurs en vedette du séminaire C++ (www.gotw.ca/cpp_seminar).

Cet article peut être un peu daté, mais il donne un bon aperçu d'une excellente utilisation de l'utilisation du modificateur volatile avec l'utilisation de la programmation multithread pour aider à garder les événements asynchrones tout en ayant le compilateur vérifiant les conditions de concurrence pour nous. Cela ne répond peut-être pas directement à la question originale des OP sur la création d'une clôture de mémoire, mais j'ai choisi de poster ceci comme une réponse pour les autres comme une excellente référence vers une bonne utilisation de volatile lorsque vous travaillez avec des applications multithread.

0
Francis Cugler