web-dev-qa-db-fra.com

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

En général, pour int num, num++ (Ou ++num), En tant qu'opération de lecture-modification-écriture, est non atomique . Mais je vois souvent des compilateurs, par exemple GCC , générez le code suivant pour cela ( essayez ici ):

Enter image description here

Puisque la ligne 5, qui correspond à num++ Est une instruction, pouvons-nous en conclure que num++ est atomique dans ce cas ?

Et si oui, signifie-t-il que le num++ Ainsi généré peut être utilisé dans des scénarios simultanés (multithreads) sans aucun risque de course des données (c.-à-d. nous n'avons pas besoin de le créer, par exemple, std::atomic<int> et d'imposer les coûts associés, car de toute façon, c'est atomique)?

[~ # ~] met à jour [~ # ~]

Notez que cette question est pas si incrément est atomique (ce n'est pas et c'était et c'est la ligne d'ouverture de la question). Que ce soit peut être dans des scénarios particuliers, c'est-à-dire si la nature à une instruction peut dans certains cas être exploitée pour éviter la surcharge du préfixe lock. Et, comme le dit la réponse acceptée dans la section sur les machines à un seul processeur, ainsi que cette réponse , la conversation dans ses commentaires et autres explique, qu'il peut (mais pas avec C ou C++).

148
Leo Heinsaar

... 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 retards de synchronisation du cache) n'a aucune possibilité d'observer les modifications individuelles.

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 se produisant dans d'autres threads.

l'atomicité ne se limite pas aux instructions, elle concerne tout le pipeline, du processeur à la mémoire, en passant par les caches.

Plus d'infos

Concernant l’effet des optimisations des mises à jour de std::atomics.

La norme c ++ a la règle 'as if', selon 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 les effets exactement les mêmes observables (y compris les effets secondaires) comme si il avait simplement exécuté votre code.

La règle "as-if" est conservative, en particulier concernant l'atomique.

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 en tant que NOP, par exemple:

void incdec(int&) {
    // nada
}

En effet, dans le modèle de mémoire c ++, il est impossible qu'un autre thread observe le résultat de l'incrément. Il 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 à modifier cette mémoire (sinon le programme est mal formé).

Cependant, ceci est un jeu de balle différent:

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

num est un atome. Les modifications apportées doivent peuvent être observées par les autres threads qui regardent. Les modifications apportées par les threads eux-mêmes (comme définir la valeur à 100 entre l'incrément et le décrément) auront des effets très importants 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
39
Richard Hodges

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

Il effectue trois opérations: charger l'opérande à partir de la mémoire, l'incrémenter, le stocker à nouveau dans la mémoire.
Au cours de ces opérations, la CPU acquiert et libère le bus deux fois, mais tout autre agent peut l’acquérir également, ce qui constitue une violation de l’atmicité.

AGENT 1          AGENT 2

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

X est incrémenté une seule fois.

37
Margaret Bloom

L'instruction add est not 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

11
Sven Nilsson

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

Il est dangereux de tirer des conclusions sur la base de l’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++. É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 sand.

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

10
Slava

Sur une machine x86 monocœur, une instruction add sera généralement atomique par rapport à un autre code de la 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 un même cœur, de sorte que toute instruction s'exécutant sur le même processeur se produira complètement avant ou après l'ajout.

Les systèmes x86 modernes sont multi-cœurs, le cas spécial du processeur unique ne s'applique donc pas.

Si l’on cible un petit PC embarqué et n’envisage pas de déplacer le code, la nature atomique de l’instruction "add" pourrait être exploitée. D'autre part, 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. Les compilateurs n'ont pas l'option d'exiger num++ pour compiler dans une destination mémoire ajouter ou xadd sans un préfixe lock. Ils pourraient choisir de charger num dans un registre et de stocker le résultat de l'incrémentation avec une instruction distincte, et le feront probablement si vous utilisez le résultat.)


Note de bas de page 1: Le préfixe lock existait même sur le 8086 d'origine, car les périphériques d'E/S fonctionnent simultanément avec l'UC; les pilotes sur un système monocœur ont besoin de lock add pour incrémenter de manière atomique une valeur dans la mémoire du périphérique si ce dernier peut également la modifier, ou par rapport à DMA access.

9
supercat

Même si votre compilateur émettait toujours cela sous forme d'opération atomique, accéder simultanément à num depuis un autre thread constituerait une course de données selon les normes C++ 11 et C++ 14 et le programme aurait un comportement indéfini.

Mais c'est pire que cela. Tout d'abord, 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 autre accès mémoire autour de ++num si num n’est pas atomique, par ex.

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 ++ready est "atomique", et que le compilateur génère la boucle de vérification en fonction des besoins (comme je l’ai dit, c’est UB et par conséquent, le compilateur est libre de l’enlever, de la remplacer par une boucle infinie, etc.), le compilateur peut toujours se déplacer. l'affectation du pointeur, ou pire encore, l'initialisation de vector à un point après l'opération d'incrémentation, ce qui provoque un chaos dans le nouveau thread. En pratique, je ne serais pas du tout surpris si un compilateur optimiseur supprimait complètement la variable ready et la boucle de vérification, car cela n’affecterait pas le comportement observable dans les règles de langage (contrairement à vos espoirs privés).

En fait, lors de la conférence Meeting C++ de l'année dernière, deux les développeurs de compilateurs ont implémenté avec plaisir des optimisations qui conduisent à un mauvais comportement des programmes multi-threads écrits naïvement, tant que les règles du langage le permettent. , même si une amélioration mineure des performances est constatée dans les programmes correctement écrits.

Enfin, même si vous ne vous souciez pas de la portabilité, et votre compilateur était comme par magie Nice, le processeur que vous utilisez est très probablement de type CISC superscalaire et décomposera les instructions en micro-opérations , réorganisez-les et/ou exécutez-les de manière spéculative, dans une mesure limitée uniquement par la synchronisation de primitives telles que (sur Intel) le préfixe LOCK ou les barrières de mémoire, afin de maximiser les opérations à la seconde.

En résumé, 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 sous les règles de langage (et en particulier le modèle de mémoire standard de langage).
  2. La tâche de votre compilateur est de générer un code machine ayant le même comportement bien défini (observable) dans le modèle de mémoire de l'architecture cible.
  3. Votre CPU doit exécuter ce code pour 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 vous serez seul responsable de tout 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 sans danger parce que:

  1. Les vérifications de ready ne peuvent pas être optimisées selon les règles de la langue.
  2. Le ++ready arrive-avant la vérification qui voit ready comme non nul, et les autres opérations ne peuvent pas être réorganisées autour de ces opérations. Ceci est dû au fait ++ready et la vérification sont séquentiellement cohérents, autre terme décrit dans le modèle de mémoire C++ et interdisant cette réorganisation spécifique. Par conséquent, le compilateur ne doit pas réorganiser les instructions et doit également indiquer à la CPU qu’elle ne doit pas p. Ex. reporter l'écriture sur vec après l'incrément de ready. séquentiellement cohérent est la garantie la plus forte en ce qui concerne l'atomique dans le standard de langue. Des garanties moindres (et théoriquement moins chères) sont disponibles, par exemple. via d'autres méthodes de std::atomic<T>, mais ceux-ci sont définitivement réservés aux experts et peuvent ne pas être optimisés par les développeurs du compilateur, car ils sont rarement utilisés.
9
Arne Vogel

À 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 les fonctions lecture/modification/écriture et que la mémoire ne serait pas utilisée en tant que DMA tampon aussi, c'était atomique en fait (et C++ n'a pas mentionné les threads dans la norme, donc cela n'a pas été abordé).

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

Aujourd'hui, cela ne servirait que contre plusieurs threads ayant tous la même affinité CPU, de sorte que les threads qui vous inquiètent n'entrent en jeu que via l'expiration de la tranche de temps et l'exécution de l'autre thread sur le même CPU (core). Ce n'est pas réaliste.

Avec les processeurs modernes x86/x64, la même instruction est divisée en plusieurs micro-opérations et la lecture et l'écriture en mémoire sont mises en mémoire tampon. Ainsi, différents threads s'exécutant sur différents processeurs le verront non seulement comme non atomique, mais ils verront peut-être des résultats incohérents concernant ce qu'il lit dans la mémoire et ce qu'il suppose que les autres threads ont déjà lu jusqu'à ce moment-là: vous devez ajouter memory clôtures pour rétablir un comportement sain.

7
JDługosz

Non. https://www.youtube.com/watch?v=31g0YE61PLQ (Il s'agit simplement d'un lien vers la scène "Non" de "L'Office")

Pensez-vous que cela pourrait être une sortie 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 de faire en sorte que la sortie seulement soit possible pour le programme, de la manière souhaitée par le compilateur. c'est-à-dire un main () qui ne fait que sortir des 100.

C'est la règle "as-if".

Et quelle que soit la sortie, vous pouvez penser à la synchronisation des threads de la même manière - si le thread A effectue num++; num--; Et que le thread B lit num à plusieurs reprises, un entrelacement valide est que le thread B ne lit jamais entre num++ Et num--. Comme cet entrelacement est valide, le compilateur est libre de faire en sorte que l’entrelacement seulement soit possible. Et supprimez simplement l'incr/décr.

Il y a quelques implications intéressantes ici:

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

(ie imaginez qu'un autre thread mette à jour une interface utilisateur de barre de progression basée sur progress)

Le compilateur peut-il transformer ceci 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 sujet. Actuellement, cela "fonctionne" car les compilateurs n'optimisent pas beaucoup l'atomique. Mais cela change.

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

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

while (local--)
    progress++;

: - /

4
tony

Que la sortie d'un seul compilateur, sur une architecture de CPU spécifique, avec les optimisations désactivées (puisque gcc ne compile même pas ++ to add lors de l'optimisation dans un exemple rapide & sale ), semble impliquer que l'incrémentation de cette façon est atomique ne signifie pas que cela est conforme à la norme (vous causeriez un comportement indéfini lorsque vous essayez de access num dans un fil), et est faux de toute façon, parce que add est pas atomique dans x86.

Notez que les atomiques (utilisant le préfixe d’instruction lock) sont relativement lourds sur x86 ( voir réponse pertinente ), mais restent remarquablement inférieurs à un mutex, ce qui n’est pas très approprié ici. cas d'utilisation.

Les résultats suivants sont extraits de clang ++ 3.8 lors de la compilation avec -Os.

Incrémenter un int par référence, de la manière "normale":

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

Cela compile dans:

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

Incrémenter un int passé par référence, de façon atomique:

#include <atomic>

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

Cet exemple, qui n’est pas beaucoup plus complexe que la méthode classique, obtient simplement le préfixe lock ajouté à l’instruction incl - mais attention, comme indiqué précédemment, il s’agit de pas pas cher. Ce n’est pas parce que l’Assemblée a l'air courte que c'est rapide.

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

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 que vous ayez laissé l'alignement sur le compilateur, ce n'est pas le cas), il est nécessairement aligné sur une seule ligne de cache. À part des instructions de streaming non-cache spéciales, 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 cacheline sont, bien sûr, également écrites de manière atomique (puisque la ligne de cache environnante est).

Est-ce thread-safe?

C'est une question différente, et il y a au moins deux bonnes raisons de répondre avec un certain "Non!".

Premièrement, il est possible qu'un autre cœur ait une copie de cette ligne de cache dans la couche L1 (la couche L2 et les versions supérieures sont généralement partagées, mais la couche L1 correspond normalement à une partie!) Et modifie simultanément cette valeur. Bien sûr, cela se produit également au niveau atomique, mais maintenant vous avez deux valeurs "correctes" (correctement, atomiquement, modifiées) - laquelle est-elle vraiment la bonne maintenant?
Bien entendu, le processeur va régler le problème. Mais le résultat peut ne pas être ce que vous attendez.

Deuxièmement, il y a un ordre de mémoire ou une formulation différente qui se passe avant des garanties. La chose la plus importante à propos des instructions atomiques n’est pas tant qu’elles sont atomiques . C'est la commande.

Vous avez la possibilité d'imposer une garantie selon laquelle tout ce qui se passe sur le plan mémoire est réalisé dans un ordre garanti et bien défini dans lequel vous avez une garantie "s'est passé avant". Cet ordre peut être aussi "détendu" (lire: aucun) ou aussi strict que nécessaire.

Par exemple, vous pouvez définir un pointeur sur un bloc de données (par exemple, les résultats de certains calculs), puis de manière atomique libérer la "donnée est prête" drapeau. Maintenant, quiconque 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 s'est produite avant l'opération atomique.

2
Damon