web-dev-qa-db-fra.com

Concurrence: atomique et volatile dans le modèle de mémoire C ++ 11

Une variable globale est partagée entre 2 threads exécutés simultanément sur 2 cœurs différents. Les threads écrivent et lisent les variables. Pour la variable atomique, un thread peut-il lire une valeur périmée? Chaque noyau peut avoir une valeur de la variable partagée dans son cache et lorsqu'un thread écrit sur sa copie dans un cache, l'autre thread sur un core différent peut lire la valeur périmée de son propre cache. Ou le compilateur fait un ordre de mémoire fort pour lire la dernière valeur de l'autre cache? La bibliothèque standard c ++ 11 a le support std :: atomic. En quoi est-ce différent du mot-clé volatile? Comment les types volatils et atomiques se comporteront-ils différemment dans le scénario ci-dessus?

53
Abhijit_K

Premièrement, volatile n'implique pas d'accès atomique. Il est conçu pour des choses comme les E/S mappées en mémoire et la gestion des signaux. volatile est complètement inutile lorsqu'il est utilisé avec std::atomic, et à moins que votre plate-forme n'indique le contraire, volatile n'a aucune incidence sur l'accès atomique ou l'ordre de la mémoire entre les threads.

Si vous avez une variable globale qui est partagée entre les threads, telle que:

std::atomic<int> ai;

les contraintes de visibilité et de classement dépendent alors du paramètre de classement de la mémoire que vous utilisez pour les opérations et des effets de synchronisation des verrous, des threads et des accès à d'autres variables atomiques.

En l'absence de synchronisation supplémentaire, si un thread écrit une valeur dans ai, rien ne garantit qu'un autre thread verra la valeur dans une période donnée. La norme spécifie qu'elle doit être visible "dans un délai raisonnable", mais tout accès donné peut renvoyer une valeur périmée.

L'ordre de mémoire par défaut de std::memory_order_seq_cst Fournit un ordre total global unique pour toutes les opérations std::memory_order_seq_cst Sur toutes les variables. Cela ne signifie pas que vous ne pouvez pas obtenir de valeurs périmées, mais cela signifie que la valeur que vous obtenez détermine et est déterminée par où se trouve votre ordre total dans votre opération.

Si vous avez 2 variables partagées x et y, initialement zéro, et qu'un thread écrit 1 dans x et un autre écrit 2 dans y, alors un le troisième thread qui lit les deux peut voir soit (0,0), (1,0), (0,2) ou (1,2) car il n'y a pas de contrainte d'ordre entre les opérations, et donc les opérations peuvent apparaître dans n'importe quel ordre dans l'ordre mondial.

Si les deux écritures proviennent du même thread, ce qui fait x=1 Avant y=2 Et si le thread de lecture lit y avant x alors (0,2) est non plus une option valide, car la lecture de y==2 implique que l'écriture précédente dans x est visible. Les 3 autres paires (0,0), (1,0) et (1,2) sont toujours possibles, selon la façon dont les 2 lectures s'entrelacent avec les 2 écritures.

Si vous utilisez d'autres ordres de mémoire tels que std::memory_order_relaxed Ou std::memory_order_acquire, Les contraintes sont encore assouplies et l'ordre global unique ne s'applique plus. Les threads n'ont même pas nécessairement à s'accorder sur l'ordre de deux magasins pour séparer les variables s'il n'y a pas de synchronisation supplémentaire.

La seule façon de garantir que vous disposez de la "dernière" valeur est d'utiliser une opération de lecture-modification-écriture telle que exchange(), compare_exchange_strong() ou fetch_add(). Les opérations de lecture-modification-écriture ont une contrainte supplémentaire de toujours opérer sur la "dernière" valeur, donc une séquence d'opérations ai.fetch_add(1) par une série de threads renverra une séquence de valeurs sans doublons ni lacunes. En l'absence de contraintes supplémentaires, il n'y a toujours aucune garantie que les threads verront quelles valeurs.

Travailler avec des opérations atomiques est un sujet complexe. Je vous suggère de lire beaucoup de documentation et d'examiner le code publié avant d'écrire du code de production avec atomics. Dans la plupart des cas, il est plus facile d'écrire du code qui utilise des verrous et pas beaucoup moins efficace.

84
Anthony Williams

volatile et les opérations atomiques ont un arrière-plan différent et ont été introduites avec une intention différente.

