web-dev-qa-db-fra.com

Comment atteindre une barrière StoreLoad en C ++ 11?

Je souhaite écrire du code portable (Intel, ARM, PowerPC ...) qui résout une variante d'un problème classique:

Initially: X=Y=0

Thread A:
  X=1
  if(!Y){ do something }
Thread B:
  Y=1
  if(!X){ do something }

dans laquelle le but est d'éviter une situation dans laquelle les deux threads font something. (C'est bien si aucune des choses ne fonctionne; ce n'est pas un mécanisme à exécution unique.) Veuillez me corriger si vous voyez des failles dans mon raisonnement ci-dessous.

Je suis conscient que je peux atteindre l'objectif avec memory_order_seq_cst Atomic stores et loads comme suit:

std::atomic<int> x{0},y{0};
void thread_a(){
  x.store(1);
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!x.load()) bar();
}

qui atteint l'objectif, car il doit y avoir un seul ordre total sur le
{x.store(1), y.store(1), y.load(), x.load()} événements, qui doivent correspondre à l'ordre du programme "bords":

  • x.store(1) "dans TO est avant" y.load()
  • y.store(1) "dans TO est avant" x.load()

et si foo() a été appelé, alors nous avons Edge supplémentaire:

  • y.load() "lit la valeur avant" y.store(1)

et si bar() a été appelé, alors nous avons Edge supplémentaire:

  • x.load() "lit la valeur avant" x.store(1)

et tous ces bords combinés ensemble formeraient un cycle:

x.store(1) "dans TO est avant" y.load() "lit la valeur avant" y.store(1) "dans TO est avant" x.load() "lit la valeur avant" x.store(true)

ce qui viole le fait que les commandes n'ont pas de cycles.

J'utilise intentionnellement des termes non standard "dans TO est avant" et "lit la valeur avant" par opposition aux termes standard comme happens-before, Parce que je souhaite solliciter des commentaires sur l'exactitude de mon hypothèse selon laquelle ces arêtes en effet impliquent happens-before relation, peuvent être combinés ensemble dans un seul graphe, et le cycle dans un tel graphe combiné est interdit. Je ne suis pas sûre à propos de ça. Ce que je sais, c'est que ce code produit des barrières correctes sur Intel gcc & clang et sur ARM gcc


Maintenant, mon vrai problème est un peu plus compliqué, car je n'ai aucun contrôle sur "X" - il est caché derrière certaines macros, modèles, etc. et peut être plus faible que seq_cst

Je ne sais même pas si "X" est une variable unique, ou un autre concept (par exemple un sémaphore léger ou un mutex). Tout ce que je sais, c'est que j'ai deux macros set() et check() telles que check() renvoie true "après" un autre thread a appelé set(). (Il est également connu que set et check sont thread-safe et ne peuvent pas créer UB de course aux données.)

Donc, sur le plan conceptuel, set() est un peu comme "X = 1" et check() est comme "X", mais je n'ai pas d'accès direct aux atomiques impliqués, le cas échéant.

void thread_a(){
  set();
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!check()) bar();
}

Je suis inquiet, que set() puisse être implémenté en interne comme x.store(1,std::memory_order_release) et/ou check() pourrait être x.load(std::memory_order_acquire). Ou hypothétiquement un std::mutex Qu'un thread est en train de déverrouiller et un autre est try_lock Ing; dans la norme ISO, std::mutex est uniquement garanti d'avoir un ordre d'acquisition et de libération, pas seq_cst.

Si tel est le cas, alors check()'s if body peut être "réorganisé" avant y.store(true) ( Voir réponse d'Alex où ils démontrent que cela se produit sur PowerPC).
Ce serait vraiment mauvais, car maintenant cette séquence d'événements est possible:

  • thread_b() charge d'abord l'ancienne valeur de x (0)
  • thread_a() exécute tout, y compris foo()
  • thread_b() exécute tout, y compris bar()

Ainsi, foo() et bar() ont été appelés, ce que j'ai dû éviter. Quelles sont mes options pour éviter cela?


Option A

Essayez de forcer la barrière Store-Load. Ceci, en pratique, peut être réalisé par std::atomic_thread_fence(std::memory_order_seq_cst); - comme expliqué par Alex dans une réponse différente tous les compilateurs testés ont émis une clôture complète:

  • x86_64: MFENCE
  • PowerPC: hwsync
  • Itanuim: mf
  • ARMv7/ARMv8: dmb ish
  • MIPS64: synchronisation

Le problème avec cette approche est que je n'ai trouvé aucune garantie dans les règles C++, que std::atomic_thread_fence(std::memory_order_seq_cst) doit se traduire par une barrière de mémoire complète. En fait, le concept de atomic_thread_fence S en C++ semble être à un niveau d'abstraction différent du concept d'assemblage de barrières mémoire et traite davantage de choses comme "quelle opération atomique synchronise avec quoi". Existe-t-il une preuve théorique que la mise en œuvre ci-dessous atteint l'objectif?

