Ich verwende Cygwin GCC und führe diesen Code aus:
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
unsigned u = 0;
void foo()
{
u++;
}
int main()
{
vector<thread> threads;
for(int i = 0; i < 1000; i++) {
threads.push_back (thread (foo));
}
for (auto& t : threads) t.join();
cout << u << endl;
return 0;
}
Zusammengestellt mit der Zeile : g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o
.
Es werden 1000 gedruckt, was korrekt ist. Ich habe jedoch eine geringere Anzahl erwartet, da Threads einen zuvor inkrementierten Wert überschreiben. Warum leidet dieser Code nicht unter gegenseitigem Zugriff?
Mein Testgerät hat 4 Kerne und ich habe dem mir bekannten Programm keine Einschränkungen auferlegt.
Das Problem besteht weiterhin, wenn der Inhalt des freigegebenen foo
durch etwas Komplexeres ersetzt wird, z
if (u % 3 == 0) {
u += 4;
} else {
u -= 1;
}
c++
race-condition
Mafu
quelle
quelle
u
in den Speicher zurückgeschrieben. Die CPU wird tatsächlich erstaunliche Dinge tun, z. B. feststellen, dass sich die Speicherzeile füru
nicht im Cache der CPU befindet, und den Inkrementierungsvorgang neu starten. Aus diesem Grund kann der Wechsel von x86 zu anderen Architekturen eine Erfahrung sein, die die Augen öffnet!while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;
999 oder 998 auf meinem System drucken.Antworten:
foo()
ist so kurz, dass jeder Thread wahrscheinlich beendet wird, bevor der nächste überhaupt erzeugt wird. Wenn Sie einen zufälligen Schlaffoo()
vor dem hinzufügenu++
, sehen Sie möglicherweise, was Sie erwarten.quelle
Es ist wichtig zu verstehen, dass eine Rennbedingung nicht garantiert, dass der Code falsch ausgeführt wird, sondern lediglich, dass er alles kann, da es sich um ein undefiniertes Verhalten handelt. Einschließlich Laufen wie erwartet.
Insbesondere auf X86- und AMD64-Maschinen verursachen die Rennbedingungen in einigen Fällen selten Probleme, da viele der Anweisungen atomar sind und die Kohärenzgarantien sehr hoch sind. Diese Garantien sind bei Multiprozessorsystemen etwas reduziert, bei denen das Sperrpräfix benötigt wird, damit viele Anweisungen atomar sind.
Wenn es sich bei Ihrem Inkrement auf Ihrem Computer um eine atomare Operation handelt, wird diese wahrscheinlich korrekt ausgeführt, obwohl es sich laut Sprachstandard um undefiniertes Verhalten handelt.
Insbesondere erwarte ich in diesem Fall, dass der Code möglicherweise zu einem atomaren Fetch and Add- Befehl (ADD oder XADD in X86-Assembly) kompiliert wird, der in Einzelprozessorsystemen tatsächlich atomar ist. Auf Multiprozessorsystemen ist dies jedoch nicht garantiert atomar und eine Sperre wäre erforderlich, um es so zu machen. Wenn Sie auf einem Multiprozessorsystem arbeiten, wird ein Fenster angezeigt, in dem Threads stören und falsche Ergebnisse erzielen können.
Insbesondere habe ich Ihren Code mithilfe von https://godbolt.org/ zur Assembly
foo()
kompiliert und Folgendes kompiliert:Dies bedeutet, dass ausschließlich eine Additionsanweisung ausgeführt wird, die für einen einzelnen Prozessor atomar ist (obwohl dies, wie oben erwähnt, für ein Multiprozessorsystem nicht der Fall ist).
quelle
inc [u]
ist nicht atomar. DasLOCK
Präfix ist erforderlich, um eine Anweisung wirklich atomar zu machen. Das OP hat einfach Glück. Denken Sie daran, dass die CPU, obwohl Sie der CPU sagen, dass sie dem Wort an dieser Adresse 1 hinzufügen soll, diesen Wert abrufen, inkrementieren, speichern muss und eine andere CPU dasselbe gleichzeitig tun kann, was dazu führt, dass das Ergebnis falsch ist.Ich denke, es ist nicht so sehr die Sache, wenn Sie vor oder nach dem schlafen
u++
. Es ist vielmehr so, dass die Operationu++
in Code übersetzt wird, der - verglichen mit dem Overhead der aufrufenden Spawning-Threadsfoo
- sehr schnell ausgeführt wird, so dass es unwahrscheinlich ist, dass er abgefangen wird. Wenn Sie jedoch die Operation "verlängern"u++
, wird die Rennbedingung viel wahrscheinlicher:Ergebnis:
694
Übrigens: Ich habe es auch versucht
und es gab mir meistens
1997
, aber manchmal1995
.quelle
else u -= 1
jemals ausgeführt werden? Selbst in einer parallelen Umgebung sollte der Wert niemals nicht passen%2
, nicht wahr?else u -= 1
einmal ausgeführt, wenn foo () zum ersten Mal aufgerufen wird, wenn u == 0. Die verbleibenden 999 mal u ist ungerade undu += 2
werden ausgeführt, was zu u = -1 + 999 * 2 = 1997 führt. dh die richtige Ausgabe. Eine Rennbedingung führt manchmal dazu, dass eines der + = 2 durch einen parallelen Thread überschrieben wird und Sie 1995 erhalten.Es leidet unter einer Rennbedingung. Setzen Sie
usleep(1000);
vorheru++;
einfoo
und ich sehe jedes Mal eine andere Ausgabe (<1000).quelle
Die wahrscheinliche Antwort darauf, warum sich die Racebedingung für Sie nicht manifestiert hat, obwohl sie existiert, ist, dass sie im Vergleich zu der Zeit, die zum Starten eines Threads benötigt wird,
foo()
so schnell ist, dass jeder Thread beendet wird, bevor der nächste überhaupt starten kann. Aber...Selbst mit Ihrer Originalversion variiert das Ergebnis je nach System: Ich habe es auf einem (Quad-Core-) Macbook versucht und in zehn Durchläufen dreimal 1000, sechsmal sechsmal und einmal 998 Mal. Das Rennen ist also etwas selten, aber deutlich präsent.
Sie haben mit kompiliert
'-g'
, wodurch Fehler verschwinden können. Ich habe Ihren Code neu kompiliert, immer noch unverändert, aber ohne'-g'
, und das Rennen wurde viel ausgeprägter: Ich habe 1000 einmal, 999 dreimal, 998 zweimal, 997 zweimal, 996 einmal und 992 einmal.Re. Der Vorschlag, einen Schlaf hinzuzufügen - das hilft, aber (a) eine feste Schlafzeit lässt die Threads immer noch durch die Startzeit verzerrt (abhängig von der Timer-Auflösung), und (b) ein zufälliger Schlaf verteilt sie, wenn wir wollen ziehe sie näher zusammen. Stattdessen würde ich sie codieren, um auf ein Startsignal zu warten, damit ich sie alle erstellen kann, bevor ich sie zur Arbeit bringen kann. Mit dieser Version (mit oder ohne
'-g'
) erhalte ich überall Ergebnisse, so niedrig wie 974 und nicht höher als 998:quelle
-g
Flagge lässt in keiner Weise "Fehler verschwinden". Das-g
Flag auf GNU- und Clang-Compilern fügt der kompilierten Binärdatei einfach Debug-Symbole hinzu. Auf diese Weise können Sie Diagnosetools wie GDB und Memcheck in Ihren Programmen mit einer von Menschen lesbaren Ausgabe ausführen. Wenn Memcheck beispielsweise über ein Programm mit einem Speicherverlust ausgeführt wird, wird die Zeilennummer nur angezeigt, wenn das Programm mit dem-g
Flag erstellt wurde.-O2
anstelle von-g
“. Aber das heißt, wenn Sie noch nie die Freude hatten, einen Fehler zu jagen, der sich nur manifestieren würde, wenn Sie ihn ohne kompilieren würden-g
, sollten Sie sich glücklich schätzen. Es kann passieren, mit einigen der schlimmsten subtilen Aliasing-Fehler. Ich habe es gesehen, wenn auch nicht in letzter Zeit, und ich konnte glauben, dass es vielleicht eine Eigenart eines alten proprietären Compilers war, also werde ich Ihnen vorläufig über moderne Versionen von GNU und Clang glauben.-g
hindert Sie nicht daran, Optimierungen zu verwenden. zBgcc -O3 -g
macht das gleiche asm wiegcc -O3
, aber mit Debug-Metadaten. gdb sagt jedoch "optimiert", wenn Sie versuchen, einige Variablen zu drucken.-g
könnte möglicherweise die relativen Positionen einiger Dinge im Speicher ändern, wenn eines der hinzugefügten Dinge Teil des.text
Abschnitts ist. Es nimmt definitiv Platz in der Objektdatei ein, aber ich denke, nach dem Verknüpfen endet alles an einem Ende des Textsegments (nicht im Abschnitt) oder überhaupt nicht Teil eines Segments. Könnte sich möglicherweise darauf auswirken, wo Dinge für dynamische Bibliotheken zugeordnet sind.