Question C ++ 11 a introduit un modèle de mémoire standardisé. Qu'est-ce que ça veut dire? Et comment cela va-t-il affecter la programmation C ++?


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 ça,

Le modèle de mémoire signifie que le code C ++   a maintenant une bibliothèque standardisée pour appeler   indépendamment de qui a fait le compilateur   et sur quelle plate-forme il fonctionne.   Il y a un moyen standard de contrôler comment   différents threads parlent à la   mémoire du processeur.

"Quand vous parlez de fractionnement   [code] à travers différents cœurs qui est   dans la norme, nous parlons de   le modèle de mémoire. Nous allons   l'optimiser sans casser le   hypothèses suivantes les gens vont   faire dans le code, " Sutter m'a dit.

Bon, je peux mémoriser Ceci et des paragraphes similaires disponibles en ligne (comme j'ai eu mon propre modèle de mémoire depuis la naissance: P) et peut même poster comme une réponse aux questions posées par d'autres, mais pour être honnête, je ne comprends pas exactement cela.

Donc, ce que je veux essentiellement savoir, c'est que les programmeurs C ++ avaient l'habitude de développer des applications multithread avant même, alors qu'importe si c'est des threads POSIX, ou des threads Windows, ou des threads 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é au support multi-threading C ++ 11, car 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 fonctionne le fonctionnement interne du multi-threading, et ce que signifie le modèle de mémoire en général, aidez-moi à comprendre ces concepts. :-)


1550
2018-06-11 23:30


origine


Réponses:


D'abord, vous devez apprendre à penser comme un avocat de la langue.

La spécification C ++ ne fait référence à aucun compilateur, système d'exploitation ou processeur particulier. Il fait référence à un machine abstraite c'est une généralisation des systèmes réels. Dans le monde de l'avocat des langues, 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 à la spécification, vous pouvez être certain que votre code sera compilé et exécuté sans modification sur n'importe quel système avec 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 ++ multithread qui soit "entièrement portable" par rapport à la spécification. La spécification ne dit même rien sur le atomicité des charges de mémoire et des magasins ou la commande dans lequel les charges et les magasins peuvent arriver, ne vous occupez pas des choses comme les mutex.

Bien sûr, vous pouvez écrire du code multithread dans la pratique pour des systèmes concrets particuliers - comme pthreads ou Windows. Mais il n'y a pas la normefaçon d'écrire du code multithread pour C ++ 98 / C ++ 03.

La machine abstraite dans C ++ 11 est multi-thread par conception. Il a également un bien défini modèle de mémoire; c'est à dire, ce que le compilateur peut faire et ne pas faire quand il s'agit d'accéder à la mémoire.

Considérons l'exemple suivant, où une paire de variables globales est accédée simultanément par deux threads:

           Global
           int x, y;

Thread 1            Thread 2
x = 17;             cout << y << " ";
y = 37;             cout << x << endl;

Que pourrait Thread 2 sortie?

Sous C ++ 98 / C ++ 03, ce comportement n'est même pas indéfini; la question elle-même est sans signification parce que la norme n'envisage rien de ce qu'on appelle un "fil".

