En C ++ 11, normalement ne jamais utiliser volatile
pour le threading, uniquement pour MMIO
Mais TL: DR, ça "fonctionne" un peu comme atomic avec mo_relaxed
du matériel avec des caches cohérents (c'est-à-dire tout); il suffit d'empêcher les compilateurs de conserver les variables dans les registres. atomic
n'a pas besoin de barrières de mémoire pour créer l'atomicité ou la visibilité inter-thread, uniquement pour faire attendre le thread actuel avant / après une opération pour créer un ordre entre les accès de ce thread à différentes variables. mo_relaxed
n'a jamais besoin de barrières, il suffit de charger, stocker ou RMW.
Pour les atomics roll-your-own avec volatile
(et inline-asm pour les barrières) dans le mauvais vieux temps avant C ++ 11 std::atomic
, volatile
c'était le seul bon moyen de faire fonctionner certaines choses . Mais cela dépendait de beaucoup d'hypothèses sur le fonctionnement des implémentations et n'était jamais garanti par aucune norme.
Par exemple, le noyau Linux utilise toujours ses propres atomiques roulés à la main avec volatile
, mais ne prend en charge que quelques implémentations C spécifiques (GNU C, clang et peut-être ICC). C'est en partie à cause des extensions GNU C et de la syntaxe et de la sémantique asm en ligne, mais aussi parce que cela dépend de certaines hypothèses sur le fonctionnement des compilateurs.
C'est presque toujours le mauvais choix pour les nouveaux projets; vous pouvez utiliser std::atomic
(avec std::memory_order_relaxed
) pour qu'un compilateur émette le même code machine efficace que vous pourriez avec volatile
. std::atomic
avec des mo_relaxed
obsolètes volatile
à des fins de filetage. (sauf peut-être pour contourner les bogues d'optimisation manqués avec atomic<double>
certains compilateurs .)
L'implémentation interne std::atomic
des compilateurs grand public (comme gcc et clang) ne s'utilise pas seulement en volatile
interne; les compilateurs exposent directement les fonctions intégrées de charge atomique, de stockage et de RMW. (par exemple, les modules internes GNU C__atomic
qui fonctionnent sur des objets "simples".)
Volatile est utilisable dans la pratique (mais ne le faites pas)
Cela dit, volatile
est utilisable en pratique pour des choses comme un exit_now
indicateur sur toutes (?) Les implémentations C ++ existantes sur des processeurs réels, en raison du fonctionnement des processeurs (caches cohérents) et des hypothèses partagées sur la façon dont volatile
devraient fonctionner. Mais pas grand-chose d'autre et n'est pas recommandé. Le but de cette réponse est d'expliquer comment les processeurs existants et les implémentations C ++ fonctionnent réellement. Si vous ne vous souciez pas de cela, tout ce que vous devez savoir est std::atomic
qu'avec mo_relaxed obsolètes volatile
pour le threading.
(La norme ISO C ++ est assez vague à ce sujet, disant simplement que les volatile
accès doivent être évalués strictement selon les règles de la machine abstraite C ++, et non optimisés. Étant donné que les implémentations réelles utilisent l'espace d'adressage mémoire de la machine pour modéliser l'espace d'adressage C ++, cela signifie que les volatile
lectures et les affectations doivent être compilées pour charger / stocker des instructions pour accéder à la représentation d'objet en mémoire.)
Comme le souligne une autre réponse, un exit_now
indicateur est un cas simple de communication inter-thread qui ne nécessite aucune synchronisation : il ne publie pas que le contenu du tableau est prêt ou quelque chose comme ça. Juste un magasin qui est remarqué rapidement par une charge non optimisée dans un autre thread.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Sans volatile ni atomique, la règle as-if et l'hypothèse de non-course aux données UB permettent à un compilateur de l'optimiser en asm qui ne vérifie le drapeau qu'une seule fois , avant d'entrer (ou non) dans une boucle infinie. C'est exactement ce qui se passe dans la vraie vie pour les vrais compilateurs. (Et généralement, optimisez beaucoup do_stuff
parce que la boucle ne se termine jamais, donc tout code ultérieur qui aurait pu utiliser le résultat n'est pas accessible si nous entrons dans la boucle).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
Le programme multithreading bloqué en mode optimisé mais s'exécute normalement en -O0 est un exemple (avec une description de la sortie asm de GCC) de la façon dont cela se produit exactement avec GCC sur x86-64. Aussi la programmation MCU - pauses d'optimisation C ++ O2 en boucle sur electronics.SE montre un autre exemple.
Nous souhaitons normalement des optimisations agressives qui permettent au CSE de sortir les charges des boucles, y compris pour les variables globales.
Avant C ++ 11, il y volatile bool exit_now
avait un moyen de faire fonctionner ce travail comme prévu (sur les implémentations C ++ normales). Mais dans C ++ 11, la course aux données UB s'applique toujours, volatile
donc il n'est pas réellement garanti par la norme ISO de fonctionner partout, même en supposant des caches cohérents HW.
Notez que pour les types plus larges, volatile
ne donne aucune garantie d'absence de déchirure. J'ai ignoré cette distinction ici bool
parce que ce n'est pas un problème sur les implémentations normales. Mais c'est aussi une partie de la raison pour laquelle il volatile
est toujours soumis à l'UB de course aux données au lieu d'être équivalent à l'atome relaxé.
Notez que "comme prévu" ne signifie pas que le thread en cours exit_now
attend que l'autre thread se termine. Ou même qu'il attend que le exit_now=true
magasin volatil soit même globalement visible avant de poursuivre les opérations ultérieures dans ce thread. ( atomic<bool>
avec la valeur par défaut, mo_seq_cst
cela ferait au moins attendre avant tout chargement ultérieur de seq_cst. Sur de nombreux ISA, vous auriez juste une barrière complète après le magasin).
C ++ 11 fournit un moyen non-UB qui compile le même
Un indicateur "continuer à courir" ou "quitter maintenant" doit être utilisé std::atomic<bool> flag
avecmo_relaxed
En utilisant
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
vous donnera exactement la même chose (sans instructions de barrière coûteuses) que vous obtiendrez volatile flag
.
En plus de ne pas déchirer, atomic
vous donne également la possibilité de stocker dans un thread et de charger dans un autre sans UB, de sorte que le compilateur ne peut pas extraire la charge d'une boucle. (L'hypothèse de l'absence de course aux données UB est ce qui permet les optimisations agressives que nous souhaitons pour les objets non volatils non atomiques.) Cette fonctionnalité atomic<T>
est à peu près la même que volatile
pour les charges pures et les magasins purs.
atomic<T>
faites également +=
et ainsi de suite des opérations RMW atomiques (beaucoup plus chères qu'une charge atomique dans un magasin temporaire, opérez, puis un magasin atomique séparé. Si vous ne voulez pas d'un RMW atomique, écrivez votre code avec un temporaire local).
Avec la seq_cst
commande par défaut que vous obtiendrez while(!flag)
, cela ajoute également des garanties de commande. accès non atomiques, et à d'autres accès atomiques.
(En théorie, la norme ISO C ++ n'exclut pas l'optimisation à la compilation des atomiques. Mais en pratique, les compilateurs ne le font pas parce qu'il n'y a aucun moyen de contrôler quand cela ne serait pas correct. Il y a quelques cas où même volatile atomic<T>
pas avoir un contrôle suffisant sur l'optimisation de l'atome si les compilateurs optimisent, donc pour l'instant les compilateurs ne le font pas. Voir Pourquoi les compilateurs ne fusionnent-ils pas les écritures std :: atomic redondantes? Notez que wg21 / p0062 recommande de ne pas utiliser volatile atomic
dans le code actuel pour se prémunir contre l'optimisation de atomique.)
volatile
fonctionne réellement pour cela sur de vrais processeurs (mais ne l'utilisez toujours pas)
même avec des modèles de mémoire faiblement ordonnés (non-x86) . Mais ne l'utilisez pas réellement, utilisez plutôt atomic<T>
avec mo_relaxed
!! Le but de cette section est de traiter les idées fausses sur le fonctionnement des processeurs réels, et non de justifier volatile
. Si vous écrivez du code sans verrou, vous vous souciez probablement des performances. Comprendre les caches et les coûts de la communication inter-thread est généralement important pour de bonnes performances.
Les vrais processeurs ont des caches / mémoire partagée cohérents: après qu'un stockage d'un cœur devient globalement visible, aucun autre cœur ne peut charger une valeur périmée. (Voir aussi Myths Programmers Believe about CPU Caches qui en parle des volatiles Java, équivalent à C ++ atomic<T>
avec l'ordre de mémoire seq_cst.)
Quand je dis load , je veux dire une instruction asm qui accède à la mémoire. C'est ce qu'un volatile
accès garantit, et ce n'est pas la même chose que la conversion lvalue-to-rvalue d'une variable C ++ non atomique / non volatile. (par exemple local_tmp = flag
ou while(!flag)
).
La seule chose que vous devez vaincre est les optimisations à la compilation qui ne se rechargent pas du tout après la première vérification. Tout chargement + contrôle à chaque itération est suffisant, sans aucune commande. Sans synchronisation entre ce thread et le thread principal, il n'est pas significatif de parler du moment exact du magasin ou de l'ordre de la charge. autres opérations dans la boucle. Ce n'est que lorsqu'il est visible par ce fil que ce qui compte. Lorsque vous voyez l'indicateur exit_now défini, vous quittez. La latence inter-core sur un Xeon x86 typique peut être quelque chose comme 40ns entre des cœurs physiques séparés .
En théorie: threads C ++ sur du matériel sans caches cohérents
Je ne vois aucun moyen que cela puisse être efficace à distance, avec juste du C ++ ISO pur sans exiger du programmeur qu'il fasse des vidages explicites dans le code source.
En théorie, vous pourriez avoir une implémentation C ++ sur une machine qui ne serait pas comme ça, nécessitant des vidages explicites générés par le compilateur pour rendre les choses visibles à d'autres threads sur d'autres cœurs . (Ou pour les lectures de ne pas utiliser une copie peut-être périmée). Le standard C ++ ne rend pas cela impossible, mais le modèle de mémoire de C ++ est conçu pour être efficace sur des machines à mémoire partagée cohérentes. Par exemple, le standard C ++ parle même de "cohérence lecture-lecture", "cohérence écriture-lecture", etc. Une note de la norme indique même la connexion au matériel:
http://eel.is/c++draft/intro.races#19
[Remarque: Les quatre exigences de cohérence précédentes interdisent effectivement la réorganisation par le compilateur des opérations atomiques en un seul objet, même si les deux opérations sont des charges relâchées. Cela rend effectivement la garantie de cohérence du cache fournie par la plupart du matériel disponible pour les opérations atomiques C ++. - note de fin]
Il n'y a pas de mécanisme pour qu'un release
magasin ne se vide que lui-même et quelques plages d'adresses sélectionnées: il devrait tout synchroniser car il ne saurait pas ce que les autres threads pourraient vouloir lire si leur chargement d'acquisition voyait ce magasin de versions (formant un release-sequence qui établit une relation qui se produit avant entre les threads, garantissant que les opérations non atomiques précédentes effectuées par le thread d'écriture sont désormais sûres à lire. À moins qu'il n'écrive plus loin après le magasin de publication ...) Ou les compilateurs auraient être vraiment intelligent pour prouver que seules quelques lignes de cache avaient besoin d'être vidées.
Connexes: ma réponse sur Mov + mfence est-il sûr sur NUMA? va dans le détail sur la non-existence de systèmes x86 sans mémoire partagée cohérente. Également lié: Charge et stocke la réorganisation sur ARM pour en savoir plus sur les charges / magasins au même emplacement.
Il y a, je pense, des clusters avec une mémoire partagée non cohérente, mais ce ne sont pas des machines à image système unique. Chaque domaine de cohérence exécute un noyau séparé, vous ne pouvez donc pas exécuter les threads d'un seul programme C ++. À la place, vous exécutez des instances distinctes du programme (chacune avec son propre espace d'adressage: les pointeurs dans une instance ne sont pas valides dans l'autre).
Pour les amener à communiquer entre eux via des vidages explicites, vous utiliseriez généralement MPI ou une autre API de transmission de messages pour que le programme spécifie les plages d'adresses à vider.
Le matériel réel ne dépasse pas les std::thread
limites de cohérence du cache:
Certaines puces ARM asymétriques existent, avec un espace d'adressage physique partagé mais pas de domaines de cache partageables en interne. Donc pas cohérent. (par exemple, commentez un noyau A8 et un Cortex-M3 comme TI Sitara AM335x).
Mais différents noyaux fonctionneraient sur ces cœurs, pas une seule image système qui pourrait exécuter des threads sur les deux cœurs. Je ne connais aucune implémentation C ++ qui exécute des std::thread
threads sur les cœurs de processeur sans caches cohérents.
Pour ARM spécifiquement, GCC et clang génèrent du code en supposant que tous les threads s'exécutent dans le même domaine partageable interne. En fait, le manuel ARMv7 ISA dit
Cette architecture (ARMv7) est écrite avec l'espoir que tous les processeurs utilisant le même système d'exploitation ou hyperviseur sont dans le même domaine de partage interne
Ainsi, la mémoire partagée non cohérente entre des domaines séparés n'est qu'une chose pour une utilisation spécifique au système explicite de régions de mémoire partagée pour la communication entre différents processus sous différents noyaux.
Voir aussi cette discussion CoreCLR sur la génération de code à l'aide des dmb ish
barrières de dmb sy
mémoire (Inner Shareable) et (System) dans ce compilateur.
J'affirme qu'aucune implémentation C ++ pour un autre ISA ne fonctionne std::thread
sur des cœurs avec des caches non cohérents. Je n'ai pas de preuve qu'une telle implémentation existe, mais cela semble hautement improbable. À moins que vous ne cibliez un élément exotique spécifique de HW qui fonctionne de cette façon, votre réflexion sur les performances devrait supposer une cohérence de cache de type MESI entre tous les threads. (Utilisez de préférence atomic<T>
de manière à garantir l'exactitude, cependant!)
Les caches cohérents simplifient les choses
Mais sur un système multicœur avec des caches cohérents, implémenter un release-store signifie simplement commander la validation dans le cache pour les magasins de ce thread, sans faire de vidage explicite. ( https://preshing.com/20120913/acquire-and-release-semantics/ et https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (Et une acquisition-charge signifie commander l'accès au cache dans l'autre noyau).
Une instruction de barrière de mémoire bloque simplement les charges et / ou les stockages du thread actuel jusqu'à ce que le tampon de stockage se vide; cela se produit toujours aussi vite que possible tout seul. ( Une barrière de mémoire garantit-elle que la cohérence du cache est terminée? Résout cette idée fausse). Donc, si vous n'avez pas besoin de commander, une simple visibilité rapide dans d'autres threads, mo_relaxed
c'est bien. (Et c'est ainsi volatile
, mais ne faites pas ça.)
Voir aussi mappages C / C ++ 11 vers les processeurs
Fait amusant: sur x86, chaque magasin asm est un magasin de versions car le modèle de mémoire x86 est essentiellement seq-cst plus un tampon de stockage (avec transfert de magasin).
Semi-liés re: tampon de stockage, visibilité globale et cohérence: C ++ 11 garantit très peu. La plupart des vrais ISA (sauf PowerPC) garantissent que tous les threads peuvent se mettre d'accord sur l'ordre d'apparition de deux magasins par deux autres threads. (Dans la terminologie formelle du modèle de mémoire de l'architecture informatique, ils sont "atomiques à copies multiples").
Une autre idée fausse est que les instructions asm de clôture de la mémoire sont nécessaires pour vider le tampon de stockage pour d' autres noyaux pour voir nos magasins du tout . En fait, le tampon de stockage essaie toujours de se vider (commettre dans le cache L1d) aussi vite que possible, sinon il se remplirait et bloquerait l'exécution. Ce que fait une barrière / clôture complète est de bloquer le thread actuel jusqu'à ce que le tampon de magasin soit vidé , de sorte que nos charges ultérieures apparaissent dans l'ordre global après nos magasins précédents.
(Le modèle de mémoire asm fortement ordonné volatile
de x86 signifie que sur x86 peut finir par vous rapprocher de mo_acq_rel
, sauf que la réorganisation au moment de la compilation avec des variables non atomiques peut toujours se produire. Mais la plupart des non-x86 ont des modèles de mémoire faiblement ordonnés volatile
et relaxed
sont à peu près aussi faible comme le mo_relaxed
permet.)