Num ++ peut-il être atomique pour 'int num'?

153

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 ):

Entrez la description de l'image 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 lockpré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 ++).

Leo Heinsaar
la source
65
Qui vous a dit que addc'était atomique?
Slava
6
étant donné que l'une des caractéristiques de l'atomique est la prévention de types spécifiques de réorganisation pendant l'optimisation, non, quelle que soit l'atomicité de l'opération réelle
jaggedSpire
19
Je tiens également à souligner que si cela est atomique sur votre plate-forme, il n'y a aucune garantie que ce sera sur une autre plate-forme. Soyez indépendant de la plateforme et exprimez votre intention en utilisant un std::atomic<int>.
NathanOliver
8
Pendant l'exécution de cette addinstruction, un autre cœur pourrait voler cette adresse mémoire du cache de ce cœur et la modifier. Sur un processeur x86, l' addinstruction a besoin d'un lockpréfixe si l'adresse doit être verrouillée dans le cache pendant la durée de l'opération.
David Schwartz
21
Il est possible que n'importe quelle opération soit "atomique". Tout ce que vous avez à faire est d'avoir de la chance et de ne jamais exécuter quoi que ce soit qui révèle que ce n'est pas atomique. Atomic n'a de valeur qu'à titre de garantie . Étant donné que vous examinez le code d'assembly, la question est de savoir si cette architecture particulière vous fournit la garantie et si le compilateur fournit une garantie que c'est l'implémentation au niveau de l'assembly qu'ils choisissent.
Cort Ammon

Réponses:

197

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::atomicpour obtenir des résultats fiables, mais vous pouvez l'utiliser avec memory_order_relaxedsi 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], 1en 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 lockpré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 lockpré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, lockvous 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 lockinstruction 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 lockpré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 lockpré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 numlater: 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 le 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 flagn'est même pas volatile. (Et non, C ++ volatilen'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> foon'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::atomicvariable 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 numest 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_ptrest créée et détruite, si le compilateur peut prouver qu'un autre shared_ptrobjet 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 lockopérations ed séparées , même memory_order_relaxeddans 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
Peter Cordes
la source
1
"[l'utilisation d'instructions séparées] était plus efficace ... mais les processeurs x86 modernes gèrent à nouveau les opérations RMW au moins aussi efficacement" - c'est toujours plus efficace dans le cas où la valeur mise à jour sera utilisée plus tard dans la même fonction et il y a un registre gratuit disponible pour le compilateur pour le stocker (et la variable n'est pas marquée comme volatile, bien sûr). Cela signifie qu'il est très probable 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 seule ligne en question.
Periata Breatta
@PeriataBreatta: oui, bon point. Dans asm, vous pouvez utiliser mov eax, 1 xadd [num], eax(sans préfixe de verrouillage) pour implémenter la post-incrémentation num++, mais ce n'est pas ce que font les compilateurs.
Peter Cordes
3
@ DavidC.Rankin: Si vous souhaitez apporter des modifications, n'hésitez pas. Je ne veux pas faire cette CW, cependant. C'est toujours mon travail (et mon désordre: P). Je vais en ranger quelques-uns après mon jeu Ultimate [frisbee] :)
Peter Cordes
1
Si ce n'est pas le wiki de la communauté, alors peut-être un lien sur le wiki de balise approprié. (les balises x86 et atomique?). Cela vaut la peine de créer un lien supplémentaire plutôt qu'un retour plein d'espoir par une recherche générique sur SO (si je savais mieux où cela devrait se situer à cet égard, je le ferais. Je vais devoir approfondir les choses à faire et à ne pas faire dans le tag lien wiki)
David C. Rankin
1
Comme toujours - excellente réponse! Bonne distinction entre cohérence et atomicité (là où certains se sont trompés)
Leeor
39

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

#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 thread, et
  2. respectueux des modifications similaires qui se produisent dans d'autres threads.

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::atomics.

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:

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

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:

void incdec(int&) {
    // nada
}

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était volatile(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:

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

numest 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:

#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;
    }
}

