162 votes

Est-ce que num++ peut être atomique pour 'int num' ?

En général, pour int num , num++ (o ++num ), comme une opération de lecture-modification-écriture, est non atomique . Mais je vois souvent des compilateurs, par exemple CCG et génère le code suivant ( essayez ici ):

void f()
{

  int num = 0;
  num++;
}

f():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

Depuis la ligne 5, qui correspond à num++ est une instruction, peut-on conclure que num++ est atomique dans cette affaire ?

Et si oui, cela veut-il dire que les produits ainsi générés num++ peut être utilisé dans des scénarios concurrents (multithreads) sans risque de course aux données (c'est-à-dire que nous n'avons pas besoin de le fabriquer, par exemple, std::atomic<int> et imposer les coûts associés, puisque c'est de toute façon atomique) ?

UPDATE

Remarquez que cette question est pas si l'incrémentation es atomique (ce n'est pas le cas et c'était et c'est toujours la première ligne de la question). Il s'agit de savoir si elle puede dans des scénarios particuliers, c'est-à-dire si la nature d'une seule instruction peut, dans certains cas, être exploitée pour éviter l'overhead de l'algorithme d'analyse de l'information. lock préfixe. Et, comme le mentionne la réponse acceptée dans la section sur les machines uniprocesseur, ainsi que cette réponse , la conversation dans ses commentaires et d'autres expliquent, il peut (mais pas avec C ou C++).

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++.

215voto

Peter Cordes Points 1375

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

1 votes

"Les CPU x86 modernes traitent à nouveau les opérations RMW au moins aussi efficacement. encore est plus efficace dans le cas où la valeur mise à jour sera utilisée plus tard dans la même fonction et qu'il existe un registre libre dans lequel le compilateur peut la stocker (et que la variable n'est pas marquée comme volatile, bien sûr). Cela signifie qu'il est très probablement que le fait que le compilateur génère une seule instruction ou plusieurs pour l'opération dépend du reste du code de la fonction, et pas seulement de la ligne en question.

0 votes

@PeriataBreatta : oui, bon point. En asm, on pourrait utiliser mov eax, 1 xadd [num], eax (sans préfixe de verrouillage) pour mettre en œuvre l'incrémentation a posteriori num++ mais ce n'est pas ce que font les compilateurs.

0 votes

@PeterCordes, où vous écrivez "...et non pas que cela se soit produit physiquement / électriquement simultanément" Vous vouliez dire "... instantanément" ?

42voto

Margaret Bloom Points 3177

Sans beaucoup de complications, une instruction comme add DWORD PTR [rbp-4], 1 est un style très CISC.

Il effectue trois opérations : charger l'opérande depuis la mémoire, l'incrémenter, le stocker à nouveau en mémoire.
Pendant ces opérations, l'unité centrale acquiert et libère le bus deux fois, entre-temps tout autre agent peut l'acquérir aussi, ce qui viole l'atomicité.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X n'est incrémenté qu'une seule fois.

7 votes

Pour que ce soit le cas, il faudrait que chaque puce mémoire ait sa propre unité arithmétique et logique (UAL). Il faudrait, en effet, que chaque puce mémoire était un processeur.

7 votes

@LeoHeinsaar : les instructions de destination de la mémoire sont des opérations de lecture-modification-écriture. Aucun registre architectural n'est modifié, mais le CPU doit conserver les données en interne pendant qu'il les envoie dans son UAL. Le fichier de registre réel n'est qu'une petite partie du stockage de données à l'intérieur même du CPU le plus simple, avec les latches qui retiennent les sorties d'un étage comme entrées pour un autre étage, etc. etc.

0 votes

PeterCordes Votre commentaire est exactement la réponse que je cherchais. La réponse de Margaret m'a fait soupçonner que quelque chose comme ça doit se passer à l'intérieur.

41voto

Richard Hodges Points 1972

...et maintenant activons les optimisations :

f():
        rep ret

OK, donnons-lui une chance :

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

résultat :

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

un autre thread observateur (même en ignorant les délais de synchronisation du cache) n'a pas la possibilité d'observer les changements individuels.

comparer à :

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

où le résultat est :

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Maintenant, chaque modification est:-

  1. observable dans un autre fil, et
  2. respectueux de modifications similaires dans d'autres fils de discussion.

