32 votes

Les atomiques peuvent-ils souffrir de magasins parasites ?

En C++, les atomiques peuvent-ils subir des stockages erronés ?

Par exemple, supposons que m y n sont des atomiques et que m = 5 initialement. Dans le fil 1,

    m += 2;

Dans le fil 2,

    n = m;

Résultat : la valeur finale de n devrait être soit 5 soit 7, non ? Mais est-ce que ça pourrait être faussement 6 ? Pourrait-il s'agir de 4 ou de 8, ou même d'autre chose ?

En d'autres termes, le modèle de mémoire C++ interdit-il au thread 1 de se comporter comme s'il avait fait cela ?

    ++m;
    ++m;

Ou, plus bizarrement, comme si c'était le cas ?

    tmp  = m;
    m    = 4;
    tmp += 2;
    m    = tmp;

Référence : H.-J. Boehm et S. V. Adve, 2008, Figure 1. (Si vous suivez le lien, alors, dans la section 1 du document, voyez le premier élément à puces : "Les spécifications informelles fournies par ...")

LA QUESTION SOUS FORME ALTERNÉE

Une réponse (appréciée) montre que la question ci-dessus peut être mal comprise. Si cela est utile, voici la question sous une autre forme.

Supposons que le programmeur ait essayé de dire au thread 1 à sauter l'opération :

    bool a = false;
    if (a) m += 2;

Le modèle de mémoire C++ interdit-il au thread 1 de se comporter, au moment de l'exécution, comme s'il avait fait cela ?

    m += 2; // speculatively alter m
    m -= 2; // oops, should not have altered! reverse the alteration

Je pose la question parce que Boehm et Adve, dont les liens ont été cités précédemment, semblent expliquer qu'une exécution multithread peut

  • modifier de façon spéculative une variable, mais ensuite
  • modifier ultérieurement la variable pour lui redonner sa valeur initiale lorsque l'altération spéculative s'avère inutile.

EXEMPLE DE CODE COMPILABLE

Voici un code que vous pouvez compiler, si vous le souhaitez.

#include <iostream>
#include <atomic>
#include <thread>

// For the orignial question, do_alter = true.
// For the question in alternate form, do_alter = false.
constexpr bool do_alter = true;

void f1(std::atomic_int *const p, const bool do_alter_)
{
    if (do_alter_) p->fetch_add(2, std::memory_order_relaxed);
}

void f2(const std::atomic_int *const p, std::atomic_int *const q)
{
    q->store(
        p->load(std::memory_order_relaxed),
        std::memory_order_relaxed
    );
}

int main()
{
    std::atomic_int m(5);
    std::atomic_int n(0);
    std::thread t1(f1, &m, do_alter);
    std::thread t2(f2, &m, &n);
    t2.join();
    t1.join();
    std::cout << n << "\n";
    return 0;
}

Ce code imprime toujours 5 ou 7 quand je l'exécute. (En fait, pour autant que je puisse dire, il imprime toujours 7 quand je l'exécute). Cependant, je ne vois rien dans la sémantique qui l'empêcherait d'imprimer 6 , 4 ou 8 .

L'excellent Cppreference.com États, "Les objets atomiques sont exempts de courses de données", ce qui est bien, mais dans un tel contexte, qu'est-ce que cela signifie ?

Sans doute, tout cela signifie que je ne comprends pas très bien la sémantique. Tout éclairage que vous pouvez apporter sur la question serait apprécié.

RÉPONSES

@Christophe, @ZalmanStern et @BenVoigt éclairent chacun la question avec talent. Leurs réponses coopèrent plutôt que de se concurrencer. À mon avis, les lecteurs devraient tenir compte des trois réponses : @Christophe d'abord ; @ZalmanStern ensuite ; et @BenVoigt enfin pour résumer.

4 votes

Même pour l'addition non atomique et l'affectation, une variable entière ne peut pas obtenir de valeurs "parasites" comme celles qui vous inquiètent. Et (en pratique) cela vaut même pour les éventuelles courses de données que vous pourriez avoir. Une opération comme m += 2 est égal à m = m + 2 et non pas deux incréments consécutifs de un.