void thread_a(){
  set();
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!y.load()) foo();
}
void thread_b(){
  y.store(true);
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!check()) bar();
}

Option B

Utilisez le contrôle que nous avons sur Y pour réaliser la synchronisation, en utilisant des opérations de lecture-modification-écriture memory_order_acq_rel sur Y:

void thread_a(){
  set();
  if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
  y.exchange(1,std::memory_order_acq_rel);
  if(!check()) bar();
}

L'idée ici est que les accès à un seul atomique (y) doivent être d'un seul ordre sur lequel tous les observateurs sont d'accord, donc soit fetch_add Est avant exchange ou vice-versa .

Si fetch_add Est avant exchange alors la partie "release" de fetch_add Se synchronise avec la partie "acquisition" de exchange et donc tous les effets secondaires de set() doit être visible par le code exécutant check(), donc bar() ne sera pas appelée.

Sinon, exchange est avant fetch_add, Alors le fetch_add Verra 1 Et n'appellera pas foo(). Donc, il est impossible d'appeler à la fois foo() et bar(). Ce raisonnement est-il correct?


Option C

Utilisez des atomiques factices, pour introduire des "bords" qui empêchent le désastre. Envisagez l'approche suivante:

void thread_a(){
  std::atomic<int> dummy1{};
  set();
  dummy1.store(13);
  if(!y.load()) foo();
}
void thread_b(){
  std::atomic<int> dummy2{};
  y.store(1);
  dummy2.load();
  if(!check()) bar();
}

Si vous pensez que le problème ici est que atomics sont locaux, alors imaginez les déplacer vers une portée globale, dans le raisonnement suivant, cela ne me semble pas important, et j'ai intentionnellement écrit le code dans un tel façon d'exposer à quel point il est drôle que dummy1 et dummy2 soient complètement séparés.

Pourquoi diable cela pourrait-il fonctionner? Eh bien, il doit y avoir un seul ordre total de {dummy1.store(13), y.load(), y.store(1), dummy2.load()} qui doit être cohérent avec l'ordre du programme "bords":

  • dummy1.store(13) "dans TO est avant" y.load()
  • y.store(1) "dans TO est avant" dummy2.load()

(Un magasin seq_cst + load forme, espérons-le, l'équivalent C++ d'une barrière de mémoire complète comprenant StoreLoad, comme ils le font dans asm sur de vrais ISA, y compris même AArch64 où aucune instruction de barrière séparée n'est requise.)

Maintenant, nous avons deux cas à considérer: soit y.store(1) est avant y.load() ou après dans l'ordre total.

Si y.store(1) est avant y.load() alors foo() ne sera pas appelée et nous sommes en sécurité.

Si y.load() est avant y.store(1), alors en le combinant avec les deux arêtes que nous avons déjà dans l'ordre du programme, nous en déduisons que:

  • dummy1.store(13) "dans TO est avant" dummy2.load()

Désormais, dummy1.store(13) est une opération de libération, qui libère les effets de set(), et dummy2.load() est une opération d'acquisition, donc check() devrait voir les effets de set() et donc de bar() ne seront pas appelés et nous sommes en sécurité.

Est-il correct ici de penser que check() verra les résultats de set()? Puis-je combiner les "bords" de différents types ("ordre du programme" aka Séquencé avant, "ordre total", "avant la libération", "après l'acquisition") comme ça? J'ai de sérieux doutes à ce sujet: les règles C++ semblent parler de relations de "synchronisation avec" entre le magasin et la charge au même endroit - ici, une telle situation n'existe pas.

Notez que nous ne sommes inquiets que du cas où dumm1.store Est connu (via un autre raisonnement) avant dummy2.load Dans l'ordre total seq_cst. Donc, s'ils avaient accédé à la même variable, la charge aurait vu la valeur stockée et synchronisée avec elle.

(Le raisonnement de la barrière de la mémoire/de la réorganisation pour les implémentations où les charges et les magasins atomiques se compilent avec au moins des barrières de mémoire unidirectionnelles (et les opérations seq_cst ne peuvent pas réorganiser: par exemple, un magasin seq_cst ne peut pas passer une charge seq_cst) est que toutes les charges/les magasins après dummy2.load deviennent définitivement visibles pour les autres threads aprèsy.store. Et de même pour l'autre thread, ... avant y.load.)


Vous pouvez jouer avec mon implémentation des options A, B, C à https://godbolt.org/z/u3dTa8

13
qbolec

dans la norme ISO, std :: mutex est uniquement garanti d'avoir un ordre d'acquisition et de publication, pas seq_cst.

Mais rien n'est garanti d'avoir "l'ordre seq_cst", car seq_cst n'est la propriété d'aucune opération.

seq_cst est une garantie sur toutes les opérations d'une implémentation donnée de std::atomic ou une classe atomique alternative. En tant que telle, votre question n'est pas fondée.

1
curiousguy