volatile remonte à très loin et est principalement conçu pour empêcher les optimisations du compilateur lors de l'accès aux entrées/sorties mappées en mémoire. Les compilateurs modernes ont tendance à ne faire que supprimer les optimisations pour volatile, bien que sur certaines machines, cela ne soit pas suffisant même pour les E/S mappées en mémoire. À l'exception du cas spécial des gestionnaires de signaux et des séquences setjmp, longjmp et getjmp (où la norme C et, dans le cas des signaux, la norme Posix, donne des garanties), il doit être considéré comme inutile sur une machine moderne, où sans instructions supplémentaires particulières (barrières ou barrières mémoire), le matériel peut réorganiser voire supprimer certains accès. Puisque vous ne devriez pas utiliser setjmp et al. en C++, cela laisse plus ou moins des gestionnaires de signaux, et dans un environnement multithread, au moins sous Unix, il existe également de meilleures solutions pour ceux-ci. Et éventuellement des E/S mappées en mémoire, si vous travaillez sur du code kernal et pouvez vous assurer que le compilateur génère tout ce qui est nécessaire pour la plate-forme en question. (Selon la norme, volatile access est un comportement observable que le compilateur doit respecter. Mais le compilateur arrive à définir ce que l'on entend par "accès", et la plupart semblent le définir comme "une machine de chargement ou de stockage instruction a été exécutée ". Ce qui, sur un processeur moderne, ne signifie même pas qu'il y a nécessairement un cycle de lecture ou d'écriture sur le bus, et encore moins qu'il est dans l'ordre que vous attendez.)

Dans cette situation, le standard C++ a ajouté un accès atomique, qui fournit un certain nombre de garanties sur les threads; en particulier, le code généré autour d'un accès atomique contiendra les instructions supplémentaires nécessaires pour empêcher le matériel de réorganiser les accès et pour garantir que les accès se propagent jusqu'à la mémoire globale partagée entre les cœurs sur une machine multicœur. (À un moment donné de l'effort de normalisation, Microsoft a proposé d'ajouter ces sémantiques à volatile, et je pense que certains de leurs compilateurs C++ le font. Après discussion des problèmes au sein du comité, cependant, le consensus général, y compris Microsoft représentatif — était qu'il valait mieux laisser volatile avec sa signification d'origine et définir les types atomiques.) Ou tout simplement utiliser les primitives de niveau système, comme les mutex, qui exécutent toutes les instructions nécessaires dans leur code. (Ils le doivent. Vous ne pouvez pas implémenter un mutex sans certaines garanties concernant l'ordre des accès à la mémoire.)

30
James Kanze

Volatile et atomique ont des fonctions différentes.

Volatile: informe le compilateur pour éviter l'optimisation. Ce mot-clé est utilisé pour les variables qui doivent changer de façon inattendue. Ainsi, il peut être utilisé pour représenter les registres d'état du matériel, les variables d'ISR, les variables partagées dans une application multi-thread.

Atomic: Il est également utilisé en cas d'application multi-thread. Cependant, cela garantit qu'il n'y a pas de verrouillage/blocage lors de l'utilisation dans une application multithread. Les opérations atomiques sont libres de races et indivisibles. Peu de scénarios clés d'utilisation consistent à vérifier si un verrou est libre ou utilisé, à ajouter atomiquement à la valeur et à renvoyer la valeur ajoutée, etc. dans une application multithread.

3
Karthik Balaguru

Voici un synopsis de base de ce que sont les 2 choses:

1) Mot-clé volatil:
Indique au compilateur que cette valeur peut être modifiée à tout moment et qu'il ne doit donc JAMAIS la mettre en cache dans un registre. Recherchez l'ancien mot-clé "register" dans C. "Volatile" est fondamentalement l'opérateur "-" pour "enregistrer" le "+". Les compilateurs modernes font maintenant l'optimisation que "enregistrer" utilisé pour demander explicitement par défaut, donc vous ne voyez plus que "volatile". L'utilisation du qualificatif volatile garantit que votre traitement n'utilise jamais une valeur périmée, mais rien de plus.

2) Atomique:
Les opérations atomiques modifient les données en un seul temps d'horloge, de sorte qu'il est impossible pour TOUT autre thread d'accéder aux données au milieu d'une telle mise à jour. Ils sont généralement limités aux instructions d'assemblage à une seule horloge prises en charge par le matériel; des choses comme ++, -, et l'échange de 2 pointeurs. Notez que cela ne dit rien sur l'ORDRE, les différents threads exécuteront les instructions atomiques, seulement qu'ils ne s'exécuteront jamais en parallèle. C'est pourquoi vous avez toutes ces options supplémentaires pour forcer une commande.

3
Zack Yezek