L'atomicité ne se limite pas au niveau des instructions, elle concerne l'ensemble du pipeline, du processeur à la mémoire en passant par les caches.

Plus d'informations

En ce qui concerne l'effet des optimisations des mises à jour de std::atomic s.

La norme c++ prévoit la règle du "comme si", selon laquelle il est permis au compilateur de réorganiser le code, voire de le réécrire, à condition que le résultat ait la forme suivante exactement la même observable (y compris les effets secondaires) comme s'il avait simplement exécuté votre code.

La règle "as-if" est conservatrice, en particulier lorsqu'elle concerne des atomes.

considérer :

void incdec(int& num) {
    ++num;
    --num;
}

Parce qu'il n'y a pas de verrous mutex, d'atomics ou d'autres constructions qui influencent le séquençage inter-thread, je dirais que le compilateur est libre de réécrire cette fonction comme un NOP, par exemple :

void incdec(int&) {
    // nada
}

En effet, 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 était volatile (peut influencer le comportement du matériel). Mais dans ce cas, cette fonction sera la seule à modifier cette mémoire (sinon le programme est mal formé).

Cependant, c'est une autre paire de manches :

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

num est un atome. Modifications apportées debe être observable pour les autres fils qui regardent. Les modifications apportées par ces threads eux-mêmes (comme le fait de fixer la valeur à 100 entre l'incrémentation et la décrémentation) auront des effets très importants sur la valeur finale de num.

Voici une démo :

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });

        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

sortie de l'échantillon :

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

0 votes

Nous devons avoir le même problème. J'ai appuyé sur le vote positif plusieurs fois, mais il ne montre que cela s'est produit une fois :(. Excellente réponse et c'est bien de montrer comment tout peut s'écrouler sur vous.

5 votes

Cela n'explique pas que add dword [rdi], 1 es pas atomique (sans le lock préfixe). Le chargement est atomique, et le stockage est atomique, mais rien n'empêche un autre thread de modifier les données entre le chargement et le stockage. Le store peut donc s'appuyer sur une modification faite par un autre thread. Voir jfdube.wordpress.com/2011/11/30/compréhension des opérations atomiques . Aussi, Les articles de Jeff Preshing sur l'absence de verrou sont très bons. et il mentionne le problème de base de RMW dans cet article d'introduction.

0 votes

Je comprends les problèmes d'observabilité. J'étais juste curieux de connaître un cas possible très simple (et en supposant qu'aucune optimisation n'a été faite - l'exemple était évidemment optimisable juste pour rester simple). En outre, par atomicité Je voulais dire que le CPU ne peut pas être préempté avant que l'opération ne prenne effet.

10voto

Sven Nilsson Points 1263

L'instruction d'addition est pas atomique. Il fait référence à la mémoire, et deux cœurs de processeur peuvent avoir des caches locaux différents de cette mémoire.

IIRC la variante atomique de l'instruction d'addition est appelée verrouiller xadd

3 votes

lock xadd implémente C++ std::atomique fetch_add en retournant l'ancienne valeur. Si vous n'avez pas besoin de cela, le compilateur utilisera les instructions normales de destination de la mémoire avec une balise lock préfixe. lock add o lock inc .

1 votes

add [mem], 1 ne serait toujours pas atomique sur une machine SMP sans cache, voir mes commentaires sur les autres réponses.

0 votes

Voir ma réponse pour plus de détails sur le fait que ce n'est pas atomique. Aussi la fin de ma réponse sur cette question connexe .

10voto

Slava Points 4119

Puisque la ligne 5, qui correspond à num++ est une instruction, peut-on conclure que num++ est atomique dans ce cas ?

Il est dangereux de tirer des conclusions sur la base d'un assemblage généré par "ingénierie inverse". 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 dans celle-ci sans invoquer operator++ . Comme l'assemblage généré peut changer de manière significative, en fonction des drapeaux d'optimisation, du CPU cible, etc., votre conclusion est basée sur le sable.

De même, votre idée selon laquelle une instruction d'assemblage signifie qu'une opération est atomique est également fausse. Voici add ne sera pas atomique sur les systèmes multi-CPU, même sur l'architecture x86.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X