4 votes

Et pourquoi demandez-vous cela ? Avez-vous un réel problème qui conduit à cette question ? Vous devriez peut-être vous interroger sur que à la place ?

10 votes

Non, ils ne peuvent pas. C'est ce que signifie "atomique". Indivisible, ne peut pas observer de parties séparées.

23voto

Christophe Points 5220

Votre code fait usage de fetch_add() sur l'atome, ce qui donne la garantie suivante :

Atomiquement remplace la valeur courante par le résultat de l'addition arithmétique de la valeur et de arg. L'opération est une opération de lecture-modification-écriture. La mémoire est affectée en fonction de la valeur de l'ordre.

La sémantique est claire comme de l'eau de roche : avant l'opération, c'est m, après l'opération c'est m+2, et aucun thread n'accède à ce qui se trouve entre ces deux états parce que l'opération est atomique.


Edit : éléments supplémentaires concernant votre autre question

Quoi qu'en disent Boehm et Adve, les compilateurs C++ obéissent à la clause standard suivante :

1.9/5 : Une implémentation conforme qui exécute un programme bien formé doit produire le fichier même comportement observable comme l'un des possibles de l'instance correspondante de la machine abstraite avec le même programme le même programme et la même entrée.

Si un compilateur C++ générait un code susceptible de permettre aux mises à jour spéculatives d'interférer avec le comportement observable du programme (c'est-à-dire obtenir autre chose que 5 ou 7), il ne serait pas conforme à la norme, car il ne parviendrait pas à assurer la garantie mentionnée dans ma réponse initiale.

0 votes

Si vous êtes toujours intéressé, j'ai édité la question pour clarifier pourquoi je comprends (ou comprends mal) Boehm et Adve pour contredire votre réponse. A la question, j'ai ajouté une nouvelle section, LA QUESTION EN FORME ALTERNATIVE.

3 votes

@thb : Boehm et Adve soulignent les problèmes liés à l'accès ordinaire (non-atomique, non-volatile) à la mémoire. L'atomique est l'outil qui permet d'éviter les problèmes qu'ils soulignent.

2 votes

@Christophe : Cependant, il faut aussi considérer le sens du "comportement observable" utilisé par la norme. Elle inclut uniquement les observations faites par le code dans le même programme. Le C et le C++ prévoient expressément la possibilité d'utiliser un verrou explicite pour la mise en œuvre des fonctions atomiques, et dans ce cas, les observations qui manquent de sécurité de type, telles que l'accès à la mémoire interprocessus du système d'exploitation, le matériel mappé en mémoire ou les outils de débogage, peuvent encore être en mesure d'observer des violations du modèle de "comportement observable".

21voto

Ben Voigt Points 151460

Les réponses existantes fournissent beaucoup de bonnes explications, mais elles ne donnent pas de réponse directe à votre question. Nous y voilà :

les atomiques peuvent-ils souffrir de magasins parasites ?

Oui, mais vous ne pouvez pas les observer à partir d'un programme C++ qui est exempt de courses de données.

Seulement volatile n'est en fait pas autorisé à effectuer des accès supplémentaires à la mémoire.

Le modèle de mémoire C++ interdit-il au thread 1 de se comporter comme s'il avait fait cela ?

++m;
++m;

Oui, mais celui-ci est autorisé :

lock (shared_std_atomic_secret_lock)
{
    ++m;
    ++m;
}

C'est autorisé mais stupide. Une possibilité plus réaliste est de tourner ça :

std::atomic<int64_t> m;
++m;

sur

memory_bus_lock
{
    ++m.low;
    if (last_operation_did_carry)
       ++m.high;
}

memory_bus_lock y last_operation_did_carry sont des caractéristiques de la plate-forme matérielle qui ne peuvent pas être exprimées en C++ portable.

