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

9voto

supercat Points 25534

Sur une machine x86 à un seul cœur, une add sera généralement atomique par rapport aux autres codes du processeur. 1 . Une interruption ne peut pas diviser une instruction unique en deux.

L'exécution hors ordre est nécessaire pour préserver l'illusion d'instructions s'exécutant une par une dans l'ordre au sein d'un même noyau. Ainsi, toute instruction s'exécutant sur le même processeur se produira soit complètement avant, soit complètement après l'ajout.

Les systèmes x86 modernes sont multi-cœurs, le cas particulier des uniprocesseurs ne s'applique donc pas.

Si l'on vise un petit PC embarqué et que l'on ne prévoit pas de transférer le code sur autre chose, la nature atomique de l'instruction "add" pourrait être exploitée. D'un autre côté, les plateformes où les opérations sont intrinsèquement atomiques sont de plus en plus rares.

(Cela ne vous aide pas si vous écrivez en C++, cependant. Les compilateurs n'ont pas l'option d'exiger num++ pour compiler vers un add ou xadd de destination mémoire sans a lock préfixe. Ils peuvent choisir de charger num dans un registre et stocker le résultat de l'incrémentation avec une instruction séparée, et le fera probablement si vous utilisez le résultat).


Footnote 1 : Le lock Le préfixe existait même sur le 8086 original parce que les périphériques d'E/S fonctionnent en même temps que le CPU ; les pilotes sur un système à un seul cœur ont besoin de lock add pour incrémenter atomiquement une valeur dans la mémoire du dispositif si le dispositif peut également la modifier, ou par rapport à l'accès DMA.

0 votes

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

0 votes

@FUZxxl : Il est certain que sur les 8088 et 80286, les interruptions pour le changement de tâche ne pouvaient se produire qu'entre les instructions. Le 80386 a ajouté la possibilité de défauts de page, mais les instructions interrompues recommençaient à zéro. Je n'ai pas suivi toutes les modifications apportées au fonctionnement interne des puces ultérieures, mais je pense que l'approche "redémarrer à partir de zéro" a été conservée.

1 votes

Considérons un système multicœur. Bien sûr, au sein d'un cœur, l'instruction est atomique, mais elle ne l'est pas pour l'ensemble du système.

9voto

Arne Vogel Points 506

Même si votre compilateur a toujours émis cette opération comme une opération atomique, accéder à num de tout 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 ça. 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éordonner autre les accès à la mémoire autour ++num si num n'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 de manière optimiste que ++ready 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 la supprimer, de la remplacer par une boucle infinie, etc.), le compilateur pourrait encore déplacer l'affectation du pointeur, ou pire encore l'initialisation de l'objet vector à un point situé après l'opération d'incrémentation, provoquant le chaos dans le nouveau thread. Dans la pratique, je ne serais pas du tout surpris qu'un compilateur optimisateur supprime l'option ready et la boucle de vérification complètement, car cela n'affecte pas le comportement observable selon les règles du langage (par opposition à vos espoirs privés).

En fait, lors de la conférence Meeting C++ de l'année dernière, j'ai entendu parler de dos les développeurs de compilateurs qu'ils mettent très volontiers en œuvre des optimisations qui font que des programmes multithreads écrits naïvement se comportent mal, tant que les règles du langage le permettent, si une amélioration même mineure des performances est constatée dans des programmes correctement écrits.

Enfin, même si si vous ne vous souciiez pas de la portabilité et que votre compilateur était magiquement gentil, le processeur que vous utilisez est très probablement de type CISC superscalaire et décomposera les instructions en micro-opérations, les réordonnera et/ou les exécutera de manière spéculative, dans une mesure uniquement limitée par des primitives de synchronisation telles que (sur Intel) la fonction LOCK préfixe ou clôtures de mémoire, afin de maximiser les opérations par seconde.

Pour faire court, les responsabilités naturelles de la programmation thread-safe sont les suivantes :

  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 de sorte que le comportement observé soit compatible avec le modèle de mémoire de sa propre architecture.

