Lassen Sie mich versuchen, diese komplizierte Frage zu klären, indem Sie es auflösen.
Was ist "Einführung lesen"?
"Lesen Sie Einführung" ist eine Optimierung, wodurch der Code:
public static Foo foo; // I can be changed on another thread!
void DoBar() {
Foo fooLocal = foo;
if (fooLocal != null) fooLocal.Bar();
}
optimiert wird durch die lokale Variable zu eliminieren. Der Compiler kann das , wenn es nur einen Thread gibt dann foo
und fooLocal
sind das gleiche. Der Compiler darf explizit jede Optimierung vornehmen, die für einen einzelnen Thread unsichtbar wäre, selbst wenn er in einem Multithread-Szenario sichtbar wird. Dem Compiler ist es daher erlaubt, dies wie folgt umzuschreiben:
void DoBar() {
if (foo != null) foo.Bar();
}
Und jetzt gibt es eine Race-Bedingung. Wenn foo
nach der Überprüfung von Nicht-Null auf Null wechselt, ist es möglich, dass foo
ein zweites Mal gelesen wird, und das zweite Mal könnte es null sein, was dann zum Absturz führen würde. Aus der Perspektive der Person, die den Crash-Dump diagnostiziert, wäre dies völlig mysteriös.
Kann das tatsächlich passieren?
Als Artikel, den Sie heraus aufgerufen verknüpft:
Beachten Sie, dass Sie nicht in der Lage sein, die Nullreferenceexception in .NET Framework 4.5 auf x86-x64 mit diesem Code Probe zu reproduzieren. Die Einführung zu lesen ist in .NET Framework 4.5 sehr schwierig zu reproduzieren, tritt jedoch unter bestimmten Umständen dennoch auf.
x86/x64-Chips haben ein "starkes" Speichermodell und die JIT-Compiler sind in diesem Bereich nicht aggressiv; Sie werden diese Optimierung nicht machen.
Wenn Sie Ihren Code auf einem schwachen Speichermodellprozessor wie einem ARM-Chip ausführen, sind alle Wetten deaktiviert.
Wenn Sie "den Compiler" sagen, welchen Compiler meinen Sie?
Ich meine den JIT-Compiler. Der C# -Compiler führt auf diese Weise keine Lesevorgänge ein. (Es ist erlaubt, aber in der Praxis tut es nie.)
Ist es nicht eine schlechte Praxis, Speicher zwischen Threads ohne Speicherbarrieren zu teilen?
Ja. Hier sollte etwas unternommen werden, um eine Speicherbarriere einzuführen, da der Wert foo
bereits ein veralteter Cache-Wert im Prozessorcache sein könnte. Meine Präferenz für die Einführung einer Speicherbarriere besteht darin, eine Sperre zu verwenden. Sie können auch das Feld volatile
verwenden oder VolatileRead
verwenden oder eine der Methoden Interlocked
verwenden. All dies führt zu einer Speicherbarriere. (volatile
führt nur einen "halben Zaun" FYI ein.)
Nur weil es eine Speicherbarriere gibt, bedeutet das nicht unbedingt, dass Leseeinführungsoptimierungen nicht durchgeführt werden. Der Jitter ist jedoch weit weniger aggressiv bei der Verfolgung von Optimierungen, die sich auf Code auswirken, der eine Speicherbarriere enthält.
Gibt es andere Gefahren dieses Muster?
Sicher! Nehmen wir an, es gibt keine Leseeinführungen. Sie haben immer noch eine Race-Bedingung. Was passiert, wenn ein anderer Thread foo
nach der Prüfung auf null setzt, und auch den globalen Status ändert, der Bar
verbraucht? Jetzt haben Sie zwei Threads, von denen einer glaubt, dass foo
nicht null ist und der globale Status für einen Aufruf von Bar
OK ist, und ein anderer Thread, der das Gegenteil glaubt, und Sie Bar
ausgeführt werden. Dies ist ein Rezept für eine Katastrophe.
Was ist die beste Vorgehensweise hier?
Erstens, nicht gemeinsamen Speicher über Threads. Diese ganze Idee, dass es zwei Fäden der Kontrolle innerhalb der Hauptlinie Ihres Programms gibt, ist einfach verrückt, um damit zu beginnen. Es hätte niemals eine Sache sein sollen.Verwenden Sie Threads als leichtgewichtige Prozesse. Geben Sie ihnen eine unabhängige Aufgabe, die nicht mit dem Hauptprogramm des Programms interagiert, und verwenden Sie sie nur, um rechenintensive Arbeit durchzuführen.
Zweitens werden Sperren verwenden, um den Speicher auf Threads zu serialisieren. Sperren sind billig, wenn sie nicht angefochten werden, und wenn Sie Streit haben, dann beheben Sie dieses Problem. Low-Lock- und No-Lock-Lösungen sind bekanntermaßen schwer zu korrigieren.
Drittens, wenn Sie Speicher über Threads teilen werden dann jede einzelne Methode, die Sie aufrufen, die beinhaltet, dass der gemeinsame Speicher entweder angesichts der Rennbedingungen robust sein muss, oder die Rennen müssen eliminiert werden. Das ist eine schwere Last, und deshalb sollten Sie nicht dorthin gehen.
Mein Punkt ist: lesen Einführungen sind gruselig, aber ehrlich gesagt sind sie die geringsten Ihrer Sorgen, wenn Sie Code schreiben, der fröhlich Speicher über Threads teilt. Es gibt tausend und andere Dinge, um die wir uns zuerst kümmern müssen.
Ein JIT-Compiler ändern Code Semantik, macht mir Sorgen. Vielleicht war es ein JIT-Fehler vor .NET 4.5. – leppie
Yup, das sieht definitiv wie ein JIT-Bug aus. In der Tat würde ich es als einen Fehler klassifizieren, egal was passiert. Der Artikel ist ein bisschen nutzlos - er erwähnt das Konzept, sagt aber nicht wirklich, wie es vorkommt und wie man sich dagegen wehrt. –
Der Lese-Einführungsteil erwähnt Multi-Threading nicht explizit, aber das Intro sagt: "Der Compiler und die Hardware können die Speicheroperationen eines Programms auf eine Art und Weise transformieren, die Singlethread-Verhalten nicht beeinflusst, aber Multithreading beeinflussen kann Verhalten.". Verwenden Sie eine Sperre wäre die Antwort auf Ihre Frage in diesem Fall. – rene