Je veux é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 lequel l'objectif est d'éviter une situation dans laquelle les deux threads fontsomething
. (Ce n'est pas grave si rien ne fonctionne; ce n'est pas un mécanisme à exécution unique.) Veuillez me corriger si vous voyez des défauts dans mon raisonnement ci-dessous.
Je suis conscient que je peux atteindre le but avec memory_order_seq_cst
les store
s atomiques et les load
s 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 les
{x.store(1), y.store(1), y.load(), x.load()}
événements, qui doit correspondre à l'ordre des programmes "bords":
x.store(1)
"dans TO c'est avant"y.load()
y.store(1)
"dans TO c'est avant"x.load()
et si a foo()
été appelé, alors nous avons un avantage supplémentaire:
y.load()
"lit la valeur avant"y.store(1)
et si a bar()
été appelé, alors nous avons un avantage 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 veux solliciter des commentaires sur l'exactitude de mon hypothèse selon laquelle ces bords impliquent effectivement une happens-before
relation, peuvent être combinés ensemble en un seul graphique, et le cycle dans un tel graphique 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 pourrait ê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()
que cela check()
retourne true
"après" qu'un autre thread ait appelé set()
. (Il est également connu que set
et check
sont thread-safe et ne peuvent pas créer UB course de données.)
Donc, conceptuellement, set()
c'est un peu comme "X = 1" et check()
c'est comme "X", mais je n'ai aucun 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, cela set()
pourrait être implémenté en interne comme x.store(1,std::memory_order_release)
et / ou check()
pourrait l'être x.load(std::memory_order_acquire)
. Ou hypothétiquement std::mutex
, un thread se déverrouille et un autre est try_lock
ing; dans la norme ISO std::mutex
est uniquement garanti d'avoir acquis et validé l'ordre, pas seq_cst.
Si tel est le cas, alors check()
si le corps peut être "réorganisé" avant y.store(true)
( voir la 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 dex
(0
)thread_a()
exécute tout, y comprisfoo()
thread_b()
exécute tout, y comprisbar()
Donc, les deux foo()
et bar()
j'ai été appelé, ce que j'ai dû éviter. Quelles sont mes options pour empêcher cela?
Option A
Essayez de forcer la barrière Store-Load. En pratique, cela peut être réalisé par std::atomic_thread_fence(std::memory_order_seq_cst);
- comme l'explique 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 ne pouvais trouver aucune garantie dans les règles C ++, qui std::atomic_thread_fence(std::memory_order_seq_cst)
doivent se traduire par une barrière de mémoire pleine. 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 de mémoire et traite plus de choses comme "quelle opération atomique se 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 les opérations 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 l'accès à un seul atomic ( y
) doit être formé d'un seul ordre sur lequel tous les observateurs s'accordent, donc fetch_add
c'est avant exchange
ou vice-versa.
Si fetch_add
c'est avant, exchange
alors la partie "libération" de se fetch_add
synchronise avec la partie "acquisition" de exchange
et donc tous les effets secondaires de set()
doivent être visibles pour l'exécution du code check()
, donc bar()
ne seront pas appelés.
Sinon, exchange
c'est avant fetch_add
, alors le fetch_add
verra 1
et n'appellera pas foo()
. Il est donc impossible d'appeler les deux foo()
et bar()
. Ce raisonnement est-il correct?
Option C
Utilisez atomique factice, 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 est que les atomic
s sont locaux, alors imaginez les déplacer à l'échelle mondiale, dans le raisonnement suivant, cela ne semble pas avoir d'importance pour moi, et j'ai intentionnellement écrit le code de manière à exposer à quel point c'est drôle ce mannequin1 et dummy2 sont complètement séparés.
Pourquoi diable cela pourrait-il fonctionner? Eh bien, il doit y avoir un seul ordre total {dummy1.store(13), y.load(), y.store(1), dummy2.load()}
qui doit être cohérent avec l'ordre des programmes "bords":
dummy1.store(13)
"dans TO c'est avant"y.load()
y.store(1)
"dans TO c'est avant"dummy2.load()
(Un magasin + chargement seq_cst forme, espérons-le, l'équivalent C ++ d'une barrière de mémoire complète, y compris StoreLoad, comme ils le font dans asm sur de véritables ISA, y compris même AArch64 où aucune instruction de barrière distincte n'est requise.)
Maintenant, nous avons deux cas à considérer: soit y.store(1)
avant y.load()
soit après dans l'ordre total.
Si y.store(1)
c'est avant y.load()
alors foo()
ne sera pas appelé et nous sommes en sécurité.
Si y.load()
est avant y.store(1)
, puis en le combinant avec les deux arêtes que nous avons déjà dans l'ordre du programme, nous déduisons que:
dummy1.store(13)
"dans TO c'est avant"dummy2.load()
Maintenant, dummy1.store(13)
c'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 bar()
ne sera donc pas appelée et nous sommes en sécurité.
Est-il correct de penser que check()
cela verra les résultats de set()
? Puis-je combiner les "bords" de différents types ("ordre du programme" aka Sequenced Before, "ordre total", "avant la sortie", "après l'acquisition") comme ça? J'ai de sérieux doutes à ce sujet: les règles C ++ semblent parler de relations "synchronise avec" entre le magasin et la charge au même endroit - ici, il n'y a pas une telle situation.
Notez que nous ne nous inquiétons que du cas où il dumm1.store
est connu (via un autre raisonnement) d'être 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 se serait synchronisée avec elle.
(Le raisonnement barrière / réorganisation de la mémoire pour les implémentations où les charges atomiques et les magasins se compilent vers au moins des barrières mémoire à 1 voie (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ès y.store
. Et de même pour l'autre thread, ... avant y.load
.)
Vous pouvez jouer avec ma mise en œuvre des options A, B, C sur https://godbolt.org/z/u3dTa8
foo()
et que les bar()
deux soient appelés.
compare_exchange_*
pour effectuer une opération RMW sur un bool atomique sans changer sa valeur (définissez simplement attendu et nouveau à la même valeur).
atomic<bool>
a exchange
et compare_exchange_weak
. Ce dernier peut être utilisé pour faire un RMW factice en (essayant de) CAS (vrai, vrai) ou faux, faux. Il échoue ou remplace atomiquement la valeur par elle-même. (Dans asm x86-64, cette astuce lock cmpxchg16b
est de savoir comment vous faites des charges atomiques garanties de 16 octets; inefficace mais moins mauvais que de prendre un verrou séparé.)
foo()
ni bar()
ne soit appelé. Je ne voulais pas apporter de nombreux éléments "réels" du code, pour éviter "vous pensez que vous avez un problème X mais vous avez un problème Y" de réponses. Mais, si l'on a vraiment besoin de savoir quel est l'étage d'arrière-plan: set()
c'est vraiment some_mutex_exit()
, check()
c'est try_enter_some_mutex()
, y
c'est "il y a des serveurs", foo()
c'est "sortir sans réveiller personne", bar()
c'est "attendre le réveil" ... Mais, je refuse de discutez de cette conception ici - je ne peux pas vraiment la changer.
std::atomic_thread_fence(std::memory_order_seq_cst)
compile à une barrière complète, mais puisque le concept entier est un détail d'implémentation que vous ne trouverez pas toute mention de celui-ci dans la norme. (Les modèles de mémoire CPU sont généralement définis en fonction des restaurations autorisées par rapport à la cohérence séquentielle. Par exemple, x86 est seq-cst + un tampon de stockage avec transfert)