C ++ 11 a introduit un modèle de mémoire standardisé, mais qu'est-ce que cela signifie exactement? Et comment cela va-t-il affecter la programmation C ++?
Cet article (par Gavin Clarke qui cite Herb Sutter ) dit que,
Le modèle de mémoire signifie que le code C ++ dispose désormais d'une bibliothèque normalisée à appeler, peu importe qui a créé le compilateur et sur quelle plate-forme il fonctionne. Il existe un moyen standard de contrôler la façon dont les différents threads communiquent avec la mémoire du processeur.
"Lorsque vous parlez de diviser [le code] entre différents cœurs qui sont dans la norme, nous parlons du modèle de mémoire. Nous allons l'optimiser sans casser les hypothèses suivantes que les gens vont faire dans le code", a déclaré Sutter .
Eh bien, je peux mémoriser cela et des paragraphes similaires disponibles en ligne (car j'ai mon propre modèle de mémoire depuis la naissance: P) et je peux même poster comme réponse aux questions posées par d'autres, mais pour être honnête, je ne comprends pas exactement cette.
Les programmeurs C ++ avaient l'habitude de développer des applications multithread même avant, alors quelle importance si ce sont des threads POSIX, ou des threads Windows ou C ++ 11? Quels sont les bénéfices? Je veux comprendre les détails de bas niveau.
J'ai également l'impression que le modèle de mémoire C ++ 11 est en quelque sorte lié à la prise en charge multithread C ++ 11, comme je vois souvent ces deux ensemble. Si c'est le cas, comment exactement? Pourquoi devraient-ils être liés?
Comme je ne sais pas comment fonctionnent les fonctions internes du multi-threading et ce que signifie le modèle de mémoire en général, veuillez m'aider à comprendre ces concepts. :-)
Réponses:
Tout d'abord, vous devez apprendre à penser comme un avocat spécialisé en langues.
La spécification C ++ ne fait référence à aucun compilateur, système d'exploitation ou CPU particulier. Il fait référence à une machine abstraite qui est une généralisation de systèmes réels. Dans le monde du Language Lawyer, le travail du programmeur est d'écrire du code pour la machine abstraite; le travail du compilateur est d'actualiser ce code sur une machine à béton. En codant de manière rigide selon les spécifications, vous pouvez être certain que votre code se compilera et s'exécutera sans modification sur n'importe quel système doté d'un compilateur C ++ conforme, que ce soit aujourd'hui ou dans 50 ans.
La machine abstraite de la spécification C ++ 98 / C ++ 03 est fondamentalement monothread. Il n'est donc pas possible d'écrire du code C ++ multithread "entièrement portable" par rapport à la spécification. La spécification ne dit même rien sur l' atomicité des charges et des magasins de mémoire ou sur l' ordre dans lequel les charges et les magasins peuvent se produire, sans parler des mutex.
Bien sûr, vous pouvez écrire du code multi-thread dans la pratique pour des systèmes concrets particuliers - comme pthreads ou Windows. Mais il n'existe aucun moyen standard d'écrire du code multithread pour C ++ 98 / C ++ 03.
La machine abstraite en C ++ 11 est multithread par conception. Il a également un modèle de mémoire bien défini ; c'est-à-dire qu'il indique ce que le compilateur peut et ne peut pas faire lorsqu'il s'agit d'accéder à la mémoire.
Prenons l'exemple suivant, où une paire de variables globales est accessible simultanément par deux threads:
Que pourrait produire la sortie de Thread 2?
Sous C ++ 98 / C ++ 03, ce n'est même pas un comportement indéfini; la question elle-même n'a pas de sens car la norme n'envisage rien de ce qu'on appelle un «fil».
Sous C ++ 11, le résultat est un comportement indéfini, car les chargements et les magasins n'ont pas besoin d'être atomiques en général. Ce qui peut ne pas sembler être une grande amélioration ... Et en soi, ce n'est pas le cas.
Mais avec C ++ 11, vous pouvez écrire ceci:
Maintenant, les choses deviennent beaucoup plus intéressantes. Tout d'abord, le comportement est ici défini . Le thread 2 peut désormais s'imprimer
0 0
(s'il s'exécute avant le thread 1),37 17
(s'il s'exécute après le thread 1) ou0 17
(s'il s'exécute après que le thread 1 a été affecté à x mais avant qu'il soit affecté à y).Ce qu'il ne peut pas imprimer
37 0
, c'est parce que le mode par défaut pour les chargements / magasins atomiques en C ++ 11 est d'appliquer la cohérence séquentielle . Cela signifie simplement que toutes les charges et tous les magasins doivent être "comme si" ils se sont produits dans l'ordre dans lequel vous les avez écrits dans chaque thread, tandis que les opérations entre les threads peuvent être entrelacées comme le système le souhaite. Ainsi, le comportement par défaut de l'atomique fournit à la fois l' atomicité et l' ordre des charges et des magasins.Désormais, sur un processeur moderne, assurer la cohérence séquentielle peut être coûteux. En particulier, le compilateur est susceptible d'émettre des barrières de mémoire à part entière entre chaque accès ici. Mais si votre algorithme peut tolérer des chargements et des magasins hors service; c'est-à-dire si elle nécessite une atomicité mais pas de commande; c'est-à-dire, s'il peut tolérer
37 0
comme sortie de ce programme, alors vous pouvez écrire ceci:Plus le processeur est moderne, plus il est probable qu'il soit plus rapide que l'exemple précédent.
Enfin, si vous avez juste besoin de garder des charges et des magasins particuliers dans l'ordre, vous pouvez écrire:
Cela nous ramène aux charges et aux magasins commandés - ce
37 0
n'est donc plus une sortie possible - mais cela le fait avec un minimum de frais généraux. (Dans cet exemple trivial, le résultat est le même que la cohérence séquentielle à part entière; dans un programme plus grand, il ne le serait pas.)Bien sûr, si les seules sorties que vous souhaitez voir sont
0 0
ou37 17
, vous pouvez simplement enrouler un mutex autour du code d'origine. Mais si vous avez lu jusqu'ici, je parie que vous savez déjà comment cela fonctionne, et cette réponse est déjà plus longue que je ne le pensais :-).Donc, en bout de ligne. Les mutex sont excellents et C ++ 11 les standardise. Mais parfois, pour des raisons de performances, vous souhaitez des primitives de niveau inférieur (par exemple, le schéma de verrouillage classique à double vérification ). Le nouveau standard fournit des gadgets de haut niveau comme les mutex et les variables de condition, et il fournit également des gadgets de bas niveau comme les types atomiques et les différentes saveurs de la barrière de mémoire. Vous pouvez donc maintenant écrire des routines simultanées sophistiquées et hautes performances entièrement dans le langage spécifié par la norme, et vous pouvez être certain que votre code se compilera et s'exécutera inchangé sur les systèmes d'aujourd'hui et de demain.
Bien que pour être franc, à moins que vous ne soyez un expert et que vous travailliez sur un code de bas niveau sérieux, vous devriez probablement vous en tenir aux mutex et aux variables de condition. Voilà ce que je compte faire.
Pour en savoir plus sur ce sujet, consultez cet article de blog .
la source
i = i++
. L'ancien concept de points de séquence a été abandonné; la nouvelle norme spécifie la même chose en utilisant une relation séquencée avant qui n'est qu'un cas particulier du concept plus général inter-thread passe avant .Je vais juste donner l'analogie avec laquelle je comprends les modèles de cohérence de la mémoire (ou les modèles de mémoire, pour faire court). Il est inspiré par le papier séminal de Leslie Lamport "Time, Clocks, and the Ordering of Events in a Distributed System" . L'analogie est appropriée et a une signification fondamentale, mais peut être excessive pour de nombreuses personnes. Cependant, j'espère qu'il fournit une image mentale (une représentation picturale) qui facilite le raisonnement sur les modèles de cohérence de la mémoire.
Voyons les historiques de tous les emplacements de mémoire dans un diagramme espace-temps dans lequel l'axe horizontal représente l'espace d'adressage (c'est-à-dire que chaque emplacement de mémoire est représenté par un point sur cet axe) et l'axe vertical représente le temps (nous verrons que, en général, il n'y a pas de notion universelle du temps). L'historique des valeurs détenues par chaque emplacement mémoire est donc représenté par une colonne verticale à cette adresse mémoire. Chaque changement de valeur est dû à l'un des threads écrivant une nouvelle valeur à cet emplacement. Par une image de mémoire , nous entendrons l'ensemble / combinaison de valeurs de tous les emplacements de mémoire observables à un moment donné par un thread particulier .
Citation de "Une introduction sur la cohérence de la mémoire et la cohérence du cache"
Cet ordre de mémoire globale peut varier d'une exécution du programme à l'autre et peut ne pas être connu à l'avance. La caractéristique de SC est l'ensemble des tranches horizontales dans le diagramme espace-temps-adresse représentant des plans de simultanéité (c'est-à-dire des images de mémoire). Sur un plan donné, tous ses événements (ou valeurs de mémoire) sont simultanés. Il existe une notion de temps absolu , dans laquelle tous les threads s'accordent sur les valeurs de mémoire qui sont simultanées. Dans SC, à chaque instant, il n'y a qu'une seule image mémoire partagée par tous les threads. Autrement dit, à chaque instant, tous les processeurs s'accordent sur l'image de la mémoire (c'est-à-dire le contenu agrégé de la mémoire). Cela implique non seulement que tous les threads affichent la même séquence de valeurs pour tous les emplacements de mémoire, mais également que tous les processeurs observent la mêmecombinaisons de valeurs de toutes les variables. Cela revient à dire que toutes les opérations de mémoire (sur tous les emplacements de mémoire) sont observées dans le même ordre total par tous les threads.
Dans les modèles de mémoire détendue, chaque thread divisera l'espace adresse-temps à sa manière, la seule restriction étant que les tranches de chaque thread ne doivent pas se croiser car tous les threads doivent s'accorder sur l'historique de chaque emplacement de mémoire individuel (bien sûr , des tranches de fils différents peuvent et vont se croiser). Il n'y a pas de moyen universel de le découper (pas de foliation privilégiée de l'espace-temps-adresse). Les tranches n'ont pas besoin d'être planes (ou linéaires). Ils peuvent être incurvés et c'est ce qui peut faire qu'un thread lit des valeurs écrites par un autre thread dans l'ordre dans lequel elles ont été écrites. Les histoires de différents emplacements mémoire peuvent glisser (ou s'étirer) arbitrairement les unes par rapport aux autres lorsqu'elles sont vues par un thread particulier. Chaque thread aura un sens différent des événements (ou, de manière équivalente, des valeurs de mémoire) qui sont simultanés. L'ensemble des événements (ou valeurs de mémoire) qui sont simultanés à un thread ne sont pas simultanés à un autre. Ainsi, dans un modèle de mémoire détendue, tous les threads observent toujours la même histoire (c'est-à-dire la séquence de valeurs) pour chaque emplacement de mémoire. Mais ils peuvent observer des images de mémoire différentes (c'est-à-dire des combinaisons de valeurs de tous les emplacements de mémoire). Même si deux emplacements de mémoire différents sont écrits par le même thread en séquence, les deux nouvelles valeurs écrites peuvent être observées dans un ordre différent par d'autres threads.
[Image de Wikipedia]
Les lecteurs familiers avec la théorie spéciale de la relativité d'Einstein remarqueront ce à quoi je fais allusion. Traduire les mots de Minkowski dans le domaine des modèles de mémoire: l'espace adresse et le temps sont des ombres de l'espace adresse temps. Dans ce cas, chaque observateur (c.-à-d., Fil) projettera des ombres d'événements (c.-à-d. Des mémoires / charges de mémoire) sur sa propre ligne du monde (c.-à-d., Son axe temporel) et son propre plan de simultanéité (son axe d'espace d'adressage) . Les threads du modèle de mémoire C ++ 11 correspondent à des observateurs qui se déplacent les uns par rapport aux autres en relativité restreinte. La cohérence séquentielle correspond à l' espace-temps galiléen (c'est -à- dire que tous les observateurs s'accordent sur un ordre absolu d'événements et un sens global de simultanéité).
La ressemblance entre les modèles de mémoire et la relativité restreinte provient du fait que les deux définissent un ensemble d'événements partiellement ordonné, souvent appelé un ensemble causal. Certains événements (c'est-à-dire les mémoires de stockage) peuvent affecter (mais ne pas être affectés par) d'autres événements. Un thread C ++ 11 (ou observateur en physique) n'est rien de plus qu'une chaîne (c'est-à-dire un ensemble totalement ordonné) d'événements (par exemple, la mémoire charge et stocke à des adresses éventuellement différentes).
En relativité, un certain ordre est rétabli à l'image apparemment chaotique d'événements partiellement ordonnés, puisque le seul ordre temporel sur lequel tous les observateurs s'accordent est l'ordre parmi les événements «temporels» (c'est-à-dire, ces événements qui sont en principe connectables par n'importe quelle particule qui ralentit). que la vitesse de la lumière dans le vide). Seuls les événements liés au temps sont ordonnés de manière invariante. Temps en physique, Craig Callender .
Dans le modèle de mémoire C ++ 11, un mécanisme similaire (le modèle de cohérence acquisition-libération) est utilisé pour établir ces relations de causalité locales .
Pour fournir une définition de la cohérence de la mémoire et une motivation pour abandonner SC, je citerai "Une introduction sur la cohérence de la mémoire et la cohérence du cache"
Parce que la cohérence du cache et la cohérence de la mémoire sont parfois confondues, il est instructif d'avoir également cette citation:
Poursuivant notre image mentale, l'invariant SWMR correspond à l'exigence physique qu'il doit y avoir au plus une particule située à n'importe quel endroit mais il peut y avoir un nombre illimité d'observateurs de n'importe quel endroit.
la source
C'est maintenant une question de plusieurs années, mais étant très populaire, il convient de mentionner une ressource fantastique pour en savoir plus sur le modèle de mémoire C ++ 11. Je ne vois pas l'intérêt de résumer son discours afin de faire une autre réponse complète, mais étant donné que c'est le gars qui a effectivement écrit la norme, je pense que cela vaut la peine de regarder le discours.
Herb Sutter a une longue conversation de trois heures sur le modèle de mémoire C ++ 11 intitulé "Atomic <> Weapons", disponible sur le site Channel9 - partie 1 et partie 2 . La conférence est assez technique et couvre les sujets suivants:
L'exposé ne traite pas de l'API, mais plutôt du raisonnement, du contexte, sous le capot et dans les coulisses (saviez-vous que la sémantique détendue a été ajoutée à la norme uniquement parce que POWER et ARM ne prennent pas en charge efficacement la charge synchronisée?).
la source
Cela signifie que la norme définit désormais le multithread et définit ce qui se passe dans le contexte de plusieurs threads. Bien sûr, les gens ont utilisé différentes implémentations, mais c'est comme demander pourquoi nous devrions avoir un
std::string
alors que nous pourrions tous utiliser unestring
classe maison.Lorsque vous parlez de threads POSIX ou de threads Windows, c'est un peu une illusion car en réalité vous parlez de threads x86, car c'est une fonction matérielle à exécuter simultanément. Le modèle de mémoire C ++ 0x offre des garanties, que vous soyez sur x86, ARM ou MIPS , ou tout ce que vous pouvez trouver.
la source
Pour les langues ne spécifiant pas de modèle de mémoire, vous écrivez du code pour la langue et le modèle de mémoire spécifiés par l'architecture du processeur. Le processeur peut choisir de réorganiser les accès à la mémoire pour des performances. Donc, si votre programme a des courses de données (une course de données, c'est quand il est possible pour plusieurs cœurs / hyper-threads d'accéder simultanément à la même mémoire), alors votre programme n'est pas multiplateforme en raison de sa dépendance sur le modèle de mémoire du processeur. Vous pouvez vous référer aux manuels des logiciels Intel ou AMD pour savoir comment les processeurs peuvent réorganiser les accès à la mémoire.
Très important, les verrous (et la sémantique de concurrence avec verrouillage) sont généralement implémentés de manière multiplateforme ... Donc, si vous utilisez des verrous standard dans un programme multithread sans course de données, vous n'avez pas à vous soucier des modèles de mémoire multiplateforme .
Fait intéressant, les compilateurs Microsoft pour C ++ ont acquis / publié une sémantique pour volatile qui est une extension C ++ pour faire face à l'absence d'un modèle de mémoire en C ++ http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80) .aspx . Cependant, étant donné que Windows fonctionne uniquement sur x86 / x64, cela ne veut pas dire grand-chose (les modèles de mémoire Intel et AMD facilitent et mettent en œuvre efficacement la sémantique d'acquisition / publication dans un langage).
la source
Si vous utilisez des mutex pour protéger toutes vos données, vous ne devriez vraiment pas avoir à vous inquiéter. Les mutex ont toujours fourni des garanties de commande et de visibilité suffisantes.
Maintenant, si vous avez utilisé des algorithmes atomiques ou sans verrouillage, vous devez penser au modèle de mémoire. Le modèle de mémoire décrit précisément quand l'atomique fournit des garanties de commande et de visibilité, et fournit des clôtures portables pour des garanties codées à la main.
Auparavant, l'atomique était effectuée à l'aide de l'intrinsèque du compilateur ou d'une bibliothèque de niveau supérieur. Les clôtures auraient été réalisées à l'aide d'instructions spécifiques au processeur (barrières de mémoire).
la source
Les réponses ci-dessus abordent les aspects les plus fondamentaux du modèle de mémoire C ++. Dans la pratique, la plupart des utilisations du
std::atomic<>
«travail juste», au moins jusqu'à ce que le programmeur sur-optimise (par exemple, en essayant de détendre trop de choses).Il y a un endroit où les erreurs sont encore courantes: les verrous de séquence . Il y a une discussion excellente et facile à lire sur les défis à https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf . Les verrous de séquence sont attrayants car le lecteur évite d'écrire sur le mot de verrouillage. Le code suivant est basé sur la figure 1 du rapport technique ci-dessus et met en évidence les défis lors de l'implémentation de verrous de séquence en C ++:
Aussi peu intuitif que cela semble au début,
data1
etdata2
doit l'êtreatomic<>
. S'ils ne sont pas atomiques, ils peuvent être lus (enreader()
) en même temps qu'ils sont écrits (enwriter()
). Selon le modèle de mémoire C ++, il s'agit d'une course même si ellereader()
n'utilise jamais réellement les données . De plus, s'ils ne sont pas atomiques, le compilateur peut mettre en cache la première lecture de chaque valeur dans un registre. Évidemment, vous ne voudriez pas cela ... vous voulez relire à chaque itération de lawhile
bouclereader()
.Il ne suffit pas non plus d'en faire
atomic<>
et d'y accédermemory_order_relaxed
. La raison en est que les lectures de seq (inreader()
) n'ont acquis que la sémantique. En termes simples, si X et Y sont des accès à la mémoire, X précède Y, X n'est pas une acquisition ou une libération, et Y est une acquisition, alors le compilateur peut réorganiser Y avant X. Si Y était la deuxième lecture de seq, et X était une lecture de données, une telle réorganisation romprait l'implémentation du verrouillage.Le document donne quelques solutions. Celui qui a les meilleures performances aujourd'hui est probablement celui qui utilise un
atomic_thread_fence
avecmemory_order_relaxed
avant la deuxième lecture du seqlock. Dans le papier, c'est la figure 6. Je ne reproduis pas le code ici, parce que quiconque a lu jusqu'ici devrait vraiment lire le papier. Il est plus précis et complet que ce billet.Le dernier problème est qu'il pourrait être contre nature de rendre les
data
variables atomiques. Si vous ne pouvez pas dans votre code, alors vous devez être très prudent, car la conversion de non-atomique en atomique n'est légale que pour les types primitifs. C ++ 20 est censé être ajoutéatomic_ref<>
, ce qui facilitera la résolution de ce problème.Pour résumer: même si vous pensez comprendre le modèle de mémoire C ++, vous devez être très prudent avant de lancer vos propres verrous de séquence.
la source
C et C ++ étaient définis par une trace d'exécution d'un programme bien formé.
Maintenant, ils sont à moitié définis par une trace d'exécution d'un programme, et à moitié a posteriori par de nombreux ordres sur des objets de synchronisation.
Cela signifie que ces définitions de langage n'ont aucun sens car aucune méthode logique pour mélanger ces deux approches. En particulier, la destruction d'un mutex ou d'une variable atomique n'est pas bien définie.
la source