C++ 11 a introduit un modèle de mémoire normalisé, 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, indépendamment de l'auteur du compilateur et de la plate-forme sur laquelle il s'exécute. Il existe un moyen standard de contrôler la manière dont différents threads communiquent avec la mémoire du processeur.
"Lorsque vous parlez de la scission de [code] entre différents cœurs de la norme, nous parlons du modèle de mémoire. Nous allons l'optimiser sans rompre les hypothèses suivantes que les gens vont faire dans le code" Sutter dit.
Eh bien, je peux mémoriser ceci et d'autres paragraphes similaires disponibles en ligne (comme je possède mon propre modèle de mémoire depuis ma naissance: P) et je peux même poster en réponse à des questions posées par d'autres , mais pour être honnête, je ne comprends pas exactement cela.
Les programmeurs C++ avaient l'habitude de développer des applications multithreads avant, alors qu'importe que ce soit un thread POSIX, un thread Windows ou un thread C++ 11? Quels sont les bénéfices? Je veux comprendre les détails de bas niveau.
J'ai également le sentiment que le modèle de mémoire C++ 11 est en quelque sorte lié au support du multi-threading C++ 11, car je vois souvent ces deux éléments ensemble. Si c'est le cas, comment exactement? Pourquoi devraient-ils être liés?
Comme je ne sais pas comment fonctionnent les éléments internes du multi-threading, et quel modèle de mémoire signifie en général, aidez-moi à comprendre ces concepts. :-)
Premièrement, vous devez apprendre à penser comme un avocat spécialiste des langues.
La spécification C++ ne fait référence à aucun compilateur, système d'exploitation ou processeur particulier. Il fait référence à une machine abstraite qui est une généralisation des systèmes actuels. Dans le monde de Language Lawyer, le travail du programmeur consiste à écrire du code pour la machine abstraite. le travail du compilateur consiste à actualiser ce code sur une machine concrète. En codant de manière rigide conformément à la spécification, vous pouvez être certain que votre code sera compilé et exécuté sans modification sur tout système doté d'un compilateur C++ conforme, que ce soit aujourd'hui ou dans 50 ans.
La machine abstraite dans la spécification C++ 98/C++ 03 est fondamentalement mono-thread. Il n'est donc pas possible d'écrire du code C++ multi-thread "entièrement portable" par rapport à la spécification. La spécification ne dit même rien sur l'atomicité de la mémoire chargée ou stockée, ni sur l'ordre dans lequel des charges et des magasins peuvent se produire, peu importe les choses comme les mutex.
Bien entendu, vous pouvez écrire du code multithread dans la pratique pour des systèmes concrets particuliers, tels que pthreads ou Windows. Mais il n'y a pas moyen standard d'écrire du code multithread pour C++ 98/C++ 03.
La machine abstraite en C++ 11 est multi-threadée 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 faire ou ne pas faire lorsqu'il s'agit d'accéder à la mémoire.
Prenons l'exemple suivant, où deux paires de variables globales sont accessibles simultanément par deux threads:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
Qu'est-ce que Thread 2 pourrait afficher?
Sous C++ 98/C++ 03, il ne s'agit même pas d'un comportement indéfini; la question elle-même est dénuée de sens car le standard ne prévoit rien qui s'appelle un "thread".
Sous C++ 11, le résultat est un comportement indéfini, car les charges et les magasins n'ont pas besoin d'être atomiques en général. Ce qui ne semble pas vraiment être une amélioration ... Et en soi, ça ne l'est pas.
Mais avec C++ 11, vous pouvez écrire ceci:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
Maintenant, les choses deviennent beaucoup plus intéressantes. Tout d'abord, le comportement ici est défini . Le thread 2 peut maintenant imprimer 0 0
(s'il s'exécute avant le thread 1), 37 17
(s'il s'exécute après le thread 1) ou 0 17
(s'il s'exécute après le thread 1 affecte à x mais avant d’assigner à y).
Ce qu'il ne peut pas imprimer, c'est 37 0
, car le mode par défaut pour les charges/magasins atomiques dans C++ 11 consiste à appliquer la cohérence séquentielle . Cela signifie simplement que tous les chargements et les magasins doivent être "comme si" ils se sont déroulés dans l'ordre que vous les avez écrits dans chaque thread, tandis que les opérations entre les threads peuvent être entrelacées comme bon vous semble. Donc, le comportement par défaut de atomics fournit à la fois atomicity et un ordre pour les charges et magasins.
Désormais, sur un processeur moderne, assurer la cohérence séquentielle peut coûter cher. En particulier, le compilateur est susceptible d’émettre des barrières de mémoire complètes entre chaque accès ici. Mais si votre algorithme peut tolérer des chargements et des stockages dans l’ordre, c'est-à-dire, si cela nécessite l'atomicité mais pas l'ordre; c'est-à-dire que s'il peut tolérer 37 0
comme sortie de ce programme, vous pouvez écrire ceci:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
Plus le processeur est moderne, plus il est probable qu'il sera plus rapide que l'exemple précédent.
Enfin, si vous avez juste besoin de garder des charges et des magasins particuliers en ordre, vous pouvez écrire:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
Cela nous ramène aux chargements et aux magasins commandés - donc 37 0
n'est plus une sortie possible - mais avec une surcharge minimale. (Dans cet exemple trivial, le résultat est identique à une cohérence séquentielle complète; dans un programme plus vaste, ce ne serait pas le cas.)
Bien sûr, si les seules sorties que vous voulez voir sont 0 0
ou 37 17
, vous pouvez simplement envelopper un mutex autour du code original. Mais si vous avez lu jusque-là, je parie que vous savez déjà comment cela fonctionne et cette réponse est déjà plus longue que prévu :-).
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 type modèle de verrouillage à double vérification ). La nouvelle norme fournit des gadgets de haut niveau tels que les mutex et les variables de condition, ainsi que des gadgets de bas niveau tels que les types atomiques et les différentes variantes de 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 sera compilé et exécuté sans modification sur les systèmes actuels et futurs.
Bien que, pour être franc, à moins que vous ne soyez un expert et que vous travailliez sur un code grave de bas niveau, vous devriez probablement vous en tenir aux mutex et aux variables de condition. C'est ce que j'ai l'intention de faire.
Pour plus d'informations sur ce sujet, voir cet article de blog .
Cette question a maintenant 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'utilité de résumer son exposé pour en faire une autre réponse complète, mais étant donné que c'est le gars qui a en fait rédigé la norme, je pense que cela vaut la peine de regarder la conversation.
Herb Sutter a parlé pendant trois heures du modèle de mémoire C++ 11 intitulé "Armes atomiques <>," disponible sur le site Channel9 - partie 1 et partie 2 . L'exposé est assez technique et couvre les sujets suivants:
L'exposé ne traite pas de l'API, mais du raisonnement, de l'arrière-plan, du capot et des coulisses (saviez-vous qu'une sémantique détendue a été ajoutée à la norme uniquement parce que POWER et ARM ne prennent pas en charge charge synchronisée efficacement?).
Cela signifie que la norme définit désormais le multi-threading et ce qui se passe dans le contexte de plusieurs threads. Bien sûr, les gens utilisaient diverses implémentations, mais c'est comme demander pourquoi nous devrions avoir un std::string
alors que nous pourrions tous utiliser une classe string
roulée à la maison.
Lorsque vous parlez de threads POSIX ou Windows, c’est un peu une illusion de parler 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 utilisiez x86, ARM ou MIPS , ou toute autre chose que vous puissiez créer.
Pour les langues ne spécifiant pas de modèle de mémoire, vous écrivez du code pour les langues et le modèle de mémoire spécifié par l'architecture du processeur. Le processeur peut choisir de réorganiser les accès à la mémoire pour améliorer les performances. Donc, si votre programme comporte des courses de données (une course de données survient lorsqu'il est possible pour plusieurs cœurs/hyper-threads d'accéder simultanément à la même mémoire), programme n'est pas multiplateforme en raison de sa dépendance au modèle de mémoire du processeur. Vous pouvez vous reporter aux manuels du logiciel Intel ou AMD pour savoir comment les processeurs peuvent commander à nouveau des accès à la mémoire.
Ce qui est très important, les verrous (et la sémantique des accès concurrents 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 parcours de données, vous ne ' pas avoir à vous soucier des modèles de mémoire multi-plateformes .
Il est intéressant de noter que les compilateurs Microsoft pour C++ ont une sémantique d’acquisition/publication pour volatile, qui est une extension C++ permettant de remédier à l’absence de modèle de mémoire en C++ http://msdn.Microsoft.com/en-us/library/12a04hfd (v = vs.80) .aspx . Cependant, étant donné que Windows ne fonctionne que sur x86/x64, cela n’en dit pas long (les modèles de mémoire Intel et AMD facilitent et optimisent l’implémentation de la sémantique d’acquisition/édition dans un langage).
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 Atomics 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 réalisé à l'aide d'intrinsèques 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 à la CPU (barrières de mémoire).
C et C++ étaient définis auparavant par une trace d'exécution d'un programme bien formé.
Maintenant, ils sont définis à moitié par la trace d'exécution d'un programme et à moitié a posteriori par de nombreux ordres sur les objets de synchronisation.
Ce qui veut dire que ces définitions de langage n’ont aucun sens car il n’ya pas de méthode logique pour combiner ces deux approches. En particulier, la destruction d'un mutex ou d'une variable atomique n'est pas bien définie.