exemple de sortie:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99
Richard Hodges
la source
5
Cela n'explique pas que ce add dword [rdi], 1n'est pas atomique (sans le lockpré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.
Peter Cordes
3
Ce qui se passe vraiment ici, c'est que personne n'a implémenté cette optimisation dans gcc, car elle serait presque inutile et probablement plus dangereuse qu'utile. (Principe de moindre surprise. Peut - être que quelqu'un s'attend à un état temporaire soit visible parfois, et sont ok avec le probabilty statistique. Ou bien ils sont en utilisant des points de montre-matériel pour interrompre sur la modification.) Le code sans verrouillage doit être soigneusement conçu, il n'y aura donc rien à optimiser. Il peut être utile de le chercher et d'imprimer un avertissement, pour alerter le codeur que son code pourrait ne pas signifier ce qu'il pense!
Peter Cordes
2
C'est peut-être une raison pour les compilateurs de ne pas l'implémenter (principe de moindre surprise, etc.). Observer cela serait possible en pratique sur du matériel réel. Cependant, les règles de classement de la mémoire C ++ ne disent rien sur la garantie que les charges d'un thread se mélangent "uniformément" avec les opérations d'autres threads dans la machine abstraite C ++. Je pense toujours que ce serait légal, mais hostile aux programmeurs.
Peter Cordes
2
Expérience de réflexion: considérez une implémentation C ++ sur un système multitâche coopératif. Il implémente std :: thread en insérant des points de rendement là où c'est nécessaire pour éviter les blocages, mais pas entre chaque instruction. Je suppose que vous diriez que quelque chose dans la norme C ++ nécessite un seuil entre num++et num--. 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.
Peter Cordes
5
Par souci de finalité, j'ai demandé sur la liste de diffusion de discussion std. Cette question a fait apparaître 2 articles qui semblent à la fois concorder avec Peter et répondre aux préoccupations que j'ai à propos de telles optimisations: wg21.link/p0062 et wg21.link/n4455 Merci à Andy qui les a portés à mon attention.
Richard Hodges
38

Sans beaucoup de complications, une instruction comme celle-ci add DWORD PTR [rbp-4], 1est 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é.

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.

Margaret Bloom
la source
7
@LeoHeinsaar Pour que cela soit le cas, chaque puce de mémoire aurait besoin de sa propre unité logique arithmétique (ALU). Il faudrait, en effet, que chaque puce mémoire soit un processeur.
Richard Hodges
6
@LeoHeinsaar: les instructions de destination 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 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.
Peter Cordes
@PeterCordes Votre commentaire est exactement la réponse que je recherchais. La réponse de Margaret m'a fait soupçonner que quelque chose comme ça devait se passer à l'intérieur.
Leo Heinsaar
J'ai transformé ce commentaire en une réponse complète, y compris en abordant la partie C ++ de la question.
Peter Cordes
1
@PeterCordes Merci, très détaillé et sur tous les points. C'était évidemment une course aux données et donc un comportement non défini par le standard C ++, j'étais juste curieux de savoir si dans les cas où le code généré était ce que j'ai posté, on pouvait supposer que cela pourrait être atomique, etc. Je viens également de vérifier qu'au moins le développeur Intel les manuels définissent très clairement l' atomicité par rapport aux opérations de mémoire et non l'indivisibilité des instructions, comme je l'ai supposé: "Les opérations verrouillées sont atomiques par rapport à toutes les autres opérations de mémoire et à tous les événements visibles de l'extérieur."
Leo Heinsaar
11

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

Sven Nilsson
la source
3
lock xaddimplémente C ++ std :: atomic fetch_add, retournant l'ancienne valeur. Si vous n'en avez pas besoin, le compilateur utilisera les instructions de destination mémoire normales avec un lockpréfixe. lock addou lock inc.
Peter Cordes
1
add [mem], 1ne serait toujours pas atomique sur une machine SMP sans cache, voir mes commentaires sur les autres réponses.
Peter Cordes
Voir ma réponse pour beaucoup plus de détails sur la façon dont ce n'est pas atomique. Aussi la fin de ma réponse sur cette question connexe .
Peter Cordes
10

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 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 appeleroperator++ . É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 addne sera pas atomique sur les systèmes multi-processeurs, même sur l'architecture x86.

Slava
la source
9

Même si votre compilateur émettait toujours cela comme une opération atomique, accéder à numpartir 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 ++nums'il numn'est pas atomique, par exemple

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Même si nous supposons avec optimisme que ++readyc'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 du vectorà 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 supprimait readycomplè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 LOCKpré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:

  1. Votre devoir est d'écrire du code qui a un comportement bien défini selon les règles du langage (et en particulier le modèle de mémoire standard du langage).
  2. Le devoir de votre compilateur est de générer du code machine qui a le même comportement bien défini (observable) sous le modèle de mémoire de l'architecture cible.
  3. Le devoir de votre CPU est d'exécuter ce code afin que le comportement observé soit compatible avec le modèle de mémoire de sa propre architecture.

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:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Ceci est sûr car:

  1. Les contrôles de ready ne peuvent pas être optimisées selon les règles linguistiques.
  2. L' ++ready arrivée avant le contrôle qui voit readycomme 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'écriture vecvers après l'incrément de ready. 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.
Arne Vogel
la source
1
Si le compilateur ne pouvait pas voir toutes les utilisations de ready, il compilerait probablement while (!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.
Peter Cordes
9

Sur une machine x86 mono-cœur, une addinstruction 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 un lockpréfixe. Ils pourraient choisir de charger numdans 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 lockpré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 doivent lock addincré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.

supercat
la source
Ce n'est même pas généralement atomique: un autre thread peut mettre à jour la même variable en même temps et une seule mise à jour est prise en charge.
fuz le
1
Considérez un système multicœur. Bien sûr, à l'intérieur d'un noyau, l'instruction est atomique, mais ce n'est pas atomique par rapport à l'ensemble du système.
fuz le
1
@FUZxxl: Quels étaient les quatrième et cinquième mots de ma réponse?
supercat du
1
@supercat Votre réponse est très trompeuse car elle ne considère que le cas rare de nos jours d'un seul cœur et donne à OP un faux sentiment de sécurité. C'est pourquoi j'ai commenté de considérer également le cas multicœur.
fuz le
1
@FUZxxl: J'ai fait une modification pour dissiper la confusion potentielle pour les lecteurs qui n'ont pas remarqué qu'il ne s'agissait pas de processeurs multicœurs modernes normaux. (Et soyez également plus précis sur certaines choses dont le supercat n'était pas sûr). BTW, tout dans cette réponse est déjà dans le mien, sauf la dernière phrase sur la façon dont les plates-formes où la lecture-modification-écriture est atomique "gratuitement" sont rares.
Peter Cordes
7

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

JDługosz
la source
1
Interruptions font encore les opérations RMW fractionne pas , donc ils ne synchronisent toujours un seul thread avec les gestionnaires de signaux qui fonctionnent dans le même fil. Bien sûr, cela ne fonctionne que si l'asm utilise une seule instruction, pas séparer charger / modifier / stocker. C ++ 11 pourrait exposer cette fonctionnalité matérielle, mais ce n'est pas le cas (probablement parce qu'il n'était vraiment utile que dans les noyaux Uniprocessor de se synchroniser avec les gestionnaires d'interruption, pas dans l'espace utilisateur avec les gestionnaires de signaux). De plus, les architectures n'ont pas d'instructions de destination de mémoire en lecture-modification-écriture. Pourtant, il pourrait simplement compiler comme un RMW atomique détendu sur un non-x86
Peter Cordes
Cependant, si je me souviens bien, l'utilisation du préfixe Lock n'était pas absurdement chère jusqu'à ce que les superscalers arrivent. Il n'y avait donc aucune raison de le remarquer comme ralentissant le code important d'un 486, même si ce n'était pas nécessaire pour ce programme.
JDługosz
Oui désolé! Je n'ai pas lu attentivement. J'ai vu le début du paragraphe avec le hareng rouge sur le décodage en uops, et je n'ai pas fini de lire pour voir ce que vous avez réellement dit. re: 486: Je pense avoir lu que le premier SMP était une sorte de Compaq 386, mais sa sémantique d'ordre mémoire n'était pas la même que ce que dit actuellement l'ISA x86. Les manuels x86 actuels peuvent même mentionner SMP 486. Ils n'étaient certainement pas courants même dans HPC (clusters Beowulf) jusqu'aux jours PPro / Athlon XP, cependant, je pense.
Peter Cordes
1
@PeterCordes Ok. Bien sûr, en supposant également qu'aucun observateur DMA / appareil ne rentre dans la zone de commentaire pour inclure celui-ci également. Merci JDługosz pour son excellent ajout (réponse ainsi que commentaires). Vraiment terminé la discussion.
Leo Heinsaar le
3
@Leo: Un point clé qui n'a pas été mentionné: les processeurs en panne réorganisent les choses en interne, mais la règle d'or est que pour un seul cœur , ils préservent l'illusion d'instructions qui s'exécutent une à la fois, dans l'ordre. (Et cela inclut les interruptions qui déclenchent des changements de contexte). Les valeurs peuvent être stockées électriquement dans la mémoire dans le désordre, mais le noyau unique sur lequel tout fonctionne garde une trace de toutes les réorganisations qu'il effectue lui-même, pour préserver l'illusion. C'est pourquoi vous n'avez pas besoin d'une barrière mémoire pour l'équivalent asm de a = 1; b = a;pour charger correctement le 1 que vous venez de stocker.
Peter Cordes
4

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:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

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 à numplusieurs reprises, alors un entrelacement valide possible est que le thread B ne lit jamais entre num++et num--. 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:

while (working())
    progress++;  // atomic, global

(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:

int local = 0;
while (working())
    local++;

progress += local;

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:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /

Tony
la source
Cette réponse semble répondre uniquement à la question secondaire à laquelle Richard et moi réfléchissions. Nous avons finalement résolu il: avère que oui, la norme C de ne permettent la fusion des opérations sur non des volatileobjets 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.
Peter Cordes
Ouais, mon "non" est vraiment une réponse à toute la ligne de raisonnement. Si la question est simplement "peut num ++ être atomique sur un compilateur / une implémentation", la réponse est sûre. Par exemple, un compilateur peut décider d'ajouter 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 ...)
tony
1
Notez que ce And just remove the incr/decr entirely.n'est pas tout à fait vrai. C'est toujours une opération d'acquisition et de libération num. 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.
Peter Cordes
@PeterCordes ah oui, d'accord.
tony le
2

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.

Damon
la source
2
La charge et le stockage sont chacun atomiques séparément, mais toute l'opération de lecture-modification-écriture dans son ensemble n'est certainement pas atomique. Les caches sont cohérents et ne peuvent donc jamais contenir de copies conflictuelles de la même ligne ( en.wikipedia.org/wiki/MESI_protocol ). Un autre noyau ne peut même pas avoir une copie en lecture seule tant que ce noyau l'a dans l'état Modifié. Ce qui le rend non atomique, c'est que le cœur effectuant le RMW peut perdre la propriété de la ligne de cache entre la charge et le magasin.
Peter Cordes
2
De plus, non, des lignes de cache entières ne sont pas toujours transférées de manière atomique. Voir cette réponse , où il est démontré expérimentalement qu'un Opteron multi-socket rend 16B SSE stocke non atomique en transférant les lignes de cache en blocs 8B avec hypertransport, même si elles sont atomiques pour les processeurs à socket unique du même type (car la charge / le matériel du magasin a un chemin de 16B vers le cache L1). x86 ne garantit l'atomicité que pour des charges séparées ou des magasins jusqu'à 8B.
Peter Cordes
Laisser l'alignement au compilateur ne signifie pas que la mémoire sera alignée sur une limite de 4 octets. Les compilateurs peuvent avoir des options ou des pragmas pour changer la limite d'alignement. Ceci est utile, par exemple, pour fonctionner sur des données très compactes dans des flux réseau.
Dmitry Rubanovich
2
Sophistries, rien d'autre. Un entier avec stockage automatique qui ne fait pas partie d'une structure comme indiqué dans l'exemple sera parfaitement aligné correctement. Revendiquer quelque chose de différent est tout simplement ridicule. Les lignes de cache ainsi que tous les POD sont dimensionnés et alignés PoT (puissance de deux) - sur n'importe quelle architecture non illusoire dans le monde. Math veut que tout PoT correctement aligné s'insère dans exactement un (jamais plus) de tout autre PoT de même taille ou plus. Ma déclaration est donc correcte.
Damon
1
@Damon, l'exemple donné dans la question ne mentionne pas de struct, mais il ne restreint pas la question aux seules situations où les entiers ne font pas partie des structures. Les POD peuvent très certainement avoir une taille PoT et ne pas être alignés avec PoT. Jetez un œil à cette réponse pour des exemples de syntaxe: stackoverflow.com/a/11772340/1219722 . Ce n'est donc pas un "sophisme" car les POD déclarés de cette manière sont assez souvent utilisés dans le code réseau dans le code réel.
Dmitry Rubanovich
2

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 ++pas addlors 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éder numà un thread), et est de toute façon erroné, car ce addn'est pas atomique dans x86.

Notez que les atomiques (en utilisant le lockpré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":

void inc(int& x)
{
    ++x;
}

Cela se compile en:

inc(int&):
    incl    (%rdi)
    retq

Incrémenter un int passé par référence, de manière atomique:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

Cet exemple, qui n'est pas beaucoup plus complexe que la méthode normale, obtient simplement le lockpréfixe ajouté à l' inclinstruction - 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.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq
Asu
la source
-2

Lorsque votre compilateur n'utilise qu'une seule instruction pour l'incrémentation et que votre machine est monothread, votre code est sécurisé. ^^

Bonita Montero
la source
-3

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.

Xirema
la source
"C'est parce que sur les machines x86, incrémenter un entier de 32 bits est, en fait, atomique." pouvez-vous fournir un lien vers la documentation qui le prouve?
Slava
8
Ce n'est pas non plus atomique sur x86. C'est sûr pour un seul cœur, mais s'il y a plusieurs cœurs (et il y en a), ce n'est pas du tout atomique.
harold
Le x86 est-il addré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 le lockpréfixe existe pour les instructions; le seul atomique utile adds'applique à la mémoire déréférencée et utilise le lockpréfixe pour garantir que la ligne de cache est verrouillée pendant la durée de l'opération .
ShadowRanger
@Slava @Harold @ShadowRanger J'ai mis à jour la réponse. addest 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.
Xirema
3
@Xirema qui le rend "pas atomique" par définition
harold