Sous C ++ 11, le résultat est Undefined Behavior, car les charges et les magasins n'ont pas besoin d'être atomiques en général. Ce qui peut ne pas sembler être une amélioration ... Et en soi, ce n'est pas le cas.

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 est ici défini. Thread 2 pouvait 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 (si elle est exécutée après que Thread 1 a assigné à x mais avant qu'il ne soit assigné à y).

Ce qu'il ne peut pas imprimer est 37 0, car le mode par défaut pour les charges / magasins atomiques dans C ++ 11 est de consistance séquentielle. Cela signifie simplement que toutes les charges et tous les magasins doivent être "comme si" ils se produisaient dans l'ordre où vous les avez écrits dans chaque thread, tandis que les opérations entre les threads peuvent être entrelacées, quel que soit le système. Ainsi, le comportement par défaut de l'atomique fournit à la fois atomicité et commander pour les charges et les magasins.

Maintenant, sur un processeur moderne, assurer une 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 stocks en désordre; c'est-à-dire, si elle nécessite une atomicité mais pas d'ordre; c'est-à-dire, s'il peut tolérer 37 0 comme sortie de ce programme, alors 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 soit plus rapide que l'exemple précédent.

Enfin, si vous avez juste besoin de garder des charges particulières et des magasins 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 charges et magasins commandés - donc 37 0 n'est plus une sortie possible - mais il 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 complète, dans un programme plus grand, 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 juste enrouler un mutex autour du code original. 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 prévu :-).

Donc, la ligne de fond. Les mutex sont excellents, et C ++ 11 les standardise. Mais parfois, pour des raisons de performance, vous voulez des primitives de niveau inférieur (par exemple, le classique 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, et fournit également des gadgets de bas niveau tels que les types atomiques et les différentes variantes de la barrière de mémoire. Vous pouvez maintenant écrire des routines simultanées sophistiquées et performantes entièrement dans la langue spécifiée par la norme, et vous pouvez être certain que votre code sera compilé et fonctionnera sans changement sur les systèmes d'aujourd'hui et de demain.

Bien que, pour être franc, à moins d'être un expert et de travailler sur un code sérieux 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 ce blog.


1797
2018-06-12 00:23



Je vais juste donner l'analogie avec laquelle je comprends les modèles de cohérence mémoire (ou modèles de mémoire, pour faire court). Il est inspiré par l'article séminal de Leslie Lamport "Heure, horloges et ordre des événements dans un système distribué". L'analogie est juste et a une signification fondamentale, mais peut être exagérée pour beaucoup de gens. 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.

Regardons les historiques de tous les emplacements de mémoire dans un diagramme spatio-temporel dans lequel l'axe horizontal représente l'espace d'adressage (ie, 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 écrit une nouvelle valeur à cet emplacement. Par un image mémoire, nous voulons dire l'agrégat / combinaison de valeurs de tous les emplacements de mémoire observables à un moment donné par un fil particulier.

Citant de "Une introduction à la cohérence de la mémoire et à la cohérence du cache"

Le modèle de mémoire intuitif (et le plus restrictif) est la cohérence séquentielle (SC) dans laquelle une exécution multithread devrait ressembler à un entrelacement des exécutions séquentielles de chaque thread constitutif, comme si les threads étaient multiplexés dans le temps sur un processeur monocœur.

Cet ordre de mémoire global 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 d'adresse-espace-temps représentant avions 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 y a une notion de Temps absolu, dans lequel 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. C'est-à-dire qu'à chaque instant, tous les processeurs sont d'accord sur l'image de la mémoire (c'est-à-dire, le contenu global 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ême combinaisons de valeurs de toutes les variables. Cela équivaut à 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 découpera l'adresse-espace-temps à sa manière, la seule restriction étant que les tranches de chaque thread ne se croisent pas car tous les threads doivent s'accorder sur l'historique de chaque emplacement mémoire individuel (bien sûr , des tranches de fils différents peuvent, et vont, se croiser). Il n'y a pas de façon universelle de le découper (pas de foliation privilégiée de l'adresse-espace-temps). Les tranches ne doivent pas nécessairement être planes (ou linéaires). Ils peuvent être courbés et c'est ce qui peut rendre un thread lire des valeurs écrites par un autre thread dans l'ordre dans lequel ils ont été écrits. Les historiques des différents emplacements de mémoire peuvent glisser (ou être étirés) arbitrairement les uns par rapport aux autres quand vu par un fil particulier. Chaque thread aura un sens différent des événements (ou, de manière équivalente, des valeurs de mémoire) 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 relaxée, tous les threads observent toujours le même historique (c'est-à-dire, la séquence de valeurs) pour chaque emplacement de mémoire. Mais ils peuvent observer différentes images de mémoire (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 valeurs nouvellement écrites peuvent être observées dans un ordre différent par d'autres threads.

[Image de Wikipedia] Picture from Wikipedia

Les lecteurs familiers avec Einstein Théorie spéciale de la relativité remarquerez ce à quoi je fais allusion. Traduire les mots de Minkowski dans le domaine des modèles de mémoire: l'espace d'adresse et le temps sont des ombres de l'adresse-espace-temps. Dans ce cas, chaque observateur (c'est-à-dire, thread) projette les ombres des événements (ie, mémoires / charges) sur sa propre ligne du monde (son axe temporel) et son propre plan de simultanéité (son axe adresse-espace) . Les threads du modèle de mémoire C ++ 11 correspondent à observateurs qui se déplacent les uns par rapport aux autres en relativité restreinte. La cohérence séquentielle correspond à Espace-temps galiléen (c'est-à-dire que tous les observateurs s'accordent sur un ordre absolu des é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és, souvent appelés un ensemble causal. Certains événements (c'est-à-dire, les magasins de mémoire) 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, des charges de mémoire et stocke des adresses éventuellement différentes).

En relativité, un certain ordre est restauré à 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 "timelike" (c.-à-d. que la vitesse de la lumière dans le vide). Seuls les événements liés au timeline sont ordonnés de manière invariable. Temps en physique, Craig Callender.

Dans le modèle de mémoire C ++ 11, un mécanisme similaire (le modèle de cohérence d'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 vais citer "Une introduction à la cohérence de la mémoire et à la cohérence du cache"

Pour une machine à mémoire partagée, le modèle de cohérence de la mémoire définit le comportement architecturalement visible de son système de mémoire. Le critère d'exactitude pour un comportement de partitions de noyau de processeur unique entre "un résultat correct" et "beaucoup d'alternatives incorrectes". En effet, l'architecture du processeur exige que l'exécution d'un thread transforme un état d'entrée donné en un état de sortie bien défini, même sur un cœur en panne. Les modèles de cohérence de mémoire partagée, cependant, concernent les charges et les magasins de plusieurs threads et permettent généralement beaucoup d'exécutions correctes tout en interdisant plusieurs (plus) incorrectes. La possibilité de plusieurs exécutions correctes est due à l'ISA qui permet à plusieurs threads de s'exécuter simultanément, souvent avec de nombreux entrelacements légaux possibles d'instructions provenant de différents threads.

Détendu ou faible Les modèles de cohérence de la mémoire sont motivés par le fait que la plupart des ordres de mémoire dans les modèles forts sont inutiles. Si un thread met à jour dix éléments de données, puis un indicateur de synchronisation, les programmeurs ne se soucient généralement pas si les éléments de données sont mis à jour les uns par rapport aux autres mais seulement que tous les éléments sont mis à jour. ). Les modèles détendus cherchent à capturer cette flexibilité de commande accrue et à ne conserver que les commandes que les programmeurs "exiger"Pour obtenir à la fois une meilleure performance et la justesse de SC. Par exemple, dans certaines architectures, les tampons d'écriture FIFO sont utilisés par chaque noyau pour contenir les résultats des magasins validés (retirés) avant d'écrire les résultats dans les caches. Cette optimisation améliore les performances mais viole SC. Le tampon d'écriture cache la latence de l'entretien d'un magasin manqué. Parce que les magasins sont communs, être capable d'éviter de caler sur la plupart d'entre eux est un avantage important. Pour un processeur monocœur, un tampon d'écriture peut être rendu invisible d'un point de vue architectural en vérifiant qu'une charge vers l'adresse A renvoie la valeur du magasin le plus récent à A même si une ou plusieurs mémoires vers A se trouvent dans le tampon d'écriture. Cela est généralement effectué en passant la valeur du magasin le plus récent à A en charge de A, où «le plus récent» est déterminé par l'ordre du programme, ou en bloquant une charge de A si un magasin vers A se trouve dans le tampon d'écriture . Lorsque plusieurs cœurs sont utilisés, chacun aura son propre tampon d'écriture de contournement. Sans tampons d'écriture, le matériel est SC, mais avec des tampons d'écriture, ce n'est pas le cas, ce qui rend les tampons d'écriture visibles sur le plan architectural dans un processeur multicœur.

Le réapprovisionnement d'un magasin peut se produire si un core dispose d'un tampon d'écriture non-FIFO qui permet aux magasins de partir dans un ordre différent de l'ordre dans lequel ils sont entrés. Cela peut se produire si le premier magasin manque dans le cache pendant que le second frappe ou si le second magasin peut fusionner avec un magasin précédent (c'est-à-dire avant le premier magasin). La réorganisation de charge peut également se produire sur des cœurs planifiés dynamiquement qui exécutent des instructions hors de l'ordre du programme. Cela peut se comporter de la même manière que réorganiser les magasins sur un autre noyau (Pouvez-vous trouver un exemple d'entrelacement entre deux threads?). La réorganisation d'une charge précédente avec un magasin ultérieur (réorganisation du magasin de chargement) peut entraîner de nombreux comportements incorrects, tels que le chargement d'une valeur après avoir libéré le verrou qui la protège (si le magasin est l'opération de déverrouillage). Notez que des réordonnances de charge de stockage peuvent également survenir en raison d'un contournement local dans le tampon d'écriture FIFO couramment implémenté, même avec un noyau qui exécute toutes les instructions dans l'ordre du programme.

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:

Contrairement à la cohérence, cohérence du cache n'est ni visible au logiciel ni nécessaire. La cohérence vise à rendre les caches d'un système à mémoire partagée aussi fonctionnellement invisibles que les caches dans un système monocœur. La cohérence correcte garantit qu'un programmeur ne peut pas déterminer si et où un système a des caches en analysant les résultats des charges et des magasins. C'est parce que la cohérence correcte garantit que les caches ne permettent jamais de nouvelles ou différentes fonctionnel comportement (les programmeurs peuvent toujours être en mesure d'inférer la structure du cache probable en utilisant timing information). L'objectif principal des protocoles de cohérence de cache est de maintenir l'invariant SWMR (single-writer-multiple-readers) pour chaque emplacement de mémoire.   Une distinction importante entre cohérence et cohérence est que la cohérence est spécifiée sur base d'emplacement par mémoire, alors que la cohérence est spécifiée en ce qui concerne tout emplacements de mémoire.

En continuant avec notre image mentale, l'invariant SWMR correspond à l'exigence physique qu'il y ait 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.


279
2017-08-29 20:42



C'est maintenant une question vieille de plusieurs années, mais étant très populaire, il convient de mentionner une ressource fantastique pour apprendre sur le modèle de mémoire C ++ 11. Je ne vois pas l'intérêt de résumer son discours pour en faire une autre réponse complète, mais étant donné que c'est le type qui a réellement écrit la norme, je pense que ça vaut la peine de regarder la conversation.

Herb Sutter parle pendant trois heures du modèle de mémoire C ++ 11 intitulé "atomic <> Weapons", disponible sur le site Channel9 - partie 1 et partie 2. La discussion est assez technique et couvre les sujets suivants:

  1. Optimisations, les courses et le modèle de mémoire
  2. Commande - Quoi: Acquérir et libérer
  3. Commande - Comment: Mutex, Atomics, et / ou clôtures
  4. Autres restrictions sur les compilateurs et le matériel
  5. Code Gen et performance: x86 / x64, IA64, POWER, ARM
  6. Atomique détendue

La discussion ne porte pas sur l'API, mais plutôt sur le raisonnement, l'arrière-plan, sous le capot et dans les coulisses (saviez-vous que la sémantique relaxée a été ajoutée à la norme uniquement parce que POWER et ARM ne supportent pas la charge synchronisée?).


79
2017-12-20 13:22



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é des implémentations différentes, mais c'est comme demander pourquoi nous devrions avoir un std::string quand on pourrait tous utiliser un home-rolled string classe.

Quand vous parlez de threads POSIX ou de threads Windows, alors c'est un peu une illusion car vous parlez de threads x86, car c'est une fonction matérielle à exécuter simultanément. Le modèle de mémoire C ++ 0x apporte des garanties, que vous soyez sur x86 ou ARM, ou MIPSou toute autre chose que vous pouvez trouver.


67
2018-06-11 23:42



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é par l'architecture du processeur. Le processeur peut choisir de réorganiser les accès mémoire pour des performances. Alors, si votre programme a des courses de données (une course de données 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 au modèle de mémoire du processeur. Vous pouvez vous reporter aux manuels des logiciels Intel ou AMD pour savoir comment les processeurs peuvent réorganiser les accès mémoire.

Très important, les verrous (et la sémantique de concurrence avec le verrouillage) sont généralement implémentés de manière multi-plateforme ... Donc, si vous utilisez des verrous standard dans un programme multithread sans courses de données, alors vous ne pas avoir à vous soucier des modèles de mémoire multi-plateforme.

Il est intéressant de noter que les compilateurs Microsoft pour C ++ ont une sémantique d'acquisition / libération pour le 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 s'exécute uniquement sur x86 / x64, cela ne signifie pas grand-chose (les modèles de mémoire Intel et AMD facilitent la mise en œuvre d'une sémantique d'acquisition / libération dans une langue).


49
2017-07-26 04:27



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 verrou, vous devez penser au modèle de mémoire. Le modèle de mémoire décrit précisément quand les atomiques fournissent des garanties de commande et de visibilité, et fournit des clôtures portables pour les garanties codées à la main.

Auparavant, atomics serait fait en utilisant des intrinsèques du compilateur, ou une bibliothèque de niveau supérieur. Les clôtures auraient été faites en utilisant des instructions spécifiques au CPU (barrières de mémoire).


22
2018-06-11 23:49