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 avec memory_order_relaxed
si vous ne vous souciez pas de la réorganisation. Voir ci-dessous pour un exemple de code et de sortie asm utilisant fetch_add
.
Mais d'abord, le langage d'assemblage fait partie de la question:
Puisque num ++ est une instruction ( add dword [num], 1
), pouvons-nous conclure que num ++ est atomique dans ce cas?
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 simple dec 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:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
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 se num++
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 votrenum++
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:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
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 pas flag
). Ensuite, il peut optimiser complètement la modification, car ce flag
n'est même pas volatile
. (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, ce volatile std::atomic<int> foo
n'est pas le comme std::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 x86lock
est une barrière de mémoire pleine, donc utiliser num.fetch_add(1, std::memory_order_relaxed);
génère le même code sur x86 que num++
(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.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
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 seule num--;
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 un load()
(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 un shared_ptr_unsynchronized<T>
pour gcc).
Pour en revenir à la num++; num-=2;
compilation comme si elle num--
: Compilateurs sont autorisés à le faire, à moins num
est volatile 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 (le num++
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 de lock 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 regardent num
, 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'un lock add dword [num], 0
(ie num += 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 autre shared_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ême memory_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.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
c'était atomique?