C++
C ++ 11-Speichermodell
Suche…
Bemerkungen
Verschiedene Threads, die versuchen, auf denselben Speicherplatz zuzugreifen, nehmen an einem Datenwettlauf teil, wenn mindestens eine der Operationen eine Modifikation ist (auch als Speicheroperation bezeichnet ). Diese Datenrennen verursachen undefiniertes Verhalten . Um dies zu vermeiden, muss verhindert werden, dass diese Threads gleichzeitig in Konflikt stehende Vorgänge ausführen.
Synchronisationsprimitive (Mutex, kritischer Abschnitt und dergleichen) können solche Zugriffe schützen. Das in C ++ 11 eingeführte Speichermodell definiert zwei neue tragbare Methoden zum Synchronisieren des Zugriffs auf den Speicher in einer Multithread-Umgebung: atomare Operationen und Zäune.
Atomoperationen
Es ist jetzt möglich, den angegebenen Speicherplatz mithilfe von Atomlast- und Atomspeicheroperationen zu lesen und zu schreiben. Der Einfachheit halber werden diese in die Vorlagenklasse std::atomic<t>
. Diese Klasse wickelt einen Wert vom Typ t
, aber dieses Mal lädt und speichert zum Objekt ist atomar.
Die Vorlage ist nicht für alle Typen verfügbar. Welche Typen verfügbar sind, ist implementierungsspezifisch, dies schließt jedoch in der Regel die meisten (oder alle) verfügbaren Integraltypen sowie Zeigertypen ein. Damit sollten std::atomic<unsigned>
und std::atomic<std::vector<foo> *>
verfügbar sein, während std::atomic<std::pair<bool,char>>
höchstwahrscheinlich nicht der Fall sein wird.
Atomare Operationen haben folgende Eigenschaften:
- Alle atomaren Operationen können gleichzeitig von mehreren Threads ausgeführt werden, ohne dass es zu einem undefinierten Verhalten kommt.
- Bei einer atomaren Last wird entweder der Anfangswert angezeigt, mit dem das atomare Objekt erstellt wurde, oder der Wert, der über eine atomare Speicheroperation in dieses Objekt geschrieben wird.
- Atomspeicher für dasselbe Atomobjekt sind in allen Threads gleich angeordnet. Wenn ein Thread bereits den Wert einer atomaren Speicheroperation gesehen hat, wird bei nachfolgenden atomaren Ladeoperationen entweder derselbe Wert angezeigt oder der Wert, der von der nachfolgenden atomaren Speicheroperation gespeichert wird.
- Atomare Lese-, Änderungs- und Schreiboperationen ermöglichen die atomare Last und den atomaren Speicher , ohne dass ein anderer atomarer Speicher dazwischen liegt. Beispielsweise kann man einen Zähler aus mehreren Threads atomar inkrementieren, und unabhängig von der Konkurrenz zwischen den Threads geht kein Inkrement verloren.
- Atomare Operationen erhalten einen optionalen Parameter
std::memory_order
, der definiert, welche zusätzlichen Eigenschaften die Operation in Bezug auf andere Speicherorte hat.
std :: memory_order | Bedeutung |
---|---|
std::memory_order_relaxed | keine zusätzlichen einschränkungen |
std::memory_order_release → std::memory_order_acquire | Wenn für load-acquire Ladebereitschaft der von store-release gespeicherte Wert angezeigt wird store-release Filialen sequenziert, bevor die store-release , bevor die Ladevorgänge nach der Ladebereitschaft erfolgen |
std::memory_order_consume | Wie memory_order_acquire aber nur für abhängige Ladungen |
std::memory_order_acq_rel | kombiniert load-acquire und store-release |
std::memory_order_seq_cst | sequentielle Konsistenz |
Diese Speicherordnungs-Tags ermöglichen drei verschiedene Speicherordnungsdisziplinen: sequenzielle Konsistenz , entspannt und Release-Acquisition mit dem Release-Consum von Geschwistern.
Sequenzielle Konsistenz
Wenn für eine atomare Operation keine Speicherreihenfolge angegeben wird, wird die Reihenfolge standardmäßig auf sequentielle Konsistenz festgelegt . Dieser Modus kann auch explizit ausgewählt werden, indem die Operation mit std::memory_order_seq_cst
.
Bei dieser Reihenfolge kann keine Speicheroperation die atomare Operation kreuzen. Alle Speicheroperationen, die vor der atomaren Operation sequenziert wurden, finden vor der atomaren Operation statt, und die atomare Operation findet statt vor allen Speicheroperationen, die nach dieser Sequenz ausgeführt werden. Dieser Modus ist wahrscheinlich der einfachste Grund, über den er nachdenken kann, aber er führt auch zu der größten Leistungseinschränkung. Außerdem werden alle Compiler-Optimierungen verhindert, die andernfalls versuchen würden, Operationen nach der atomaren Operation neu zu ordnen.
Entspannte Bestellung
Das Gegenteil von sequentieller Konsistenz ist die entspannte Speicheranordnung. Es wird mit dem Tag std::memory_order_relaxed
. Durch den entspannten atomaren Betrieb werden keine anderen Speicheroperationen eingeschränkt. Der einzige Effekt, der bleibt, ist, dass die Operation selbst noch atomar ist.
Bestellung freigeben
Eine Atomspeicheroperation kann mit Tags versehen wird std::memory_order_release
und ein Atomlastbetrieb kann mit Tags versehen wird std::memory_order_acquire
. Die erste Operation wird als (atomare) Speicherfreigabe bezeichnet, während die zweite als (atomare) Lastakquisition bezeichnet wird .
Bei Last-acquire den Wert von einem Speicher-Release geschrieben sieht , geschieht folgendes: alle Speicheroperationen vor dem Laden-Release sequenziert werden sichtbar (geschehen vor) Ladevorgänge , die nach dem Last-acquire sequenziert werden.
Atomische Lese-, Änderungs- und Schreiboperationen können auch das kumulative Tag std::memory_order_acq_rel
. Dies macht die Atomlast Teil des Betriebs eine Atom Last-acquire , während der Atomspeicherteil Atom-Store-Release wird.
Der Compiler darf keine Speicheroperationen nach einer atomaren Speicherfreigabe verschieben . Es ist auch nicht zulässig, Ladeoperationen vor der atomaren Lastaufnahme (oder Lastaufnahme ) zu verschieben.
Beachten Sie auch, dass es keine atomare Lastfreigabe oder atomare Speichererfassung gibt . Wenn Sie versuchen, solche Operationen zu erstellen, werden die Operationen entspannt .
Bestellung freigeben
Diese Kombination ähnelt Release- std::memory_order_consume
, aber dieses Mal wird die atomare Last mit std::memory_order_consume
und wird zu einer (atomaren) load- std::memory_order_consume
- Operation. Dieser Modus ist derselbe wie bei der Freigabeerfassung, mit dem einzigen Unterschied, dass unter den nach dem Lastverbrauch aufeinanderfolgenden Ladeoperationen nur diese in Abhängigkeit von dem durch den Lastverbrauch geladenen Wert angeordnet werden.
Zäune
Zäune ermöglichen auch die Anordnung von Speicheroperationen zwischen Threads. Ein Zaun ist entweder ein Freigabezaun oder ein Zaun.
Wenn ein Freigabezaun vor einem Erfassungszaun auftritt, sind die vor dem Freigabezaun sequenzierten Speicher für Ladungen sichtbar, die nach dem Erfassungszaun sequenziert wurden. Um zu gewährleisten, dass der Freigabezaun vor dem Erfassungszaun geschieht, können andere Synchronisationsprimitive verwendet werden, einschließlich entspannter atomarer Operationen.
Notwendigkeit eines Speichermodells
int x, y;
bool ready = false;
void init()
{
x = 2;
y = 3;
ready = true;
}
void use()
{
if (ready)
std::cout << x + y;
}
Ein Thread ruft die Funktion init()
, während ein anderer Thread (oder Signalhandler) die Funktion use()
aufruft. Man könnte erwarten, dass die use()
Funktion entweder 5
druckt oder nichts tut. Dies kann aus verschiedenen Gründen nicht immer der Fall sein:
Die CPU ordnet möglicherweise die Schreibvorgänge in
init()
so dass der tatsächlich ausgeführte Code folgendermaßen aussehen kann:void init() { ready = true; x = 2; y = 3; }
Die CPU ordnet möglicherweise die in
use()
Lesevorgänge neu an, so dass der tatsächlich ausgeführte Code folgendermaßen wird:void use() { int local_x = x; int local_y = y; if (ready) std::cout << local_x + local_y; }
Ein optimierender C ++ - Compiler kann das Programm auf ähnliche Weise neu anordnen.
Eine solche Neuordnung kann das Verhalten eines Programms, das in einem einzelnen Thread ausgeführt wird, nicht ändern, da ein Thread die Aufrufe von init()
und use()
nicht verschachteln kann. Auf der anderen Seite sieht ein Thread in einer Einstellung mit mehreren Threads möglicherweise einen Teil der Schreibvorgänge des anderen Threads, bei denen use()
ready==true
und garbage in x
oder y
oder beiden sehen kann.
Mit dem C ++ - Speichermodell kann der Programmierer angeben, welche Umordnungsvorgänge zulässig sind und welche nicht, damit ein Multithread-Programm sich auch wie erwartet verhalten kann. Das obige Beispiel kann wie folgt auf Thread-sichere Weise umgeschrieben werden:
int x, y;
std::atomic<bool> ready{false};
void init()
{
x = 2;
y = 3;
ready.store(true, std::memory_order_release);
}
void use()
{
if (ready.load(std::memory_order_acquire))
std::cout << x + y;
}
Hier führt init()
eine atomare Speicherfreigabe durch. Dadurch wird nicht nur der Wert true
in ready
, sondern der Compiler wird auch darüber informiert, dass er diesen Vorgang nicht vor Schreibvorgängen verschieben kann, die zuvor sequenziert wurden .
Die use()
Funktion führt eine atomare Ladeerfassungsoperation aus . Es liest den aktuellen Wert von ready
und verbietet dem Compiler außerdem, Leseoperationen zu platzieren, die sequenziert werden , bevor sie vor der atomaren Ladeerfassung stattfinden .
Diese atomaren Operationen veranlassen den Compiler außerdem dazu, alle erforderlichen Hardwarebefehle einzugeben, um die CPU darüber zu informieren, dass sie unerwünschte Neuanordnungen unterlassen muss.
Da sich die atomare Speicherfreigabe am selben Speicherort befindet wie die atomare Ladeerfassung , schreibt das Speichermodell vor, dass, wenn die Ladeerfassungsoperation den von der Speicherfreigabeoperation geschriebenen Wert sieht, alle von init()
durchgeführten Schreibvorgänge s Faden vor dieser Speicher-Version wird zu Lasten sichtbar sein , die use()
, ‚s Thread führt nach seiner last akquirieren. Das heißt, wenn use()
ready==true
sieht, werden x==2
und y==3
garantiert y==3
.
Beachten Sie, dass der Compiler und die CPU noch vor dem Schreiben in x
nach y
schreiben dürfen. In ähnlicher Weise können die Lesevorgänge dieser Variablen in use()
in beliebiger Reihenfolge ausgeführt werden.
Zaun Beispiel
Das obige Beispiel kann auch mit Zäunen und entspannten atomaren Operationen implementiert werden:
int x, y;
std::atomic<bool> ready{false};
void init()
{
x = 2;
y = 3;
atomic_thread_fence(std::memory_order_release);
ready.store(true, std::memory_order_relaxed);
}
void use()
{
if (ready.load(std::memory_order_relaxed))
{
atomic_thread_fence(std::memory_order_acquire);
std::cout << x + y;
}
}
Wenn bei der atomaren Ladeoperation der vom Atomspeicher geschriebene Wert angezeigt wird, geschieht der Speicher vor der Last und auch die Zäune: Der Freigabezaun geschieht vor dem Erfassungszaun, wodurch Schreibvorgänge nach x
und y
, die vor dem Freigabezaun sichtbar werden an die std::cout
Anweisung, die dem std::cout
folgt.
Ein Zaun kann vorteilhaft sein, wenn dadurch die Gesamtzahl der Erfassungs-, Freigabe- oder sonstigen Synchronisationsvorgänge reduziert werden kann. Zum Beispiel:
void block_and_use()
{
while (!ready.load(std::memory_order_relaxed))
;
atomic_thread_fence(std::memory_order_acquire);
std::cout << x + y;
}
Die block_and_use()
Funktion dreht sich, bis das ready
Flag mit Hilfe einer entspannten block_and_use()
gesetzt wird. Dann wird ein einzelner Erfassungszaun verwendet, um die erforderliche Speicherreihenfolge bereitzustellen.