Si vous voulez le faire à votre façon, cela peut fonctionner dans certains cas, mais comprenez que la garantie est annulée et que vous serez le seul responsable de tout dommage causé par l'utilisation de ce produit. indésirable résultats. :-)

PS : Exemple correctement rédigé :

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();
}

C'est sûr parce que :

  1. Les contrôles de ready ne peuvent pas être éliminés par optimisation selon les règles de la langue.
  2. El ++ready se passe avant le chèque qui voit ready comme non nulle, et les autres opérations ne peuvent pas être réorganisées autour de ces opérations. Ceci est dû au fait que ++ready et le contrôle sont séquentiellement cohérent qui est un autre terme décrit dans le modèle de mémoire C++ et qui interdit ce réordonnancement spécifique. Par conséquent, le compilateur ne doit pas réordonner les instructions et doit également indiquer au CPU qu'il ne doit pas, par exemple, reporter l'écriture à vec après l'incrément de ready . Cohérence séquentielle est la garantie la plus forte concernant les atomiques dans la norme de langage. Des garanties moindres (et théoriquement moins chères) sont disponibles, par exemple, via d'autres méthodes de l'option std::atomic<T> mais elles sont réservées aux experts et ne sont pas optimisées par les développeurs de compilateurs, car elles sont rarement utilisées.

1 votes

Si le compilateur ne pouvait pas voir toutes les utilisations de ready il compilerait probablement while (!ready); en quelque chose de plus proche de if(!ready) { while(true); } . Upvoted : une partie clé de std::atomic est de changer la sémantique pour supposer une modification asynchrone à tout moment. Le fait que ce soit UB normalement est ce qui permet aux compilateurs d'extraire les charges et les magasins des boucles.

8voto

jdlugosz Points 96

