Ich möchte tragbaren Code (Intel, ARM, PowerPC ...) schreiben, der eine Variante eines klassischen Problems löst:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
in dem das Ziel ist, eine Situation zu vermeiden, in der beide Threads arbeitensomething
. (Es ist in Ordnung, wenn keines der beiden Elemente ausgeführt wird. Dies ist kein Mechanismus, der genau einmal ausgeführt wird.) Bitte korrigieren Sie mich, wenn Sie einige Fehler in meiner Argumentation unten sehen.
Mir ist bewusst, dass ich das Ziel mit memory_order_seq_cst
atomaren store
s und load
s wie folgt erreichen kann:
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();
}
Dies erreicht das Ziel, da es eine einzige Gesamtreihenfolge für die
{x.store(1), y.store(1), y.load(), x.load()}
Ereignisse geben muss, die mit den "Kanten" der Programmreihenfolge übereinstimmen muss:
x.store(1)
"in TO ist vor"y.load()
y.store(1)
"in TO ist vor"x.load()
und wenn foo()
aufgerufen wurde, dann haben wir zusätzliche Kante:
y.load()
"liest Wert vor"y.store(1)
und wenn bar()
aufgerufen wurde, dann haben wir zusätzliche Kante:
x.load()
"liest Wert vor"x.store(1)
und alle diese Kanten zusammen würden einen Zyklus bilden:
x.store(1)
"in TO ist vor" y.load()
"liest Wert vor" y.store(1)
"in TO ist vor" x.load()
"liest Wert vor" "x.store(true)
Dies verstößt gegen die Tatsache, dass Bestellungen keine Zyklen haben.
Ich verwende absichtlich nicht standardmäßige Begriffe "in TO ist vor" und "liest Wert vor" im Gegensatz zu Standardbegriffen wie happens-before
, weil ich Feedback über die Richtigkeit meiner Annahme einholen möchte, dass diese Kanten tatsächlich eine happens-before
Beziehung implizieren , die in einer einzigen kombiniert werden können Diagramm, und der Zyklus in einem solchen kombinierten Diagramm ist verboten. Ich bin mir darüber nicht sicher. Was ich weiß ist, dass dieser Code korrekte Barrieren auf Intel gcc & clang und auf ARM gcc erzeugt
Jetzt ist mein eigentliches Problem etwas komplizierter, weil ich keine Kontrolle über "X" habe - es ist hinter einigen Makros, Vorlagen usw. versteckt und möglicherweise schwächer als seq_cst
Ich weiß nicht einmal, ob "X" eine einzelne Variable oder ein anderes Konzept ist (z. B. ein leichtes Semaphor oder ein Mutex). Alles was ich weiß ist , dass ich zwei Makros set()
und check()
so dass check()
Renditen true
„nach“ einem anderen Thread aufgerufen hat set()
. (Es ist auch bekannt, dass set
und check
sind threadsicher und können keine Datenrassen-UB erstellen.)
Konzeptionell set()
ist es also etwas wie "X = 1" und check()
ist wie "X", aber ich habe keinen direkten Zugang zu Atomics, wenn überhaupt.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Ich mache mir Sorgen, dass set()
dies intern implementiert werden könnte x.store(1,std::memory_order_release)
und / oder sein check()
könnte x.load(std::memory_order_acquire)
. Oder hypothetisch ein, std::mutex
dass ein Thread entsperrt und ein anderer try_lock
ing; In der ISO-Norm std::mutex
wird nur garantiert, dass die Bestellung erworben und freigegeben wird, nicht seq_cst.
Wenn dies der Fall ist, check()
kann der Körper vorher "neu angeordnet" werden y.store(true)
( siehe Alex 'Antwort, in der gezeigt wird, dass dies auf PowerPC geschieht ).
Das wäre wirklich schlimm, da jetzt diese Abfolge von Ereignissen möglich ist:
thread_b()
lädt zuerst den alten Wert vonx
(0
)thread_a()
führt alles aus einschließlichfoo()
thread_b()
führt alles aus einschließlichbar()
Also beide foo()
und bar()
wurden angerufen, was ich vermeiden musste. Welche Möglichkeiten habe ich, dies zu verhindern?
Option A.
Versuchen Sie, die Store-Load-Barriere zu erzwingen. Dies kann in der Praxis erreicht werden, indem std::atomic_thread_fence(std::memory_order_seq_cst);
- wie von Alex in einer anderen Antwort erklärt - alle getesteten Compiler einen vollständigen Zaun emittierten:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: Synchronisieren
Das Problem bei diesem Ansatz ist, dass ich in C ++ - Regeln keine Garantie finden konnte, die std::atomic_thread_fence(std::memory_order_seq_cst)
zu einer vollständigen Speicherbarriere führen muss. Tatsächlich atomic_thread_fence
scheint sich das Konzept von s in C ++ auf einer anderen Abstraktionsebene zu befinden als das Assemblierungskonzept von Speicherbarrieren und befasst sich eher mit Dingen wie "Welche atomare Operation synchronisiert sich mit was?". Gibt es einen theoretischen Beweis dafür, dass die unten stehende Implementierung das Ziel erreicht?
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.
Verwenden Sie die Kontrolle über Y, um eine Synchronisation zu erreichen, indem Sie die Operationen read_ modify-write memory_order_acq_rel für Y verwenden:
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();
}
Die Idee dabei ist, dass der Zugriff auf ein einzelnes Atom ( y
) eine einzige Reihenfolge bilden muss, in der sich alle Beobachter einig sind, also entweder fetch_add
vorher exchange
oder umgekehrt.
Wenn dies fetch_add
vorher exchange
der Fall ist, fetch_add
synchronisiert sich der "Release" -Teil von mit dem "Erwerb" -Teil von exchange
und daher müssen alle Nebenwirkungen von set()
für die Ausführung des Codes sichtbar sein check()
, bar()
wird also nicht aufgerufen.
Ansonsten exchange
ist vorher fetch_add
, dann fetch_add
wird der sehen 1
und nicht anrufen foo()
. Es ist also unmöglich, beide foo()
und anzurufen bar()
. Ist diese Argumentation richtig?
Option C.
Verwenden Sie Dummy-Atomics, um "Kanten" einzuführen, die eine Katastrophe verhindern. Betrachten Sie folgenden Ansatz:
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();
}
Wenn Sie der Meinung atomic
sind, dass das Problem hier s lokal ist, stellen Sie sich vor, Sie verschieben sie in den globalen Bereich. In der folgenden Argumentation scheint es mir nicht wichtig zu sein, und ich habe den Code absichtlich so geschrieben, dass deutlich wird, wie lustig dieser Dummy1 ist und Dummy2 sind völlig getrennt.
Warum um alles in der Welt könnte das funktionieren? Nun, es muss eine einzelne Gesamtreihenfolge geben, {dummy1.store(13), y.load(), y.store(1), dummy2.load()}
die mit den "Kanten" der Programmreihenfolge übereinstimmen muss:
dummy1.store(13)
"in TO ist vor"y.load()
y.store(1)
"in TO ist vor"dummy2.load()
(Ein seq_cst store + load bildet hoffentlich das C ++ - Äquivalent einer vollständigen Speicherbarriere einschließlich StoreLoad, wie dies bei echten ISAs einschließlich AArch64 der Fall ist, bei denen keine separaten Barriereanweisungen erforderlich sind.)
Nun müssen wir zwei Fälle berücksichtigen: entweder y.store(1)
vor y.load()
oder nach in der Gesamtreihenfolge.
Wenn y.store(1)
vorher y.load()
ist, foo()
wird nicht angerufen und wir sind in Sicherheit.
Wenn y.load()
es vorher ist y.store(1)
, dann kombinieren wir es mit den beiden Kanten, die wir bereits in der Programmreihenfolge haben, daraus:
dummy1.store(13)
"in TO ist vor"dummy2.load()
Nun, das dummy1.store(13)
ist eine Freigabeoperation, die die Effekte von freigibt set()
und dummy2.load()
eine Erfassungsoperation ist, check()
sollte also die Auswirkungen von sehen set()
und bar()
wird daher nicht aufgerufen und wir sind sicher.
Ist es hier richtig zu denken, dass check()
die Ergebnisse von sehen werden set()
? Kann ich die "Kanten" verschiedener Arten ("Programmreihenfolge", auch bekannt als "Sequenced Before", "Total Order", "Before Release", "After Acquisition")) so kombinieren? Ich habe ernsthafte Zweifel: C ++ - Regeln scheinen von "Synchronisierungen mit" Beziehungen zwischen Speichern und Laden am selben Ort zu sprechen - hier gibt es keine solche Situation.
Beachten Sie, dass wir nur über den Fall besorgt , wo dumm1.store
ist bekannt (über andere Argumentation) , bevor zu sein dummy2.load
in dem seq_cst Gesamtauftrag. Wenn sie also auf dieselbe Variable zugegriffen hätten, hätte die Last den gespeicherten Wert gesehen und mit ihm synchronisiert.
(Die Begründung für die Speicherbarriere / Neuordnung bei Implementierungen, bei denen atomare Lasten und Speicher zu mindestens 1-Wege-Speicherbarrieren kompiliert werden (und seq_cst-Operationen können nicht neu anordnen: z. B. kann ein seq_cst-Speicher eine seq_cst-Last nicht passieren), ist, dass alle Lasten / speichert nach dummy2.load
definitiv sichtbar für andere Threads nach y.store
. Und ähnlich für den anderen Thread, ... vorher y.load
.)
Sie können mit meiner Implementierung der Optionen A, B, C unter https://godbolt.org/z/u3dTa8 spielen
foo()
und verhindern, dass bar()
beide aufgerufen werden.
compare_exchange_*
eine RMW-Operation für einen Atom-Bool ausführen, ohne dessen Wert zu ändern (setzen Sie einfach erwartet und neu auf denselben Wert).
atomic<bool>
hat exchange
und compare_exchange_weak
. Letzteres kann verwendet werden, um ein Dummy-RMW durch (Versuch) CAS (wahr, wahr) oder falsch, falsch zu erstellen. Es schlägt entweder fehl oder ersetzt den Wert atomar durch sich selbst. (In x86-64 asm besteht dieser Trick lock cmpxchg16b
darin, wie Sie garantierte atomare 16-Byte-Ladevorgänge ausführen; ineffizient, aber weniger schlecht als eine separate Sperre.)
foo()
noch bar()
aufgerufen wird. Ich wollte nicht zu vielen "realen" Elementen des Codes bringen, um zu vermeiden, dass "Sie denken, Sie haben Problem X, aber Sie haben Problem Y" Antworten. Aber wenn man wirklich wissen muss, was das Hintergrundgeschoss ist: set()
ist wirklich some_mutex_exit()
, check()
ist try_enter_some_mutex()
, y
ist "es gibt einige Kellner", foo()
ist "verlassen, ohne jemanden bar()
aufzuwecken" , ist "auf das Aufwachen warten" ... Aber ich weigere mich Besprechen Sie dieses Design hier - ich kann es nicht wirklich ändern.
std::atomic_thread_fence(std::memory_order_seq_cst)
eine vollständige Barriere kompiliert, aber da das gesamte Konzept ein Implementierungsdetail ist, werden Sie es nicht finden jede Erwähnung im Standard. (CPU Speichermodelle in der Regel sind definiert in Bezug auf was reorerings sind sequentielle Konsistenz relativ erlaubt zB x86 Seq.-cst + ein Speicherpuffer w / Forwarding.)