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:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
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:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17); cout << y.load() << " ";
y.store(37); cout << x.load() << endl;
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) oder 0 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:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl;
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:
Global
atomic<int> x, y;
Thread 1 Thread 2
x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl;
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 sind 37 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 .