À l'époque où les ordinateurs x86 n'avaient qu'une seule unité centrale, l'utilisation d'une seule instruction garantissait que les interruptions ne diviseraient pas la lecture/modification/écriture et que la mémoire ne serait pas utilisée comme tampon DMA également, c'était atomique en fait (et le C++ ne mentionnait pas les threads dans la norme, donc ce point n'était pas abordé).

Lorsqu'il était rare d'avoir un double processeur (par exemple un Pentium Pro à double socket) sur le bureau d'un client, je l'utilisais effectivement pour éviter le préfixe LOCK sur une machine à un seul cœur et améliorer les performances.

Aujourd'hui, cela n'aiderait que contre les multiples threads qui ont tous la même affinité avec le CPU, donc les threads qui vous inquiètent n'entreraient en jeu que par l'expiration de la tranche de temps et l'exécution de l'autre thread sur le même CPU (noyau). Ce n'est pas réaliste.

Avec les processeurs modernes x86/x64, l'instruction unique est décomposée en plusieurs éléments. 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 s'exécutant sur des CPU différents ne verront pas seulement cela comme non-atomique, mais pourront voir des résultats incohérents concernant ce qu'ils lisent de la mémoire et ce qu'ils supposent que les autres threads ont lu à ce moment-là : vous devez ajouter clôtures de mémoire pour retrouver un comportement sain.

2 votes

Les interruptions ne divisent toujours pas les opérations RMW. faire synchronise toujours un thread unique avec des gestionnaires de signaux qui s'exécutent dans le même thread. Bien sûr, cela ne fonctionne que si l'asm utilise une seule instruction, et non pas des instructions load/modify/store séparées. Le C++11 pourrait exposer cette fonctionnalité matérielle, mais il ne le fait pas (probablement parce qu'elle n'était vraiment utile que dans les noyaux d'uniprocesseur pour synchroniser avec les gestionnaires d'interruption, et non dans l'espace utilisateur avec les gestionnaires de signaux). De plus, les architectures n'ont pas d'instructions de lecture-modification-écriture de la destination de la mémoire. Néanmoins, cela pourrait être compilé comme un RMW atomique détendu sur les architectures non-x86.

0 votes

Si je me souviens bien, l'utilisation du préfixe Lock n'était pas absurdement coûteuse jusqu'à l'arrivée des supercalculateurs. Il n'y avait donc aucune raison de remarquer qu'il ralentissait le code important d'un 486, même si ce programme n'en avait pas besoin.

0 votes

Oui, désolé ! Je n'ai pas lu attentivement. J'ai vu le début du paragraphe avec le faux-fuyant sur le décodage en uops, et je n'ai pas fini de lire pour voir ce que vous disiez réellement. re : 486 : je crois avoir lu que le premier SMP était une sorte de Compaq 386, mais sa sémantique d'ordonnancement de la mémoire n'était pas la même que ce que l'ISA x86 dit aujourd'hui. Les manuels x86 actuels peuvent même mentionner le SMP 486. Ils n'étaient certainement pas courants, même dans le domaine du calcul intensif (grappes Beowulf), jusqu'à l'époque du PPro / Athlon XP, je pense.

4voto

tony Points 2048

Non. https://www.youtube.com/watch?v=31g0YE61PLQ (C'est juste un lien vers la scène du "Non" de "The Office")

Êtes-vous d'accord pour dire qu'il s'agit d'un résultat possible pour le programme ?

sortie de l'échantillon :

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

Si c'est le cas, le compilateur est libre de faire en sorte que l'option uniquement sortie possible pour le programme, de la manière dont le compilateur le souhaite. Par exemple, un main() qui ne fait que sortir 100s.

C'est la règle du "comme si".

Et indépendamment de la sortie, vous pouvez penser à la synchronisation des threads de la même manière - si le thread A fait num++; num--; et le fil B lit num à plusieurs reprises, alors un entrelacement valide possible est que le fil B ne lit jamais entre num++ y num-- . Puisque cet entrelacement est valide, le compilateur est libre de faire en sorte que l'élément uniquement l'entrelacement possible. Et supprimer complètement l'incr/decr.

Il y a quelques implications intéressantes ici :

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

(par exemple, imaginez qu'un autre fil de discussion mette à jour une barre de progression basée sur progress )

Le compilateur peut-il transformer cela en :

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

progress += local;

probablement que c'est valable. Mais ce n'est probablement pas ce que le programmeur espérait :-(

Le comité travaille toujours sur ce sujet. Actuellement, cela "fonctionne" parce que les compilateurs n'optimisent pas beaucoup les atomiques. Mais cela est en train de changer.

Et même si progress était également volatile, cela serait toujours valable :

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

while (local--)
    progress++;

:-/

0 votes

Cette réponse semble ne répondre qu'à la question secondaire que Richard et moi nous posions. Nous avons fini par la résoudre : il s'avère que oui, la norme C++ hace permettre la fusion d'opérations sur des objets non volatile les objets atomiques, lorsque cela n'enfreint aucune autre règle. Deux documents de discussion sur les normes traitent précisément de cette question (liens dans la section Commentaire de Richard ), l'un utilisant le même exemple de compteur de progression. Il s'agit donc d'un problème de qualité d'implémentation jusqu'à ce que le C++ standardise des moyens de l'éviter.

0 votes

Oui, mon "non" est en fait une réponse à l'ensemble du raisonnement. Si la question est simplement "num++ peut-il être atomique sur un compilateur/une implémentation", la réponse est oui. Par exemple, un compilateur pourrait décider d'ajouter lock à chaque opération. Ou une combinaison compilateur+uniprocesseur où aucun des deux ne réordonnait (c'est-à-dire "le bon vieux temps"), tout est atomique. Mais quel en est l'intérêt ? On ne peut pas vraiment s'y fier. Sauf si vous savez que c'est le système pour lequel vous écrivez. (Même dans ce cas, il vaudrait mieux que atomic<int> n'ajoute aucune opération supplémentaire sur ce système. Vous devriez donc continuer à écrire du code standard...)

1 votes

Il convient de noter que And just remove the incr/decr entirely. n'est pas tout à fait juste. Il s'agit toujours d'une opération d'acquisition et de libération sur num . Sur x86, num++;num-- pourrait se compiler en MFENCE, mais certainement pas en rien. (A moins que l'analyse du programme entier du compilateur puisse prouver que rien ne se synchronise avec cette modification de num, et que cela n'a pas d'importance si certains stockages d'avant cette modification sont retardés jusqu'à ce que des chargements d'après cette modification soient effectués). Par exemple, s'il s'agissait d'un cas d'utilisation de déverrouillage et de reverrouillage, vous avez toujours deux sections critiques séparées (peut-être en utilisant mo_relaxed), et non pas une seule.

2voto

Damon Points 26437

Oui, mais...

Atomique n'est pas ce que vous vouliez dire. Vous demandez probablement la mauvaise chose.

L'augmentation est certainement atomique . À moins que le stockage ne soit mal aligné (et puisque vous avez laissé l'alignement au compilateur, il ne l'est pas), il est nécessairement aligné sur une seule ligne de cache. En dehors des instructions spéciales de streaming sans cache, chaque écriture passe par le cache. Des lignes de cache complètes sont lues et écrites de manière atomique, jamais autre chose.
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-il à l'abri des fils ?

Il s'agit d'une question différente, et il y a au moins deux bonnes raisons d'y répondre par la négative. "Non !" .

Tout d'abord, il y a la possibilité qu'un autre cœur ait une copie de cette ligne de cache dans L1 (L2 et le haut sont généralement partagés, mais L1 est normalement par cœur !), et modifie simultanément cette valeur. Bien sûr, cela se produit aussi de manière atomique, mais maintenant vous avez deux valeurs "correctes" (correctement, atomiquement, modifiées) -- laquelle est la vraie valeur correcte maintenant ?
L'unité centrale va s'en sortir d'une manière ou d'une autre, bien sûr. Mais le résultat ne sera peut-être pas celui que vous attendez.

Deuxièmement, il y a l'ordonnancement de la mémoire, ou, en d'autres termes, les garanties "happens-before". La chose la plus importante à propos des instructions atomiques n'est pas tant qu'elles sont atomique . C'est une commande.

Vous avez la possibilité d'appliquer une garantie selon laquelle tout ce qui se passe en mémoire est réalisé dans un certain ordre garanti et bien défini où vous avez une garantie "arrivé avant". Cet ordre peut être aussi "détendu" (lire : pas 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 d'un calcul) et ensuite, de manière atomique, vous pouvez définir un pointeur vers un bloc de données (par exemple, les résultats d'un calcul). libérer le drapeau "les données sont prêtes". Maintenant, celui qui acquiert ce drapeau sera amené à penser que le pointeur est valide. Et en effet, il le fera siempre être un pointeur valide, jamais autre chose. C'est parce que l'écriture du pointeur s'est produite avant l'opération atomique.

2 votes

Le chargement et le stockage sont chacun atomiques séparément, mais l'ensemble de l'opération de lecture-modification-écriture est définitivement atomique. no atomique. Les caches sont cohérents et ne peuvent donc jamais contenir des copies contradictoires de la même ligne ( fr.wikipedia.org/wiki/MESI_protocol ). Un autre noyau ne peut même pas avoir une copie en lecture seule alors que ce noyau l'a dans l'état modifié. Ce qui le rend non atomique, c'est que le noyau qui effectue le RMW peut perdre la propriété de la ligne de cache entre le chargement et le stockage.

2 votes

En outre, non, les 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 les magasins SSE de 16B non atomiques en transférant les lignes de cache en morceaux de 8B avec l'hypertransport, même s'ils ont une capacité de stockage de 16B. sont atomique pour les CPU monosocket du même type (parce que le matériel de chargement/stockage a un chemin de 16B vers le cache L1). x86 ne garantit l'atomicité que pour des chargements ou des stockages séparés jusqu'à 8B.

0 votes

Le fait de laisser l'alignement au compilateur ne signifie pas que la mémoire sera alignée sur une frontière de 4 octets. Les compilateurs peuvent disposer d'options ou de pragmas permettant de modifier la limite d'alignement. Ceci est utile, par exemple, pour opérer sur des données étroitement empilées dans des flux de réseaux.

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