C'est absolument ce que le C++ définit comme une course aux données qui provoque un comportement indéfini, même si un compilateur a produit un code qui a fait ce que vous espériez sur une machine cible. Vous devez utiliser std::atomic
pour des résultats fiables, mais vous pouvez l'utiliser avec memory_order_relaxed
si vous ne vous souciez pas du réordonnancement. Voir ci-dessous pour un exemple de code et de sortie asm utilisant fetch_add
.
Mais d'abord, la partie de la question concernant le langage d'assemblage :
Puisque num++ est une instruction ( add dword [num], 1
), peut-on conclure que num++ est atomique dans ce cas ?
Les instructions de destination mémoire (autres que les stockages purs) sont des opérations de lecture-modification-écriture qui se déroulent en plusieurs étapes internes . Aucun registre d'architecture n'est modifié, mais l'unité centrale doit conserver les données en interne pendant qu'elle les envoie à travers son ALU . Le fichier de registre proprement dit n'est qu'une petite partie du stockage de données à l'intérieur d'un processeur, même le plus simple, avec les verrous qui maintiennent les sorties d'un étage comme entrées pour un autre étage, etc. etc.
Les opérations de mémoire des autres CPU peuvent devenir globalement visibles entre le chargement et le stockage. C'est-à-dire que deux threads exécutant add dword [num], 1
dans une boucle se marcherait sur les pieds de l'autre. (Voir Réponse de @Margaret pour un beau diagramme). Après 40k incréments de chacun des deux threads, le compteur pourrait n'avoir augmenté que de ~60k (et non 80k) sur du matériel x86 multi-core réel.
"Atomique", du mot grec signifiant indivisible, signifie qu'aucun observateur ne peut voir l'opération en tant qu'étapes distinctes. L'instantanéité physique/électrique de tous les bits simultanément n'est qu'un moyen d'y parvenir pour un chargement ou un stockage, mais ce n'est même pas possible pour une opération ALU. J'ai donné beaucoup plus de détails sur les charges pures et les magasins purs dans ma réponse à la question suivante Atomicité sur x86 alors que cette réponse se concentre sur la lecture-modification-écriture.
El lock
préfixe peut être appliqué à de nombreuses instructions de lecture-modification-écriture (destination mémoire) pour rendre l'opération entière atomique par rapport à tous les observateurs possibles du système (autres cœurs et dispositifs DMA, pas un oscilloscope branché sur les broches du CPU). C'est pour cela qu'il existe. (Voir aussi cette Q&R ).
Alors lock add dword [num], 1
es atomique . Un cœur de processeur exécutant cette instruction conserverait la ligne de cache dans l'état Modifié dans son cache L1 privé à partir du moment où le chargement lit les données dans le cache jusqu'à ce que le stockage valide son résultat dans le cache. Cela empêche tout autre cache du système d'avoir une copie de la ligne de cache à n'importe quel moment entre le chargement et le stockage, selon les règles de l'algorithme de l'utilisateur. Protocole de cohérence du cache MESI (ou ses versions MOESI/MESIF utilisées par les CPU multi-cœurs AMD/Intel, respectivement). Ainsi, les opérations effectuées par les autres cœurs semblent se produire soit avant, soit après, et non pendant.
Sans le lock
préfixe, un autre noyau pourrait s'approprier la ligne de cache et la modifier après notre chargement mais avant notre magasin, de sorte que l'autre magasin deviendrait globalement visible entre notre chargement et notre magasin. Plusieurs autres réponses se trompent à ce sujet et prétendent que sans lock
vous auriez 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 un lock
Lorsque l'instruction ed opère 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 puisse voir de déchirement. L'unité centrale peut être amenée à verrouiller l'ensemble du bus mémoire jusqu'à ce que les données arrivent en mémoire. Ne désalignez pas vos variables atomiques).
Notez que le lock
Le préfixe transforme également une instruction en une barrière de mémoire complète (comme le préfixe MFENCE ), arrêtant tout réordonnancement en cours d'exécution et donnant ainsi une cohérence séquentielle. (Voir L'excellent billet de blog de Jeff Preshing . Ses autres articles sont tous excellents, également, et expliquent clairement un certain nombre de choses. lote de bonnes choses sur programmation sans verrouillage des détails relatifs aux x86 et autres matériels aux règles du C++).
Sur une machine uniprocesseur, ou dans un processus monofilaire un seul RMW l'enseignement est en fait es atomique sans lock
préfixe. Le seul moyen pour un autre code d'accéder à la variable partagée est que le CPU effectue un changement de contexte, ce qui ne peut pas se produire au milieu d'une instruction. Donc un simple dec dword [num]
peut se synchroniser entre un programme monofil et ses gestionnaires de signaux, ou dans un programme multi-fil s'exécutant sur une machine à un seul cœur. Voir la deuxième moitié de ma réponse à une autre question et les commentaires qui suivent, où j'explique cela plus en détail.
Retour au C++ :
C'est totalement faux d'utiliser num++
sans dire au compilateur que vous avez besoin qu'il compile vers une seule implémentation de 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
plus tard : le compilateur le gardera en direct dans un registre après l'incrément. Donc même si vous vérifiez comment num++
compile par lui-même, la modification du code environnant peut l'affecter.
(Si la valeur n'est pas nécessaire plus tard, inc dword [num]
est préférable ; les CPU x86 modernes exécuteront une instruction RMW de destination mémoire au moins aussi efficacement qu'en utilisant trois instructions séparées. Fait amusant : [gcc -O3 -m32 -mtune=i586
émettra effectivement ce](http://gcc.godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(j:1,options:(compileOnChange:%270%27),source:%27int+a%3B%0Avoid+func()%7B+%2B%2Ba%3B+%7D%27),l:%275%27,n:%271%27,o:%27C%2B%2B+source+%231%27,t:%270%27)),k:50,l:%274%27,m:100,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((g:!((h:compiler,i:(compiler:g62,filters:(b:%270%27,commentOnly:%270%27,directives:%270%27,intel:%270%27),options:%27-xc+-Wall+-Wextra+-O3+-m32+-mtune%3Di586+-fomit-frame-pointer+-fverbose-asm%27),l:%275%27,n:%270%27,o:%27%231+with+x86-64+gcc+6.2%27,t:%270%27)),k:50,l:%274%27,m:84.06862745098039,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((h:output,i:(compiler:1,editor:1),l:%275%27,n:%270%27,o:%27%231+with+x86-64+gcc+6.2%27,t:%270%27)),l:%274%27,m:15.931372549019606,n:%270%27,o:%27%27,s:0,t:%270%27)),k:50,l:%273%27,n:%270%27,o:%27%27,t:%270%27)),l:%272%27,n:%270%27,o:%27%27,t:%270%27)),version:4) En effet, le pipeline superscalaire du P5 (Pentium) ne décode pas les instructions complexes en de multiples micro-opérations simples comme le font les microarchitectures P6 et ultérieures. Voir le Tables d'instructions d'Agner Fog / guide de microarchitecture pour plus d'informations, et le x86 tag wiki pour de nombreux liens utiles (y compris les manuels x86 ISA d'Intel, qui sont disponibles gratuitement en PDF)).
Ne pas confondre le modèle de mémoire cible (x86) avec le modèle de mémoire C++.
Réorganisation au moment de la compilation est autorisé . L'autre partie de ce que vous obtenez avec std::atomic est le contrôle sur le réordonnancement au moment de la compilation, pour s'assurer que votre num++
ne devient globalement visible qu'après une autre opération.
Exemple classique : Stocker des données dans un tampon pour qu'un autre thread puisse les consulter, puis activer un drapeau. Même si x86 acquiert gratuitement des magasins de chargements/libérations, vous devez toujours indiquer au compilateur de ne pas réordonner en utilisant la commande flag.store(1, std::memory_order_release);
.
Vous vous attendez peut-être à ce que ce code se synchronise avec d'autres threads :
// int flag; is just a plain global, not std::atomic<int>.
flag--; // Pretend this is supposed to be some kind of locking attempt
modify_a_data_structure(&foo); // doesn't look at flag, and the compiler knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Mais ça n'arrivera pas. Le compilateur est libre de déplacer le flag++
à travers l'appel de fonction (s'il inline la fonction ou sait qu'il ne regarde pas à flag
). Ensuite, il peut optimiser la modification entièrement, parce que flag
n'est même pas volatile
.
(Et non, C++ volatile
n'est pas un substitut utile de std::atomic. std::atomic fait supposer au compilateur que les valeurs en mémoire peuvent être modifiées de manière asynchrone, comme dans le cas de volatile
mais il y a beaucoup plus que cela. (En pratique, il y a similarités entre volatile int et std::atomic avec mo_relaxed pour les opérations de charge pure et de magasin pur, mais pas pour les RMW). Également, volatile std::atomic<int> foo
n'est pas nécessairement la même chose que std::atomic<int> foo
Bien que les compilateurs actuels n'optimisent pas les atomiques (par exemple, 2 stockages dos à dos de la même valeur), l'atomique volatile ne changerait pas la génération de code).
Définir les courses aux données sur les variables non-atomiques comme un comportement indéfini est ce qui permet au compilateur de continuer à hisser les charges et les magasins hors des boucles, et de nombreuses autres optimisations pour la mémoire à laquelle plusieurs threads peuvent avoir une référence. (Voir ce blog LLVM pour en savoir plus sur la façon dont UB permet les optimisations du compilateur).
Comme je l'ai mentionné, le x86 lock
préfixe est une barrière de mémoire complète, donc l'utilisation de num.fetch_add(1, std::memory_order_relaxed);
génère le même code sur x86 que num++
(le 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, relaxed permet plus de réordonnancement à la compilation.
C'est ce que GCC fait en réalité sur x86, pour quelques fonctions qui opèrent sur un fichier std::atomic
variable globale.
Voir le code source + le code en langage assembleur joliment mis en forme sur le site de la Commission européenne. Explorateur de compilateur Godbolt,filterAsm:(commentOnly:!t,directives:!t,intel:!t,labels:!t),version:3) . Vous pouvez sélectionner d'autres architectures cibles, notamment ARM, MIPS et PowerPC, pour voir quel type de code en langage assembleur 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 à consistance séquentielle. x86 est fortement ordonné en général, mais le réordonnancement StoreLoad est autorisé. Avoir un tampon de stockage est essentiel pour de bonnes performances sur un processeur hors-ordre pipeliné. L'article de Jeff Preshing La réorganisation de la mémoire prise en flagrant délit montre les conséquences de pas en utilisant MFENCE, avec du code réel pour montrer que le réordonnancement se produit sur du matériel réel.
Re : discussion dans les commentaires sur la réponse de @Richard Hodges à propos de compilateurs fusionnant std::atomic num++; num-=2;
en une seule opération num--;
instruction :
Une autre question-réponse sur ce même sujet : Pourquoi les compilateurs ne fusionnent-ils pas les écritures std::atomiques 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 ? discute de l'attente de nombreux programmeurs qui pensent que les compilateurs ne feront 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, dont celui-ci. Il souligne que l'inlining et la propagation constante peuvent introduire des choses telles que fetch_or(0)
qui pourrait se transformer en un simple load()
(mais a toujours la sémantique d'acquisition et de libération), même si la source originale n'avait pas d'opérations atomiques manifestement redondantes.
Les véritables raisons pour lesquelles les compilateurs ne le font pas (encore) sont les suivantes : (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 de l'égalité des chances. le principe de la moindre surprise . Le code sans verrou est déjà assez difficile à écrire 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, puisqu'il n'existe pas de version non-atomique de cette dernière (bien que une des réponses ici offre un moyen simple de définir un shared_ptr_unsynchronized<T>
pour gcc).
Pour en revenir à num++; num-=2;
compilant comme si c'était num--
: Compilateurs sont autorisés pour ce faire, à moins que num
es volatile std::atomic<int>
. Si un réordonnancement est possible, la règle as-if permet au compilateur de décider au moment de la compilation qu'il siempre se passe comme ça. Rien ne garantit qu'un observateur puisse voir les valeurs intermédiaires (les num++
résultat).
C'est-à-dire que si l'ordonnancement où rien ne devient globalement visible entre ces opérations est compatible avec les exigences d'ordonnancement de la source (selon les règles C++ pour la machine abstraite, et non pour l'architecture cible), le compilateur peut émettre un seul fichier 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
Il s'agit à la fois d'une acquisition-chargement et d'une libération-magasin, ce qui empêche la réorganisation d'autres opérations dans ce fil. Pour x86, ceci pourrait être compilé en un MFENCE, au lieu d'un lock add dword [num], 0
(c'est-à-dire num += 0
).
Comme indiqué dans PR0062 la 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 elle peut également améliorer les performances sans inconvénient (par exemple, sauter l'incrémentation/décrémentation atomique des comptes de réf. lorsqu'une copie d'un compteur de réf. est mise à jour). shared_ptr
est créée et détruite, si le compilateur peut prouver qu'une autre shared_ptr
l'objet existe pendant toute la durée de vie du temporaire).
Même num++; num--
La fusion pourrait nuire à l'équité de l'implémentation d'un verrou lorsqu'un thread le déverrouille et le reverrouille immédiatement. S'il n'est jamais réellement libéré dans l'asm, même les mécanismes d'arbitrage matériel ne donneront pas à un autre thread une chance de prendre le verrou à ce moment-là.
Avec les versions actuelles de gcc6.2 et clang3.9, vous obtenez toujours des versions séparées de lock
même avec memory_order_relaxed
dans le cas le plus évident d'optimisation. ( Explorateur de compilateur Godbolt,l:%275%27,n:%271%27,o:%27C%2B%2B+source+%231%27,t:%270%27)),k:50,l:%274%27,m:100,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((g:!((h:compiler,i:(compiler:g62,filters:(b:%270%27,commentOnly:%270%27,directives:%270%27,intel:%270%27),options:%27-std%3Dgnu%2B%2B11+-Wall+-Wextra+-O3++-fverbose-asm%27),l:%275%27,n:%270%27,o:%27%231+with+x86-64+gcc+6.2%27,t:%270%27)),k:50,l:%274%27,m:84.06862745098039,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((h:output,i:(compiler:1,editor:1),l:%275%27,n:%270%27,o:%27%231+with+x86-64+gcc+6.2%27,t:%270%27)),l:%274%27,m:15.931372549019606,n:%270%27,o:%27%27,s:0,t:%270%27)),k:50,l:%273%27,n:%270%27,o:%27%27,t:%270%27)),l:%272%27,n:%270%27,o:%27%27,t:%270%27)),version:4) afin 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
68 votes
Qui vous a dit que
add
est atomique ?6 votes
Étant donné que l'une des caractéristiques de l'atomique est la prévention de types spécifiques de réordonnancement pendant l'optimisation, non, indépendamment de l'atomicité de l'opération réelle
1 votes
Il se peut que votre compilateur voit que num++ peut être optimisé à cela parce que vous n'utilisez pas la valeur de retour de num++.
2 votes
Il doit encore charger num, l'augmenter puis le réécrire en mémoire
20 votes
Je tiens également à souligner que si ce qui est atomique sur votre plateforme, il n'y a aucune garantie qu'il le sera sur une autre pltaforme. Soyez indépendant de la plateforme et exprimez votre intention en utilisant un fichier de type
std::atomic<int>
.9 votes
Pendant l'exécution de cette
add
un autre noyau pourrait voler cette adresse mémoire dans le cache de ce noyau et la modifier. Sur un processeur x86, leadd
l'enseignement a besoin d'unelock
préfixe si l'adresse doit être verrouillée dans le cache pendant la durée de l'opération.21 votes
Il est possible pour cualquier pour que l'opération soit "atomique". Il suffit d'avoir de la chance et de ne jamais exécuter quoi que ce soit qui puisse révéler qu'elle n'est pas atomique. Atomique n'a de valeur que comme garantie . Étant donné que vous regardez le code d'assemblage, la question est de savoir si cette architecture particulière vous fournit la garantie y si le compilateur fournit une garantie que c'est l'implémentation au niveau de l'assemblage qu'ils choisissent.
2 votes
Dans ce cas, votre fonction telle qu'elle est est parfaitement sûre, car les autres threads n'ont aucun moyen d'accéder à la pile de données
num
variable. Mais ce n'est probablement pas ce que vous demandez. (Il serait peut-être préférable que votrenum
étaient une variable globale).0 votes
Donner l'impression de fonctionner est une réponse valable à l'invocation d'un comportement indéfini.
0 votes
@Lmn : Les questions de SO devraient utiliser des blocs de code pour le code, pas des images de texte. La compacité de l'image était agréable, mais il y a suffisamment de raisons de ne pas utiliser d'images pour que vous laissiez la modification de Joseph s'appliquer. (Les utilisateurs aveugles avec des lecteurs d'écran, les pare-feu d'entreprise qui bloquent les images, etc.) Dans d'autres cas, la possibilité pour les moteurs de recherche de rechercher le code est pertinente ; ce n'est pas le cas ici mais les autres raisons sont suffisantes.