Notez que les périphériques situés sur le bus mémoire hacer voir la valeur intermédiaire, mais peuvent interpréter correctement cette situation en regardant le verrouillage du bus mémoire. Les débogueurs logiciels ne pourront pas voir la valeur intermédiaire.

Dans d'autres cas, les opérations atomiques peuvent être mises en œuvre par des verrous logiciels, auquel cas :

  1. Les débogueurs logiciels peuvent voir des valeurs intermédiaires, et doivent être conscients du verrouillage logiciel pour éviter toute mauvaise interprétation.
  2. Les périphériques matériels verront les modifications apportées au verrou logiciel, et les valeurs intermédiaires de l'objet atomique. Une certaine magie peut être nécessaire pour que le périphérique reconnaisse la relation entre les deux.
  3. Si l'objet atomique est en mémoire partagée, les autres processus peuvent voir les valeurs intermédiaires et n'ont aucun moyen d'inspecter le verrou logiciel / peuvent avoir une copie séparée dudit verrou logiciel.
  4. Si d'autres threads du même programme C++ enfreignent la sécurité des types d'une manière qui provoque une course aux données (par exemple, en utilisant memcpy pour lire l'objet atomique), ils peuvent observer des valeurs intermédiaires. Formellement, c'est un comportement non défini.

Un dernier point important. L'"écriture spéculative" est un scénario très complexe. Il est plus facile de le voir si nous renommons la condition :

Sujet n°1

if (my_mutex.is_held) o += 2; // o is an ordinary variable, not atomic or volatile
return o;

Fil de discussion n°2

{
    scoped_lock l(my_mutex);
    return o;
}

Il n'y a pas de course aux données ici. Si le fil n°1 a le mutex verrouillé, l'écriture et la lecture ne peuvent pas se produire sans ordre. S'il n'a pas le mutex verrouillé, les threads s'exécutent sans ordre mais les deux n'effectuent que des lectures.

Le compilateur ne peut donc pas permettre que des valeurs intermédiaires soient vues. Ce code C++ n'est pas une réécriture correcte :

o += 2;
if (!my_mutex.is_held) o -= 2;

parce que le compilateur a inventé une course aux données. Cependant, si la plate-forme matérielle fournit un mécanisme d'écriture spéculative sans course (Itanium peut-être ?), le compilateur peut l'utiliser. Ainsi, le matériel peut voir les valeurs intermédiaires, même si le code C++ ne le peut pas.

Si les valeurs intermédiaires ne doivent pas être vues par le matériel, vous devez utiliser volatile (éventuellement en plus des atomiques, car volatile lecture-modification-écriture n'est pas garanti atomique). Avec volatile En effet, demander une opération qui ne peut pas être exécutée telle qu'elle est écrite entraînera l'échec de la compilation, et non pas un accès mémoire parasite.

2 votes

La seule chose dont je suis conscient qui fonctionne comme memory_bus_lock{ multiple ops } est la mémoire transactionnelle. (Par exemple, le TSX d'Intel. realworldtech.com/haswell-tm ). AFAIK, c'est la seule façon de mettre en oeuvre int64_t m; ++m en utilisant l'algorithme que vous décrivez en langage assembleur sur x86 ou tout autre RISC typique. (La plupart des processeurs RISC supportent LL/SC mais pas imbriqués, donc vous ne pouvez pas opérer atomiquement sur une paire de mots.

2 votes

Sur x86, vous pouvez incrémenter de manière atomique un entier de deux mots (comme int64_t sur x86 32 bits) en utilisant un CAS double-mot (que x86 hace ont même sans la mémoire transactionnelle TSX-NI : cmpxchg8b ). Charger les deux mots, les incrémenter dans les registres, puis essayer de les CASer en mémoire. Sinon, réessayer. Il n'y a pas d'instructions asm pour verrouiller le bus mémoire. (Et bien sûr, normalement une opération atomique ne verrouille en interne que la ligne de cache sur laquelle elle opère, c'est-à-dire qu'elle ne répond pas aux requêtes MESI pour Invalider ou Partager la ligne de cache entre la lecture et l'écriture d'une RMW atomique).

2 votes

Quoi qu'il en soit, j'ai voté pour parce que le mécanisme que vous proposez est autorisé par le standard C++. Aucun matériel grand public ne fonctionne de cette façon, mais c'est une expérience de pensée intéressante. Et aussi pour la suggestion d'utiliser volatile atomic<T> quand on s'intéresse aux internes.

5voto

Zalman Stern Points 2706

Votre question révisée diffère quelque peu de la première dans la mesure où nous sommes passés de la cohérence séquentielle à l'ordre de mémoire relaxé.

Le raisonnement et la spécification des ordres de mémoire faibles peuvent être assez délicats. Notez par exemple la différence entre les spécifications du C++11 et du C++14 soulignée ici : http://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering . Cependant, la définition de l'atomicité empêche le fetch_add de permettre à tout autre thread de voir des valeurs autres que celles écrites dans la variable ou l'une de ces valeurs plus 2. (Un thread peut faire à peu près n'importe quoi tant qu'il garantit que les valeurs intermédiaires ne sont pas observables par les autres threads).

(Pour être terriblement précis, vous pouvez rechercher "read-modify-write" dans la spécification C++, par exemple. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf .)

Il serait peut-être utile de donner une référence spécifique à l'endroit du document lié sur lequel vous avez des questions. Cet article précède d'un tout petit peu la première spécification du modèle de mémoire concurrente du C++ (dans le C++11) et nous sommes maintenant une autre révision après cela, donc il peut aussi être un peu dépassé par rapport à ce que la norme dit réellement, bien que je pense que c'est plus un problème de proposer des choses qui pourraient se produire sur des variables non atomiques.

EDIT : Je vais ajouter un peu plus sur "la sémantique" pour peut-être aider à réfléchir sur la façon d'analyser ce genre de chose.

L'objectif de l'ordonnancement de la mémoire est d'établir un ensemble d'ordres possibles entre les lectures et les écritures de variables entre les threads. Dans les ordonnancements plus faibles, il n'est pas garanti qu'il existe un ordre global unique qui s'applique à tous les threads. Ce point est déjà suffisamment délicat pour que l'on s'assure de bien le comprendre avant de poursuivre.

Les deux éléments impliqués dans la spécification d'un ordre sont les adresses et les opérations de synchronisation. En effet, une opération de synchronisation a deux côtés et ces deux côtés sont reliés par le partage d'une adresse. (Une clôture peut être considérée comme s'appliquant à toutes les adresses.) Une grande partie de la confusion dans l'espace vient de la compréhension du moment où une opération de synchronisation sur une adresse garantit quelque chose pour d'autres adresses. Par exemple, les opérations de verrouillage et de déverrouillage d'un mutex n'établissent un ordre que via les opérations d'acquisition et de libération sur les adresses à l'intérieur du mutex, mais cette synchronisation s'applique à tous les lectures et écritures par les threads verrouillant et déverrouillant le mutex. Une variable atomique à laquelle on accède en utilisant un ordre relaxé impose peu de contraintes sur ce qui se passe, mais ces accès peuvent avoir des contraintes d'ordre imposées par des opérations plus fortement ordonnées sur d'autres variables atomiques ou mutex.

Les principales opérations de synchronisation sont acquire y release . Voir : http://en.cppreference.com/w/cpp/atomic/memory_order . Ces noms correspondent à ce qui se passe avec un mutex. L'opération d'acquisition s'applique aux chargements et empêche toute opération de mémoire sur le fil d'exécution actuel d'être réorganisée au-delà du point où l'acquisition a lieu. Elle établit également un ordre avec toute opération de libération antérieure sur la même variable. Le dernier point est régi par la valeur chargée. C'est-à-dire que si le chargement renvoie une valeur d'une écriture donnée avec synchronisation de la libération, le chargement est maintenant ordonné par rapport à cette écriture et toutes les autres opérations de mémoire par ces threads se mettent en place selon les règles d'ordonnancement.

Les opérations atomiques, ou lecture-modification-écriture, constituent leur propre petite séquence dans l'ordonnancement plus large. Il est garanti que la lecture, l'opération et l'écriture se produisent de manière atomique. Tout autre ordre est donné par le paramètre d'ordre de mémoire de l'opération. Par exemple, spécifier un ordre relaxé signifie qu'aucune contrainte ne s'applique aux autres variables. C'est-à-dire qu'il n'y a pas d'acquisition ou de libération impliquée par l'opération. Spécifier memory_order_acq_rel indique que non seulement l'opération est atomique, mais que la lecture est une acquisition et l'écriture une libération -- si le thread lit une valeur à partir d'une autre écriture avec une sémantique de libération, tous les autres atomiques ont maintenant la contrainte d'ordre appropriée dans ce thread.

A fetch_add avec un ordre de mémoire relaxé pourrait être utilisé pour un compteur de statistiques dans le profilage. À la fin de l'opération, tous les threads auront fait quelque chose d'autre pour s'assurer que tous ces incréments de compteur sont maintenant visibles pour le lecteur final, mais dans l'état intermédiaire, nous ne nous en soucions pas tant que le total final s'additionne. Cependant, cela n'implique pas que les lectures intermédiaires puissent échantillonner des valeurs qui n'ont jamais fait partie du comptage. Par exemple, si nous ajoutons toujours des valeurs paires à un compteur commençant à 0, aucun thread ne devrait jamais lire une valeur impaire, quel que soit l'ordre.

Je suis un peu déconcerté par le fait de ne pas être en mesure d'indiquer un texte spécifique de la norme qui dit qu'il ne peut y avoir d'effets secondaires sur les variables atomiques autres que ceux explicitement encodés dans le programme d'une manière ou d'une autre. Beaucoup de choses mentionnent les effets secondaires, mais il semble que l'on considère comme acquis que les effets secondaires sont ceux spécifiés par la source et non ceux inventés par le compilateur. Je n'ai pas le temps de rechercher cela pour le moment, mais il y a beaucoup de choses qui ne fonctionneraient pas si cela n'était pas garanti et une partie de l'objectif de std::atomic est d'obtenir cette contrainte car elle n'est pas garantie par d'autres variables. (Elle est en quelque sorte fournie par volatile ou du moins est destiné à l'être. Une partie de la raison pour laquelle nous avons ce degré de spécification pour l'ordre de la mémoire autour de std::atomic est parce que volatile n'ont jamais été assez bien spécifiées pour qu'on puisse y réfléchir en détail et aucun ensemble de contraintes ne répond à tous les besoins).

0 votes

+1. J'ai modifié la question pour donner une référence spécifique à l'endroit dans le document lié. Bon conseil. Bonne réponse. Vous dites que le raisonnement peut être assez délicat. C'est exactement ce que j'essaie de faire maintenant : J'essaie d'apprendre le truc !

1 votes

Ce texte décrit en effet des choses qui peuvent se produire en l'absence des contraintes d'un modèle de concurrence. Ce document faisait partie d'un travail préparatoire visant à spécifier toutes ces choses plutôt complexes dans la norme. Cependant, ces possibilités spécifiques ne semblent pas s'appliquer à std:atomic du tout. Je vais ajouter un peu à ma réponse sur la façon de penser à "la sémantique".

0 votes

@Zalman : La raison pour laquelle la norme ne contient pas la déclaration que vous recherchez est qu'elle n'est pas vraie. Sur certaines architectures, les variables atomiques sont implémentées en utilisant une séquence de sous-opérations protégées par un verrou. En fait, c'est vrai sur presque toutes les architectures, les principales différences étant que le verrou est un verrou matériel ou logiciel. La garantie apportée par std::atomic est que le code C++ ne peut pas observer les valeurs intermédiaires. Si vous avez besoin d'empêcher le matériel d'observer les sous-opérations, il vous faut volatile (et les opérations qui ne sont pas implémentées de manière atomique dans le matériel échoueront)

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