In C ++ 11 wurde ein standardisiertes Speichermodell eingeführt. Was genau bedeutet das? Und wie wird sich das auf die C ++ - Programmierung auswirken?
Dieser Artikel (von Gavin Clarke, der Herb Sutter zitiert ) sagt Folgendes:
Das Speichermodell bedeutet, dass C ++ - Code jetzt über eine standardisierte Bibliothek verfügt, die aufgerufen werden kann, unabhängig davon, wer den Compiler erstellt hat und auf welcher Plattform er ausgeführt wird. Es gibt eine Standardmethode, um zu steuern, wie verschiedene Threads mit dem Speicher des Prozessors kommunizieren.
"Wenn Sie über die Aufteilung von [Code] auf verschiedene Kerne sprechen, die im Standard enthalten sind, sprechen wir über das Speichermodell. Wir werden es optimieren, ohne die folgenden Annahmen zu brechen, die die Leute im Code treffen werden", sagte Sutter .
Nun, ich kann mir diesen und ähnliche online verfügbare Absätze merken (da ich seit meiner Geburt mein eigenes Gedächtnismodell habe: P) und sogar als Antwort auf Fragen anderer posten, aber um ehrlich zu sein, verstehe ich das nicht genau diese.
C ++ - Programmierer haben bereits zuvor Multithread-Anwendungen entwickelt. Wie spielt es also eine Rolle, ob es sich um POSIX-Threads, Windows-Threads oder C ++ 11-Threads handelt? Was sind die Vorteile? Ich möchte die Details auf niedriger Ebene verstehen.
Ich habe auch das Gefühl, dass das C ++ 11-Speichermodell irgendwie mit der Unterstützung von C ++ 11-Multithreading zusammenhängt, da ich diese beiden oft zusammen sehe. Wenn ja, wie genau? Warum sollten sie verwandt sein?
Da ich nicht weiß, wie die Interna von Multithreading funktionieren und was das Speichermodell im Allgemeinen bedeutet, helfen Sie mir bitte, diese Konzepte zu verstehen. :-)
Antworten:
Zuerst muss man lernen, wie ein Sprachanwalt zu denken.
Die C ++ - Spezifikation bezieht sich nicht auf einen bestimmten Compiler, ein bestimmtes Betriebssystem oder eine bestimmte CPU. Es bezieht sich auf eine abstrakte Maschine , die eine Verallgemeinerung tatsächlicher Systeme darstellt. In der Welt der Sprachanwälte besteht die Aufgabe des Programmierers darin, Code für die abstrakte Maschine zu schreiben. Die Aufgabe des Compilers besteht darin, diesen Code auf einer konkreten Maschine zu aktualisieren. Wenn Sie streng nach Spezifikation codieren, können Sie sicher sein, dass Ihr Code auf jedem System mit einem kompatiblen C ++ - Compiler kompiliert und ohne Änderungen ausgeführt wird, egal ob heute oder in 50 Jahren.
Die abstrakte Maschine in der C ++ 98 / C ++ 03-Spezifikation ist grundsätzlich Single-Threaded. Es ist daher nicht möglich, Multithread-C ++ - Code zu schreiben, der in Bezug auf die Spezifikation "vollständig portabel" ist. Die Spezifikation sagt nicht einmal etwas über die Atomizität aus von Speicherladevorgängen und -speichern oder die Reihenfolge aus, in der Ladevorgänge und Speicherungen auftreten können, ganz zu schweigen von Dingen wie Mutexen.
Natürlich können Sie in der Praxis Multithread-Code für bestimmte konkrete Systeme schreiben - wie z. B. Pthreads oder Windows. Aber es gibt keinen Standardmethode zum Schreiben von Multithread-Code für C ++ 98 / C ++ 03.
Die abstrakte Maschine in C ++ 11 ist vom Design her multithreaded. Es hat auch eine gut definierte Speichermodell ; Das heißt, es wird angegeben, was der Compiler beim Zugriff auf den Speicher tun darf und was nicht.
Betrachten Sie das folgende Beispiel, in dem zwei Threads gleichzeitig auf zwei globale Variablen zugreifen:
Was könnte Thread 2 ausgeben?
Unter C ++ 98 / C ++ 03 ist dies nicht einmal undefiniertes Verhalten. Die Frage selbst ist bedeutungslos da der Standard nichts betrachtet, was als "Thread" bezeichnet wird.
Unter C ++ 11 ist das Ergebnis Undefiniertes Verhalten, da Lasten und Speichern im Allgemeinen nicht atomar sein müssen. Was vielleicht nicht viel von einer Verbesserung zu sein scheint ... Und an sich ist es nicht.
Mit C ++ 11 können Sie jedoch Folgendes schreiben:
Jetzt wird es viel interessanter. Zunächst wird das Verhalten hier definiert . Thread 2 könnte jetzt gedruckt werden
0 0
(wenn er vor Thread 1 ausgeführt wird),37 17
(wenn er nach Thread 1 ausgeführt wird) oder0 17
(wenn er ausgeführt wird, nachdem Thread 1 x zugewiesen wurde, aber bevor er y zugewiesen wurde).Was nicht gedruckt werden kann, ist
37 0
, dass der Standardmodus für atomare Ladevorgänge / Speicher in C ++ 11 darin besteht, die sequentielle Konsistenz zu erzwingen . Dies bedeutet nur, dass alle Ladevorgänge und Speicher "so sein müssen", als ob sie in der Reihenfolge geschehen wären, in der Sie sie in jedem Thread geschrieben haben, während Operationen zwischen Threads verschachtelt werden können, wie es das System wünscht. Das Standardverhalten der Atomik bietet also sowohl Atomizität als auch Ordnung für Lasten und Speicher.Auf einer modernen CPU kann die Sicherstellung der sequentiellen Konsistenz teuer sein. Insbesondere wird der Compiler wahrscheinlich zwischen jedem Zugriff hier vollständige Speicherbarrieren emittieren. Aber wenn Ihr Algorithmus nicht ordnungsgemäße Ladevorgänge und Speicher tolerieren kann; dh wenn es Atomizität erfordert, aber keine Ordnung; Das heißt, wenn es
37 0
als Ausgabe dieses Programms toleriert werden kann, können Sie Folgendes schreiben:Je moderner die CPU, desto wahrscheinlicher ist dies schneller als im vorherigen Beispiel.
Wenn Sie nur bestimmte Ladungen und Speicher in Ordnung halten müssen, können Sie Folgendes schreiben:
Dies bringt uns zurück zu den bestellten Ladungen und Lagern - also
37 0
ist also keine mögliche Ausgabe mehr - aber dies mit minimalem Overhead. (In diesem trivialen Beispiel entspricht das Ergebnis der vollständigen sequentiellen Konsistenz. In einem größeren Programm wäre dies nicht der Fall.)Natürlich, wenn die einzigen Ausgänge, die Sie sehen möchten, sind
0 0
oder sind37 17
, können Sie den Originalcode einfach mit einem Mutex umschließen. Aber wenn Sie so weit gelesen haben, wissen Sie bestimmt schon, wie das funktioniert, und diese Antwort ist bereits länger als beabsichtigt :-).Unterm Strich also. Mutexe sind großartig und C ++ 11 standardisiert sie. Manchmal möchten Sie jedoch aus Leistungsgründen untergeordnete Grundelemente (z. B. das klassische doppelt überprüfte Sperrmuster ). Der neue Standard bietet Gadgets auf hoher Ebene wie Mutexe und Bedingungsvariablen sowie Gadgets auf niedriger Ebene wie Atomtypen und die verschiedenen Varianten der Speicherbarriere. Jetzt können Sie anspruchsvolle, leistungsstarke gleichzeitige Routinen vollständig in der vom Standard festgelegten Sprache schreiben und sicher sein, dass Ihr Code auf den Systemen von heute und morgen unverändert kompiliert und ausgeführt wird.
Um ehrlich zu sein, sollten Sie sich wahrscheinlich an Mutexe und Bedingungsvariablen halten, es sei denn, Sie sind Experte und arbeiten an ernsthaftem Code auf niedriger Ebene. Das habe ich vor.
Weitere Informationen zu diesem Thema finden Sie in diesem Blogbeitrag .
quelle
i = i++
. Das alte Konzept der Sequenzpunkte wurde verworfen. Der neue Standard spezifiziert dasselbe unter Verwendung einer Sequenz-vor- Beziehung, die nur ein Sonderfall des allgemeineren Inter-Thread -Vor-Vor- Konzepts ist.Ich werde nur die Analogie geben, mit der ich Speicherkonsistenzmodelle (oder kurz Speichermodelle) verstehe. Es ist inspiriert von Leslie Lamports wegweisendem Artikel "Zeit, Uhren und die Reihenfolge von Ereignissen in einem verteilten System" . Die Analogie ist zutreffend und von grundlegender Bedeutung, kann aber für viele Menschen übertrieben sein. Ich hoffe jedoch, dass es ein mentales Bild (eine bildliche Darstellung) liefert, das das Nachdenken über Gedächtniskonsistenzmodelle erleichtert.
Lassen Sie uns die Historien aller Speicherorte in einem Raum-Zeit-Diagramm anzeigen, in dem die horizontale Achse den Adressraum darstellt (dh jeder Speicherort wird durch einen Punkt auf dieser Achse dargestellt) und die vertikale Achse die Zeit darstellt (wir werden sehen, dass Im Allgemeinen gibt es keinen universellen Zeitbegriff. Der Verlauf der von jedem Speicherort gehaltenen Werte wird daher durch eine vertikale Spalte an dieser Speicheradresse dargestellt. Jede Wertänderung ist darauf zurückzuführen, dass einer der Threads einen neuen Wert an diese Stelle schreibt. Mit einem Speicherbild ist die Aggregation / Kombination von Werten aller Speicherorte gemeint, die zu einem bestimmten Zeitpunkt von einem bestimmten Thread beobachtet werden können .
Zitat aus "Ein Leitfaden zur Speicherkonsistenz und Cache-Kohärenz"
Diese globale Speicherreihenfolge kann von Programmlauf zu Programmlauf variieren und ist möglicherweise nicht im Voraus bekannt. Das charakteristische Merkmal von SC ist der Satz horizontaler Schichten im Adressraum-Zeit-Diagramm, die Gleichzeitigkeitsebenen darstellen (dh Speicherbilder). Auf einer bestimmten Ebene sind alle Ereignisse (oder Speicherwerte) gleichzeitig. Es gibt eine Vorstellung von absoluten Zeitkombinationen , in dem sich alle Threads darauf einigen, welche Speicherwerte gleichzeitig vorliegen. In SC gibt es zu jedem Zeitpunkt nur ein Speicherabbild, das von allen Threads gemeinsam genutzt wird. Das heißt, zu jedem Zeitpunkt sind sich alle Prozessoren über das Speicherbild einig (dh über den Gesamtinhalt des Speichers). Dies bedeutet nicht nur, dass alle Threads für alle Speicherorte dieselbe Wertesequenz anzeigen, sondern auch, dass alle Prozessoren dieselbe beobachtenKombinationen von Werten aller Variablen. Dies entspricht der Aussage, dass alle Speicheroperationen (an allen Speicherorten) von allen Threads in derselben Gesamtreihenfolge beobachtet werden.
In entspannten Speichermodellen schneidet jeder Thread die Adressraumzeit auf seine eigene Weise auf. Die einzige Einschränkung besteht darin, dass sich die Slices jedes Threads nicht kreuzen dürfen, da sich alle Threads (natürlich) auf den Verlauf jedes einzelnen Speicherorts einigen müssen , Scheiben unterschiedlicher Fäden können und werden sich kreuzen). Es gibt keine universelle Möglichkeit, es aufzuteilen (keine privilegierte Folierung von Adressraum-Zeit). Slices müssen nicht planar (oder linear) sein. Sie können gekrümmt sein, und dies kann dazu führen, dass ein Thread die von einem anderen Thread geschriebenen Werte in der Reihenfolge liest, in der sie geschrieben wurden. Historien verschiedener Speicherorte können bei Betrachtung durch einen bestimmten Thread beliebig relativ zueinander verschoben (oder gedehnt) werden. Jeder Thread hat eine andere Vorstellung davon, welche Ereignisse (oder gleichwertig Speicherwerte) gleichzeitig auftreten. Die Ereignisse (oder Speicherwerte), die gleichzeitig mit einem Thread ausgeführt werden, sind nicht gleichzeitig mit einem anderen Thread. Somit beobachten in einem entspannten Speichermodell alle Threads immer noch den gleichen Verlauf (dh die Folge von Werten) für jeden Speicherort. Sie können jedoch unterschiedliche Speicherbilder beobachten (dh Kombinationen von Werten aller Speicherorte). Selbst wenn zwei verschiedene Speicherorte nacheinander von demselben Thread geschrieben werden, können die beiden neu geschriebenen Werte von anderen Threads in unterschiedlicher Reihenfolge beobachtet werden.
[Bild aus Wikipedia]
Leser, die mit Einsteins spezieller Relativitätstheorie vertraut sind, werden bemerken, worauf ich anspreche. Minkowskis Worte in den Bereich der Speichermodelle übersetzen: Adressraum und Zeit sind Schatten der Adressraumzeit. In diesem Fall projiziert jeder Beobachter (dh Thread) Schatten von Ereignissen (dh Speicher / Speicher) auf seine eigene Weltlinie (dh seine Zeitachse) und seine eigene Ebene der Gleichzeitigkeit (seine Adressraumachse). . Threads im C ++ 11-Speichermodell entsprechen Beobachtern , die sich in spezieller Relativitätstheorie relativ zueinander bewegen. Die sequentielle Konsistenz entspricht der galiläischen Raumzeit (dh alle Beobachter sind sich über eine absolute Reihenfolge der Ereignisse und ein globales Gefühl der Gleichzeitigkeit einig).
Die Ähnlichkeit zwischen Speichermodellen und spezieller Relativitätstheorie beruht auf der Tatsache, dass beide eine teilweise geordnete Menge von Ereignissen definieren, die oft als kausale Menge bezeichnet wird. Einige Ereignisse (z. B. Speicher) können andere Ereignisse beeinflussen (aber nicht beeinflussen). Ein C ++ 11-Thread (oder Beobachter in der Physik) ist nicht mehr als eine Kette (dh eine vollständig geordnete Menge) von Ereignissen (z. B. Speicher wird geladen und an möglicherweise unterschiedliche Adressen gespeichert).
In der Relativitätstheorie wird eine gewisse Ordnung in das scheinbar chaotische Bild von teilweise geordneten Ereignissen wiederhergestellt, da die einzige zeitliche Reihenfolge, über die sich alle Beobachter einig sind, die Reihenfolge unter "zeitlichen" Ereignissen ist (dh jene Ereignisse, die im Prinzip durch ein Teilchen verbunden werden können, das langsamer wird als die Lichtgeschwindigkeit im Vakuum). Nur die zeitlichen Ereignisse werden immer geordnet. Zeit in Physik, Craig Callender .
Im C ++ 11-Speichermodell wird ein ähnlicher Mechanismus (das Erfassungsfreigabekonsistenzmodell) verwendet, um diese lokalen Kausalitätsbeziehungen herzustellen .
Um eine Definition der Speicherkonsistenz und eine Motivation für das Aufgeben von SC bereitzustellen, zitiere ich aus "Ein Leitfaden zur Speicherkonsistenz und Cache-Kohärenz".
Da Cache-Kohärenz und Speicherkonsistenz manchmal verwechselt werden, ist es aufschlussreich, auch dieses Zitat zu haben:
In Fortsetzung unseres mentalen Bildes entspricht die SWMR-Invariante der physikalischen Anforderung, dass sich höchstens ein Teilchen an einem Ort befindet, es jedoch eine unbegrenzte Anzahl von Beobachtern an jedem Ort geben kann.
quelle
Dies ist jetzt eine mehrjährige Frage, aber da sie sehr beliebt ist, sollte eine fantastische Ressource erwähnt werden, um mehr über das C ++ 11-Speichermodell zu erfahren. Ich sehe keinen Sinn darin, seinen Vortrag zusammenzufassen, um eine weitere vollständige Antwort zu erhalten, aber da dies der Typ ist, der den Standard tatsächlich geschrieben hat, denke ich, dass es sich lohnt, den Vortrag anzuschauen.
Herb Sutter hat einen dreistündigen Vortrag über das C ++ 11-Speichermodell mit dem Titel "Atomwaffen <>", das auf der Channel9-Site verfügbar ist - Teil 1 und Teil 2 . Der Vortrag ist ziemlich technisch und behandelt die folgenden Themen:
Der Vortrag geht nicht auf die API ein, sondern auf die Argumentation, den Hintergrund, unter der Haube und hinter den Kulissen (wussten Sie, dass dem Standard eine entspannte Semantik hinzugefügt wurde, nur weil POWER und ARM das synchronisierte Laden nicht effizient unterstützen?).
quelle
Dies bedeutet, dass der Standard jetzt Multithreading definiert und definiert, was im Kontext mehrerer Threads geschieht. Natürlich verwendeten die Leute unterschiedliche Implementierungen, aber das ist wie die Frage, warum wir eine haben sollten
std::string
wenn wir alle eine selbst gerolltestring
Klasse verwenden könnten .Wenn Sie über POSIX-Threads oder Windows-Threads sprechen, ist dies eine Illusion, da es sich tatsächlich um x86-Threads handelt, da es sich um eine Hardwarefunktion handelt, die gleichzeitig ausgeführt wird. Das C ++ 0x-Speichermodell bietet Garantien, unabhängig davon, ob Sie mit x86, ARM, MIPS oder anderen Funktionen arbeiten.
quelle
Für Sprachen, die kein Speichermodell angeben , schreiben Sie Code für die Sprache und das Speichermodell, die von der Prozessorarchitektur angegeben werden. Der Prozessor kann sich dafür entscheiden, Speicherzugriffe für die Leistung neu zu ordnen. Also, wenn Ihr Programm Datenrennen hat (ein Datum Rennen ist , wenn es möglich ist , für mehrere Kerne / hyper-Threads desselben Speicher gleichzeitig zuzugreifen) dann Ihrem Programm ist nicht Cross - Plattform wegen seiner Abhängigkeit von dem Prozessor - Speicher - Modell. In den Handbüchern der Intel- oder AMD-Software finden Sie Informationen dazu, wie die Prozessoren Speicherzugriffe neu anordnen können.
Sehr wichtig ist, dass Sperren (und Parallelitätssemantik mit Sperren) normalerweise plattformübergreifend implementiert werden. Wenn Sie also Standardsperren in einem Multithread-Programm ohne Datenrennen verwenden, müssen Sie sich keine Gedanken über plattformübergreifende Speichermodelle machen .
Interessanterweise verfügen Microsoft-Compiler für C ++ über eine Semantik für Volatile, die eine C ++ - Erweiterung darstellt, um das Fehlen eines Speichermodells in C ++ zu beheben. Http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80) .aspx . Angesichts der Tatsache, dass Windows nur unter x86 / x64 ausgeführt wird, sagt dies jedoch nicht viel aus (Intel- und AMD-Speichermodelle machen es einfach und effizient, die Semantik zum Erfassen / Freigeben in einer Sprache zu implementieren).
quelle
Wenn Sie Mutexe verwenden, um alle Ihre Daten zu schützen, sollten Sie sich wirklich keine Sorgen machen müssen. Mutexe haben immer ausreichende Bestell- und Sichtbarkeitsgarantien geliefert.
Wenn Sie nun Atomics oder sperrfreie Algorithmen verwendet haben, müssen Sie über das Speichermodell nachdenken. Das Speichermodell beschreibt genau, wann Atomics Ordnungs- und Sichtbarkeitsgarantien bietet, und bietet tragbare Zäune für handcodierte Garantien.
Bisher wurden Atomics mithilfe von Compiler-Intrinsics oder einer höheren Bibliothek durchgeführt. Zäune wären mit CPU-spezifischen Anweisungen (Speicherbarrieren) ausgeführt worden.
quelle
Die obigen Antworten beziehen sich auf die grundlegendsten Aspekte des C ++ - Speichermodells. In der Praxis
std::atomic<>
funktionieren die meisten Anwendungen "nur", zumindest bis der Programmierer überoptimiert (z. B. indem er versucht, zu viele Dinge zu entspannen).Es gibt einen Ort, an dem Fehler immer noch häufig sind: Sequenzsperren . Unter https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf finden Sie eine hervorragende und leicht zu lesende Diskussion der Herausforderungen . Sequenzsperren sind ansprechend, da der Leser das Schreiben in das Sperrwort vermeidet. Der folgende Code basiert auf Abbildung 1 des obigen technischen Berichts und zeigt die Herausforderungen bei der Implementierung von Sequenzsperren in C ++ auf:
So unintuitiv wie es zunächst scheint
data1
und seindata2
mussatomic<>
. Wenn sie nicht atomar sind, können siereader()
genau zur gleichen Zeit (in ) gelesen werden, in der sie geschrieben sind (inwriter()
). Nach dem C ++ - Speichermodell ist dies ein Rennen, auch wennreader()
die Daten nie tatsächlich verwendet werden . Wenn sie nicht atomar sind, kann der Compiler außerdem den ersten Lesevorgang jedes Werts in einem Register zwischenspeichern. Offensichtlich möchten Sie das nicht ... Sie möchten jede Iteration derwhile
Schleife erneut einlesenreader()
.Es reicht auch nicht aus, sie zu erstellen
atomic<>
und mit ihnen zuzugreifenmemory_order_relaxed
. Der Grund dafür ist , dass die von Seq liest (inreader()
) nur haben acquire Semantik. In einfachen Worten, wenn X und Y Speicherzugriffe sind, X vor Y steht, X keine Erfassung oder Freigabe ist und Y eine Erfassung ist, kann der Compiler Y vor X neu anordnen. Wenn Y der zweite Lesevorgang von seq und X war Wenn Daten gelesen werden, würde eine solche Neuordnung die Sperrimplementierung unterbrechen.Das Papier gibt einige Lösungen. Derjenige mit der besten Leistung heute ist wahrscheinlich derjenige, der ein
atomic_thread_fence
mit verwendetmemory_order_relaxed
Derjenige vor dem zweiten Lesen des seqlock verwendet. In der Zeitung ist es Abbildung 6. Ich reproduziere den Code hier nicht, weil jeder, der bisher gelesen hat, die Zeitung wirklich lesen sollte. Es ist präziser und vollständiger als dieser Beitrag.Das letzte Problem ist, dass es unnatürlich sein könnte, das zu machen
data
Variablen atomar . Wenn Sie Ihren Code nicht eingeben können, müssen Sie sehr vorsichtig sein, da das Umwandeln von nicht-atomar in atomar nur für primitive Typen zulässig ist. C ++ 20 soll hinzugefügt werdenatomic_ref<>
, wodurch dieses Problem leichter zu lösen ist.Zusammenfassend: Selbst wenn Sie glauben, das C ++ - Speichermodell zu verstehen, sollten Sie sehr vorsichtig sein, bevor Sie Ihre eigenen Sequenzsperren setzen.
quelle
C und C ++ wurden früher durch eine Ausführungsspur eines wohlgeformten Programms definiert.
Jetzt werden sie zur Hälfte durch eine Ausführungsspur eines Programms und zur Hälfte durch viele Ordnungen auf Synchronisationsobjekten definiert.
Dies bedeutet, dass diese Sprachdefinitionen überhaupt keine sinnvolle Methode sind, um diese beiden Ansätze zu mischen. Insbesondere ist die Zerstörung eines Mutex oder einer atomaren Variablen nicht genau definiert.
quelle