En général, for int num
, num++
(ou ++num
), en tant qu'opération de lecture-modification-écriture, n'est pas atomique . Mais je vois souvent des compilateurs, par exemple GCC , générer le code suivant ( essayez ici ):
Puisque la ligne 5, qui correspond à num++
une instruction, peut-on conclure que num++
c'est atomique dans ce cas?
Et si oui, cela signifie-t-il que ainsi généré num++
peut être utilisé dans des scénarios simultanés (multi-threads) sans aucun danger de course aux données (c'est-à-dire que nous n'avons pas besoin de le faire, par exemple, std::atomic<int>
et d'imposer les coûts associés, car il est atomique de toute façon)?
METTRE À JOUR
Notez que cette question n'est pas de savoir si l'incrément est atomique (ce n'est pas et c'était et est la première ligne de la question). Il s'agit de savoir si cela peut être dans des scénarios particuliers, c'est-à-dire si la nature d'une seule instruction peut dans certains cas être exploitée pour éviter la surcharge du lock
préfixe. Et, comme le mentionne la réponse acceptée dans la section sur les machines monoprocesseurs, ainsi que cette réponse , la conversation dans ses commentaires et d'autres l'expliquent, il le peut (mais pas avec C ou C ++).
add
c'était atomique?std::atomic<int>
.add
instruction, un autre cœur pourrait voler cette adresse mémoire du cache de ce cœur et la modifier. Sur un processeur x86, l'add
instruction a besoin d'unlock
préfixe si l'adresse doit être verrouillée dans le cache pendant la durée de l'opération.Réponses:
C'est absolument ce que C ++ définit comme une course aux données qui provoque un comportement indéfini, même si un compilateur a produit du code qui a fait ce que vous espériez sur une machine cible. Vous devez l'utiliser
std::atomic
pour obtenir des résultats fiables, mais vous pouvez l'utiliser avecmemory_order_relaxed
si vous ne vous souciez pas de la réorganisation. Voir ci-dessous pour un exemple de code et de sortie asm utilisantfetch_add
.Mais d'abord, le langage d'assemblage fait partie de la question:
Les instructions de destination de la mémoire (autres que les magasins purs) sont des opérations de lecture-modification-écriture qui se produisent en plusieurs étapes internes . Aucun registre architectural n'est modifié, mais le CPU doit conserver les données en interne pendant qu'il les envoie via son ALU . Le fichier de registre réel n'est qu'une petite partie du stockage de données à l'intérieur même du processeur le plus simple, avec des verrous retenant les sorties d'un étage comme entrées pour un autre étage, etc., etc.
Les opérations de mémoire d'autres CPU peuvent devenir globalement visibles entre le chargement et le stockage. C'est-à-dire que deux threads fonctionnant
add dword [num], 1
en boucle marcheraient sur les magasins l'un de l'autre. (Voir la réponse de @ Margaret pour un joli diagramme). Après des incréments de 40k pour chacun des deux threads, le compteur n'a peut-être augmenté que d'environ 60k (et non 80k) sur du matériel x86 multicœur réel.«Atomique», du mot grec signifiant indivisible, signifie qu'aucun observateur ne peut voir l'opération comme des étapes séparées. Se produire physiquement / électriquement instantanément pour tous les bits simultanément n'est qu'un moyen d'y parvenir pour une charge ou un stockage, mais ce n'est même pas possible pour une opération ALU. Je suis entré beaucoup plus en détail sur les charges pures et les magasins purs dans ma réponse à Atomicity sur x86 , tandis que cette réponse se concentre sur la lecture-modification-écriture.
Le
lock
préfixe peut être appliqué à de nombreuses instructions de lecture-modification-écriture (destination de la mémoire) pour rendre l'opération entière atomique par rapport à tous les observateurs possibles dans le système (autres cœurs et périphériques DMA, pas un oscilloscope connecté aux broches du processeur). Voilà pourquoi cela existe. (Voir aussi cette Q&R ).Ainsi
lock add dword [num], 1
est atomique . Un cœur de processeur exécutant cette instruction garderait la ligne de cache épinglée à l'état Modifié dans son cache L1 privé à partir du moment où la charge lit les données du cache jusqu'à ce que le magasin remette son résultat dans le cache. Cela empêche tout autre cache du système d'avoir une copie de la ligne de cache à tout moment du chargement au stockage, selon les règles du protocole de cohérence du cache MESI (ou les versions MOESI / MESIF de celui-ci utilisées par AMD multicœur / Processeurs Intel, respectivement). Ainsi, les opérations effectuées par d'autres cœurs semblent se produire avant ou après, pas pendant.Sans le
lock
préfixe, un autre noyau pourrait prendre possession de la ligne de cache et la modifier après notre chargement mais avant notre magasin, de sorte que l'autre magasin devienne globalement visible entre notre chargement et notre magasin. Plusieurs autres réponses se trompent et prétendent que sans cela,lock
vous obtiendrez des copies conflictuelles de la même ligne de cache. Cela ne peut jamais arriver dans un système avec des caches cohérents.(Si une
lock
instruction ed fonctionne sur une mémoire qui s'étend sur deux lignes de cache, il faut beaucoup plus de travail pour s'assurer que les modifications apportées aux deux parties de l'objet restent atomiques lorsqu'elles se propagent à tous les observateurs, de sorte qu'aucun observateur ne peut voir la déchirure. Le processeur peut doivent verrouiller tout le bus mémoire jusqu'à ce que les données atteignent la mémoire. Ne désalignez pas vos variables atomiques!)Notez que le
lock
préfixe transforme également une instruction en une barrière de mémoire complète (comme MFENCE ), arrêtant toute réorganisation au moment de l'exécution et donnant ainsi une cohérence séquentielle. (Voir l'excellent article de blog de Jeff Preshing . Ses autres articles sont également excellents et expliquent clairement beaucoup de bonnes choses sur la programmation sans verrouillage , de x86 et d'autres détails matériels aux règles C ++.)Sur une machine monoprocesseur, ou dans un processus monothread , une seule instruction RMW est en réalité atomique sans
lock
préfixe. Le seul moyen pour un autre code d'accéder à la variable partagée est que le processeur effectue un changement de contexte, ce qui ne peut pas se produire au milieu d'une instruction. Ainsi, un simpledec dword [num]
peut se synchroniser entre un programme monothread et ses gestionnaires de signaux, ou dans un programme multi-thread fonctionnant sur une machine monocœur. Voir la deuxième moitié de ma réponse sur une autre question , et les commentaires en dessous, où je l'explique plus en détail.Retour au C ++:
C'est totalement faux à utiliser
num++
sans dire au compilateur que vous en avez besoin pour compiler en une seule implémentation en lecture-modification-écriture:Ceci est très probable si vous utilisez la valeur de
num
later: le compilateur le conservera en direct dans un registre après l'incrémentation. Ainsi, même si vous vérifiez comment senum++
compile seul, la modification du code environnant peut l'affecter.(Si la valeur est nécessaire plus tard,
inc dword [num]
est préféré, les processeurs x86 modernes vont exécuter une instruction RMW destination de mémoire au moins aussi efficace que l' utilisation de trois instructions distinctes fait Fun:.gcc -O3 -m32 -mtune=i586
Émet effectivement ce , parce que (Pentium) pipeline superscalaire didn de P5 ne décode pas d'instructions complexes en plusieurs micro-opérations simples comme le font les microarchitectures P6 et ultérieures. Voir les tableaux d'instructions / guide de microarchitecture d'Agner Fog pour plus d'informations, et lex86 tag wiki pour de nombreux liens utiles (y compris les manuels Intel x86 ISA, qui sont disponibles gratuitement au format PDF)).Ne confondez pas le modèle de mémoire cible (x86) avec le modèle de mémoire C ++
La réorganisation au moment de la compilation est autorisée . L'autre partie de ce que vous obtenez avec std :: atomic est le contrôle de la réorganisation au moment de la compilation, pour vous assurer que votre
num++
devient globalement visible seulement après une autre opération.Exemple classique: stockage de certaines données dans un tampon pour qu'un autre thread les regarde, puis définition d'un indicateur. Même si x86 acquiert des chargements / librairies gratuitement, vous devez quand même dire au compilateur de ne pas réorganiser en utilisant
flag.store(1, std::memory_order_release);
.Vous vous attendez peut-être à ce que ce code se synchronise avec d'autres threads:
Mais ce ne sera pas le cas. Le compilateur est libre de déplacer le à
flag++
travers l'appel de fonction (s'il intègre la fonction ou sait qu'il ne regarde pasflag
). Ensuite, il peut optimiser complètement la modification, car ceflag
n'est même pasvolatile
. (Et non, C ++volatile
n'est pas un substitut utile pour std :: atomic. Std :: atomic oblige le compilateur à supposer que les valeurs en mémoire peuvent être modifiées de manière asynchrone similaire àvolatile
, mais il y a bien plus que cela. De plus, cevolatile std::atomic<int> foo
n'est pas le commestd::atomic<int> foo
, comme discuté avec @Richard Hodges.)Définir des courses de données sur des variables non atomiques en tant que comportement indéfini est ce qui permet au compilateur de toujours hisser les charges et évacuer les magasins hors des boucles, et de nombreuses autres optimisations pour la mémoire auxquelles plusieurs threads peuvent avoir une référence. (Consultez ce blog LLVM pour en savoir plus sur la façon dont UB permet les optimisations du compilateur.)
Comme je l'ai mentionné, le préfixe x86
lock
est une barrière de mémoire pleine, donc utilisernum.fetch_add(1, std::memory_order_relaxed);
génère le même code sur x86 quenum++
(la valeur par défaut est la cohérence séquentielle), mais il peut être beaucoup plus efficace sur d'autres architectures (comme ARM). Même sur x86, la fonction Relax permet une plus grande réorganisation au moment de la compilation.C'est ce que fait réellement GCC sur x86, pour quelques fonctions qui opèrent sur une
std::atomic
variable globale.Voir le code source + langage d'assemblage bien formaté sur l' explorateur du compilateur Godbolt . Vous pouvez sélectionner d'autres architectures cibles, notamment ARM, MIPS et PowerPC, pour voir quel type de code de langage d'assemblage vous obtenez d'Atomics pour ces cibles.
Remarquez comment MFENCE (une barrière complète) est nécessaire après un stockage de cohérence séquentielle. x86 est fortement ordonné en général, mais la réorganisation StoreLoad est autorisée. Disposer d'un tampon de stockage est essentiel pour de bonnes performances sur un processeur hors service en pipeline. La réorganisation de la mémoire Caught in the Act de Jeff Preshing montre les conséquences de la non- utilisation de MFENCE, avec du vrai code pour montrer que la réorganisation se produit sur du matériel réel.
Re: discussion dans les commentaires sur la réponse de @Richard Hodges à propos des compilateurs fusionnant les
num++; num-=2;
opérations std :: atomic en une seulenum--;
instruction :Une question-réponse distincte sur le même sujet: Pourquoi les compilateurs ne fusionnent-ils pas les écritures std :: atomic redondantes? , où ma réponse reprend une grande partie de ce que j'ai écrit ci-dessous.
Les compilateurs actuels ne le font pas (encore), mais pas parce qu'ils n'y sont pas autorisés. C ++ WG21 / P0062R1: Quand les compilateurs doivent-ils optimiser les atomiques? traite de l'attente de nombreux programmeurs que les compilateurs n'effectuent pas d'optimisations «surprenantes» et de ce que la norme peut faire pour donner le contrôle aux programmeurs. N4455 présente de nombreux exemples de choses qui peuvent être optimisées, y compris celle-ci. Il souligne que l'inlining et la propagation constante peuvent introduire des choses comme
fetch_or(0)
qui peuvent être capables de se transformer en juste unload()
(mais qui a toujours une sémantique d'acquisition et de publication), même lorsque la source d'origine n'avait pas d'opérations atomiques manifestement redondantes.Les vraies raisons pour lesquelles les compilateurs ne le font pas (encore) sont: (1) personne n'a écrit le code compliqué qui permettrait au compilateur de le faire en toute sécurité (sans jamais se tromper), et (2) cela viole potentiellement le principe du moins surprise . Un code sans verrouillage est suffisamment difficile pour être écrit correctement en premier lieu. Ne soyez donc pas désinvolte dans votre utilisation des armes atomiques: elles ne sont pas bon marché et n'optimisent pas beaucoup. Il n'est pas toujours facile d'éviter les opérations atomiques redondantes avec
std::shared_ptr<T>
, cependant, car il n'y a pas de version non atomique de celui-ci (bien qu'une des réponses ici donne un moyen facile de définir unshared_ptr_unsynchronized<T>
pour gcc).Pour en revenir à la
num++; num-=2;
compilation comme si ellenum--
: Compilateurs sont autorisés à le faire, à moinsnum
estvolatile std::atomic<int>
. Si une réorganisation est possible, la règle as-if permet au compilateur de décider au moment de la compilation que cela se passe toujours de cette façon. Rien ne garantit qu'un observateur pourrait voir les valeurs intermédiaires (lenum++
résultat).C'est-à-dire que si l'ordre où rien ne devient globalement visible entre ces opérations est compatible avec les exigences d'ordre de la source (selon les règles C ++ de la machine abstraite, pas de l'architecture cible), le compilateur peut en émettre un seul
lock dec dword [num]
au lieu delock inc dword [num]
/lock sub dword [num], 2
.num++; num--
ne peut pas disparaître, car il a toujours une relation Synchronizes With avec d'autres threads qui regardentnum
, et c'est à la fois une acquisition-charge et un magasin de publication qui interdit la réorganisation des autres opérations dans ce thread. Pour x86, cela pourrait être en mesure de compiler vers un MFENCE, au lieu d'unlock add dword [num], 0
(ienum += 0
).Comme indiqué dans PR0062 , une fusion plus agressive d'opérations atomiques non adjacentes au moment de la compilation peut être mauvaise (par exemple, un compteur de progression n'est mis à jour qu'une seule fois à la fin au lieu de chaque itération), mais cela peut également améliorer les performances sans inconvénients (par exemple, sauter le atomic inc / dec of ref compte lorsqu'une copie de a
shared_ptr
est créée et détruite, si le compilateur peut prouver qu'un autreshared_ptr
objet existe pendant toute la durée de vie du temporaire.)Même la
num++; num--
fusion pourrait nuire à l'équité d'une implémentation de verrou lorsqu'un thread se déverrouille et se reverrouille immédiatement. S'il n'est jamais réellement publié dans l'asm, même les mécanismes d'arbitrage matériel ne donneront pas à un autre thread une chance de saisir le verrou à ce stade.Avec gcc6.2 et clang3.9 actuels, vous obtenez toujours des
lock
opérations ed séparées , mêmememory_order_relaxed
dans le cas le plus évidemment optimisable. ( Explorateur de compilateur Godbolt pour que vous puissiez voir si les dernières versions sont différentes.)la source
mov eax, 1
xadd [num], eax
(sans préfixe de verrouillage) pour implémenter la post-incrémentationnum++
, mais ce n'est pas ce que font les compilateurs.... et maintenant activons les optimisations:
OK, donnons-lui une chance:
résultat:
un autre thread d'observation (même en ignorant les délais de synchronisation du cache) n'a pas la possibilité d'observer les changements individuels.
comparer aux:
où le résultat est:
Maintenant, chaque modification est: -
l'atomicité n'est pas seulement au niveau de l'instruction, elle implique tout le pipeline du processeur, en passant par les caches, à la mémoire et inversement.
Plus d'infos
Concernant l'effet des optimisations des mises à jour de
std::atomic
s.Le standard c ++ a la règle `` comme si '', par laquelle il est permis au compilateur de réorganiser le code, et même de réécrire le code à condition que le résultat ait exactement les mêmes effets observables (y compris les effets secondaires) que s'il avait simplement exécuté votre code.
La règle du «comme si» est conservatrice, en particulier en ce qui concerne l'atomique.
considérer:
Comme il n'y a pas de verrous mutex, d'atomes ou de toute autre construction qui influencent le séquençage inter-thread, je dirais que le compilateur est libre de réécrire cette fonction en tant que NOP, par exemple:
C'est parce que dans le modèle de mémoire c ++, il n'y a aucune possibilité qu'un autre thread observe le résultat de l'incrément. Ce serait bien sûr différent si
num
étaitvolatile
(pourrait influencer le comportement du matériel). Mais dans ce cas, cette fonction sera la seule fonction modifiant cette mémoire (sinon le programme est mal formé).Cependant, c'est un jeu de balle différent:
num
est un atomique. Les modifications apportées doivent être observables par les autres threads qui surveillent. Les changements que ces threads eux-mêmes font (comme définir la valeur à 100 entre l'incrémentation et la décrémentation) auront des effets très profonds sur la valeur éventuelle de num.Voici une démo:
exemple de sortie:
la source
add dword [rdi], 1
n'est pas atomique (sans lelock
préfixe). La charge est atomique et le magasin est atomique, mais rien n'empêche un autre thread de modifier les données entre la charge et le magasin. Ainsi, le magasin peut marcher sur une modification effectuée par un autre thread. Voir jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . En outre, les articles sans verrouillage de Jeff Preshing sont extrêmement bons , et il mentionne le problème de base de RMW dans cet article d'introduction.num++
etnum--
. Si vous pouvez trouver une section dans la norme qui l'exige, cela réglerait le problème. Je suis à peu près sûr que cela nécessite seulement qu'aucun observateur ne puisse jamais voir une mauvaise réorganisation, ce qui ne nécessite pas de rendement là-bas. Je pense donc que ce n'est qu'une question de qualité de mise en œuvre.Sans beaucoup de complications, une instruction comme celle-ci
add DWORD PTR [rbp-4], 1
est très semblable à celle du SCRC.Il effectue trois opérations: charger l'opérande de la mémoire, l'incrémenter, stocker l'opérande en mémoire.
Au cours de ces opérations, la CPU acquiert et libère le bus deux fois, entre n'importe quel autre agent peut également l'acquérir et cela viole l'atomicité.
X n'est incrémenté qu'une seule fois.
la source
L'instruction add n'est pas atomique. Il fait référence à la mémoire et deux cœurs de processeur peuvent avoir un cache local différent de cette mémoire.
IIRC la variante atomique de l'instruction add est appelée lock xadd
la source
lock xadd
implémente C ++ std :: atomicfetch_add
, retournant l'ancienne valeur. Si vous n'en avez pas besoin, le compilateur utilisera les instructions de destination mémoire normales avec unlock
préfixe.lock add
oulock inc
.add [mem], 1
ne serait toujours pas atomique sur une machine SMP sans cache, voir mes commentaires sur les autres réponses.Il est dangereux de tirer des conclusions basées sur un assemblage généré par "rétro-ingénierie". Par exemple, vous semblez avoir compilé votre code avec l'optimisation désactivée, sinon le compilateur aurait jeté cette variable ou chargé 1 directement dessus sans appeler
operator++
. Étant donné que l'assemblage généré peut changer de manière significative, en fonction des indicateurs d'optimisation, du processeur cible, etc., votre conclusion est basée sur le sable.De plus, votre idée qu'une instruction d'assemblage signifie qu'une opération est atomique est également erronée. Ce
add
ne sera pas atomique sur les systèmes multi-processeurs, même sur l'architecture x86.la source
Même si votre compilateur émettait toujours cela comme une opération atomique, accéder à
num
partir de n'importe quel autre thread simultanément constituerait une course aux données selon les normes C ++ 11 et C ++ 14 et le programme aurait un comportement indéfini.Mais c'est pire que cela. Premièrement, comme cela a été mentionné, l'instruction générée par le compilateur lors de l'incrémentation d'une variable peut dépendre du niveau d'optimisation. Deuxièmement, le compilateur peut réorganiser d' autres accès à la mémoire
++num
s'ilnum
n'est pas atomique, par exempleMême si nous supposons avec optimisme que
++ready
c'est "atomique", et que le compilateur génère la boucle de vérification selon les besoins (comme je l'ai dit, c'est UB et donc le compilateur est libre de le supprimer, de le remplacer par une boucle infinie, etc.), le Le compilateur peut toujours déplacer l'affectation du pointeur, ou pire encore, l'initialisation duvector
à un point après l'opération d'incrémentation, provoquant le chaos dans le nouveau thread. En pratique, je ne serais pas du tout surpris si un compilateur d'optimisation supprimaitready
complètement la variable et la boucle de vérification, car cela n'affecte pas le comportement observable sous les règles du langage (contrairement à vos espérances privées).En fait, lors de la conférence Meeting C ++ de l'année dernière, j'ai entendu deux développeurs de compilateurs dit qu'ils implémentaient très volontiers des optimisations qui font que les programmes multithreads écrits naïvement se comportent mal, tant que les règles de langage le permettent, si même une amélioration mineure des performances est constatée. dans des programmes correctement écrits.
Enfin, même si vous ne vous souciez pas de la portabilité et que votre compilateur était magiquement agréable, le processeur que vous utilisez est très probablement de type CISC superscalaire et décomposera les instructions en micro-opérations, les réorganisera et / ou les exécutera de manière spéculative, dans une mesure seulement limitée par la synchronisation des primitives telles que (sur Intel) le
LOCK
préfixe ou les barrières de mémoire, afin de maximiser les opérations par seconde.Pour résumer, les responsabilités naturelles de la programmation thread-safe sont:
Si vous souhaitez le faire à votre manière, cela peut fonctionner dans certains cas, mais sachez que la garantie est nulle et que vous serez seul responsable de tout résultat indésirable . :-)
PS: Exemple correctement écrit:
Ceci est sûr car:
ready
ne peuvent pas être optimisées selon les règles linguistiques.++ready
arrivée avant le contrôle qui voitready
comme différent de zéro, et les autres opérations ne peuvent pas être réorganisées autour de ces opérations. En effet++ready
, les vérifications sont séquentiellement cohérentes , ce qui est un autre terme décrit dans le modèle de mémoire C ++ et qui interdit cette réorganisation spécifique. Par conséquent, le compilateur ne doit pas réorganiser les instructions, et doit également indiquer au CPU qu'il ne doit pas, par exemple, reporter l'écriturevec
vers après l'incrément deready
. Séquentiellement cohérents est la garantie la plus forte concernant les atomes dans la norme de langage. Des garanties moindres (et théoriquement moins chères) sont disponibles, par exemple via d'autres méthodes destd::atomic<T>
, mais ceux-ci sont définitivement réservés aux experts et peuvent ne pas être beaucoup optimisés par les développeurs du compilateur, car ils sont rarement utilisés.la source
ready
, il compilerait probablementwhile (!ready);
en quelque chose de plus semblable àif(!ready) { while(true); }
. Vote positif: un élément clé de std :: atomic est de changer la sémantique pour assumer une modification asynchrone à tout moment. Le fait d'être UB est normalement ce qui permet aux compilateurs de hisser des charges et d'évacuer les magasins hors de boucles.Sur une machine x86 mono-cœur, une
add
instruction sera généralement atomique par rapport à un autre code sur le CPU 1 . Une interruption ne peut pas diviser une seule instruction au milieu.Une exécution dans le désordre est nécessaire pour préserver l'illusion d'instructions s'exécutant une par une dans l'ordre dans un seul cœur, de sorte que toute instruction s'exécutant sur le même processeur se produira complètement avant ou complètement après l'ajout.
Les systèmes x86 modernes sont multicœurs, donc le cas particulier des monoprocesseurs ne s'applique pas.
Si l'on cible un petit PC embarqué et n'a pas l'intention de déplacer le code vers autre chose, la nature atomique de l'instruction "add" pourrait être exploitée. D'un autre côté, les plates-formes où les opérations sont intrinsèquement atomiques se font de plus en plus rares.
(Cela ne vous aidera pas si vous écrivez en C ++, cependant. Compilateurs n'ont pas une option d'exiger
num++
de compiler un complément destination de mémoire ou xadd sans unlock
préfixe. Ils pourraient choisir de chargernum
dans un registre et un magasin le résultat de l'incrémentation avec une instruction séparée, et le fera probablement si vous utilisez le résultat.)Note de bas de page 1: Le
lock
préfixe existait même sur le 8086 d'origine car les périphériques d'E / S fonctionnent simultanément avec le processeur; les pilotes sur un système monocœur doiventlock add
incrémenter de manière atomique une valeur dans la mémoire du périphérique si le périphérique peut également la modifier, ou en ce qui concerne l'accès DMA.la source
À l'époque où les ordinateurs x86 n'avaient qu'un seul processeur, l'utilisation d'une seule instruction garantissait que les interruptions ne diviseraient pas la lecture / la modification / l'écriture et si la mémoire n'était pas également utilisée comme tampon DMA, c'était en fait atomique (et C ++ n'a pas mentionné les threads dans le standard, donc cela n'a pas été abordé).
Lorsqu'il était rare d'avoir un double processeur (par exemple, le Pentium Pro à double socket) sur le bureau d'un client, j'utilisais effectivement cela pour éviter le préfixe LOCK sur une machine monocœur et améliorer les performances.
Aujourd'hui, cela ne serait utile que contre plusieurs threads qui étaient tous définis sur la même affinité de processeur, de sorte que les threads qui vous inquiètent n'entreront en jeu que lorsque la tranche de temps expirera et exécutera l'autre thread sur le même processeur (cœur). Ce n’est pas réaliste.
Avec les processeurs x86 / x64 modernes, l'instruction unique est divisée en plusieurs micro-opérations et de plus, la lecture et l'écriture de la mémoire sont mises en mémoire tampon. Ainsi, différents threads exécutés sur différents processeurs verront non seulement cela comme non atomique, mais pourront également voir des résultats incohérents concernant ce qu'il lit dans la mémoire et ce qu'il suppose que d'autres threads ont lu à ce moment-là: vous devez ajouter des clôtures de mémoire pour restaurer sainement comportement.
la source
a = 1; b = a;
pour charger correctement le 1 que vous venez de stocker.Non. Https://www.youtube.com/watch?v=31g0YE61PLQ (C'est juste un lien vers la scène "Non" de "The Office")
Êtes-vous d'accord pour dire que ce serait une sortie possible pour le programme:
exemple de sortie:
Si tel est le cas, le compilateur est libre d'en faire la seule sortie possible pour le programme, de la manière dont le compilateur le souhaite. c'est-à-dire un main () qui ne met que des centaines.
C'est la règle du "comme si".
Et quelle que soit la sortie, vous pouvez penser à la synchronisation des threads de la même manière - si le thread A le fait
num++; num--;
et que le thread B lit ànum
plusieurs reprises, alors un entrelacement valide possible est que le thread B ne lit jamais entrenum++
etnum--
. Puisque cet entrelacement est valide, le compilateur est libre d'en faire le seul entrelacement possible. Et supprimez simplement l'incr / dimin entièrement.Il y a ici quelques implications intéressantes:
(par exemple, imaginez qu'un autre thread met à jour une interface utilisateur de barre de progression basée sur
progress
)Le compilateur peut-il transformer cela en:
c'est probablement valable. Mais probablement pas ce que le programmeur espérait :-(
Le comité travaille toujours sur ce dossier. Actuellement, cela "fonctionne" parce que les compilateurs n'optimisent pas beaucoup l'atome. Mais cela change.
Et même si elle
progress
était également volatile, cela serait toujours valable:: - /
la source
volatile
objets atomiques, quand il ne se casse pas d'autres règles. Deux documents de discussion sur les normes discutent exactement de cela (liens dans le commentaire de Richard ), l'un utilisant le même exemple de compteur de progrès. C'est donc un problème de qualité d'implémentation jusqu'à ce que C ++ normalise les moyens de l'empêcher.lock
à chaque opération. Ou une combinaison compilateur + monoprocesseur où aucun des deux ne réorganise (c'est-à-dire "le bon vieux temps") tout est atomique. Mais à quoi ça sert? Vous ne pouvez pas vraiment vous y fier. Sauf si vous savez que c'est le système pour lequel vous écrivez. (Même dans ce cas, mieux serait que atomic <int> n'ajoute aucune opération supplémentaire sur ce système. Vous devriez donc toujours écrire du code standard ...)And just remove the incr/decr entirely.
n'est pas tout à fait vrai. C'est toujours une opération d'acquisition et de libérationnum
. Sur x86,num++;num--
pourrait compiler uniquement MFENCE, mais certainement pas rien. (À moins que l'analyse du programme complet du compilateur ne puisse prouver que rien ne se synchronise avec cette modification de num, et que cela n'a pas d'importance si certains magasins d'avant qui sont retardés jusqu'à ce que les charges soient après cela.) Par exemple, s'il s'agissait d'un déverrouillage et re -lock-right-away use-case, vous avez toujours deux sections critiques distinctes (peut-être en utilisant mo_relaxed), pas une seule grande.Oui mais...
Atomic n'est pas ce que vous vouliez dire. Vous demandez probablement la mauvaise chose.
L'incrément est certainement atomique . À moins que le stockage ne soit mal aligné (et puisque vous avez laissé l'alignement sur le compilateur, ce n'est pas le cas), il est nécessairement aligné dans une seule ligne de cache. À moins d'instructions de streaming spéciales sans mise en cache, chaque écriture passe par le cache. Des lignes de cache complètes sont lues et écrites de manière atomique, jamais rien de différent.
Les données plus petites que la ligne de cache sont, bien sûr, également écrites de manière atomique (puisque la ligne de cache environnante l'est).
Est-ce thread-safe?
C'est une question différente, et il y a au moins deux bonnes raisons de répondre par un «non! .
Premièrement, il est possible qu'un autre cœur puisse avoir une copie de cette ligne de cache en L1 (L2 et les versions supérieures sont généralement partagées, mais L1 est normalement par cœur!), Et modifie simultanément cette valeur. Bien sûr, cela se produit également de manière atomique, mais maintenant vous avez deux valeurs «correctes» (correctement, atomiquement, modifiées) - laquelle est la vraiment correcte maintenant?
Le CPU le réglera d'une manière ou d'une autre, bien sûr. Mais le résultat n'est peut-être pas celui que vous attendez.
Deuxièmement, il y a l'ordre de la mémoire ou les garanties formulées différemment avant. La chose la plus importante à propos des instructions atomiques n'est pas tant qu'elles sont atomiques . C'est ordonnant.
Vous avez la possibilité d'appliquer une garantie que tout ce qui se passe en mémoire est réalisé dans un ordre garanti et bien défini où vous avez une garantie "arrivé avant". Cet ordre peut être aussi «détendu» (lire: aucun du tout) ou aussi strict que vous le souhaitez.
Par exemple, vous pouvez définir un pointeur vers un bloc de données (par exemple, les résultats de certains calculs), puis libérer de manière atomique l'indicateur «les données sont prêtes». Désormais, celui qui acquiert ce drapeau sera amené à penser que le pointeur est valide. Et en effet, ce sera toujours un pointeur valide, jamais rien de différent. C'est parce que l'écriture sur le pointeur a eu lieu avant l'opération atomique.
la source
Que la sortie d'un seul compilateur, sur une architecture de processeur spécifique, avec les optimisations désactivées (puisque gcc ne compile même
++
pasadd
lors de l'optimisation dans un exemple rapide et sale ), semble impliquer que l'incrémentation de cette manière est atomique ne signifie pas que c'est conforme à la norme ( vous provoquerait un comportement indéfini en essayant d'accédernum
à un thread), et est de toute façon erroné, car ceadd
n'est pas atomique dans x86.Notez que les atomiques (en utilisant le
lock
préfixe d'instruction) sont relativement lourds sur x86 ( voir cette réponse pertinente ), mais toujours remarquablement moins qu'un mutex, ce qui n'est pas très approprié dans ce cas d'utilisation.Les résultats suivants sont tirés de clang ++ 3.8 lors de la compilation avec
-Os
.Incrémenter un entier par référence, de manière "régulière":
Cela se compile en:
Incrémenter un int passé par référence, de manière atomique:
Cet exemple, qui n'est pas beaucoup plus complexe que la méthode normale, obtient simplement le
lock
préfixe ajouté à l'incl
instruction - mais attention, comme indiqué précédemment, ce n'est pas bon marché. Ce n'est pas parce que l'assemblage semble court qu'il est rapide.la source
Lorsque votre compilateur n'utilise qu'une seule instruction pour l'incrémentation et que votre machine est monothread, votre code est sécurisé. ^^
la source
Essayez de compiler le même code sur une machine non x86 et vous verrez rapidement des résultats d'assemblage très différents.
La raison
num++
semble être atomique parce que sur les machines x86, l'incrémentation d'un entier de 32 bits est, en fait, atomique (en supposant qu'aucune récupération de mémoire n'a lieu). Mais cela n'est ni garanti par le standard c ++, ni probablement le cas sur une machine qui n'utilise pas le jeu d'instructions x86. Donc, ce code n'est pas à l'abri des conditions de course multi-plateforme.Vous n'avez pas non plus de garantie solide que ce code est à l'abri des conditions de course, même sur une architecture x86, car x86 ne configure pas les charges et les stockages en mémoire, sauf indication contraire. Donc, si plusieurs threads ont essayé de mettre à jour cette variable simultanément, ils peuvent finir par incrémenter les valeurs mises en cache (obsolètes)
La raison, alors, que nous avons
std::atomic<int>
et ainsi de suite est que lorsque vous travaillez avec une architecture où l'atomicité des calculs de base n'est pas garantie, vous avez un mécanisme qui forcera le compilateur à générer du code atomique.la source
add
réellement garanti atomique? Je ne serais pas surpris si les incréments de registre étaient atomiques, mais ce n'est guère utile; pour rendre l'incrément de registre visible à un autre thread, il doit être en mémoire, ce qui nécessiterait des instructions supplémentaires pour le charger et le stocker, supprimant l'atomicité. Je crois comprendre que c'est pourquoi lelock
préfixe existe pour les instructions; le seul atomique utileadd
s'applique à la mémoire déréférencée et utilise lelock
préfixe pour garantir que la ligne de cache est verrouillée pendant la durée de l'opération .add
est atomique, mais j'ai précisé que cela n'implique pas que le code est sans danger pour la race, car les changements ne deviennent pas visibles globalement tout de suite.