Kann jemand eine gute Erklärung für das flüchtige Schlüsselwort in C # geben? Welche Probleme löst es und welche nicht? In welchen Fällen erspart es mir die Verwendung von Sperren?
Kann jemand eine gute Erklärung für das flüchtige Schlüsselwort in C # geben? Welche Probleme löst es und welche nicht? In welchen Fällen erspart es mir die Verwendung von Sperren?
Antworten:
Ich glaube nicht, dass es eine bessere Person gibt, um dies zu beantworten als Eric Lippert (Hervorhebung im Original):
In C # bedeutet "flüchtig" nicht nur "sicherstellen, dass der Compiler und der Jitter keine Code-Neuordnung durchführen oder Caching-Optimierungen für diese Variable registrieren". Es bedeutet auch, "die Prozessoren anzuweisen, alles zu tun, was sie tun müssen, um sicherzustellen, dass ich den neuesten Wert lese, auch wenn dies bedeutet, andere Prozessoren anzuhalten und sie dazu zu bringen, den Hauptspeicher mit ihren Caches zu synchronisieren".
Eigentlich ist das letzte bisschen eine Lüge. Die wahre Semantik flüchtiger Lese- und Schreibvorgänge ist erheblich komplexer als hier beschrieben. Tatsächlich garantieren sie nicht, dass jeder Prozessor seine Arbeit beendet und die Caches im / vom Hauptspeicher aktualisiert. Sie bieten vielmehr schwächere Garantien dafür, wie beobachtet werden kann, dass Speicherzugriffe vor und nach Lese- und Schreibvorgängen in Bezug zueinander angeordnet sind . Bestimmte Vorgänge wie das Erstellen eines neuen Threads, das Eingeben einer Sperre oder die Verwendung einer der Methoden der Interlocked-Familie bieten strengere Garantien für die Beobachtung der Reihenfolge. Wenn Sie weitere Informationen wünschen, lesen Sie die Abschnitte 3.10 und 10.5.3 der C # 4.0-Spezifikation.
Ehrlich gesagt, ich rate Ihnen davon ab, jemals ein volatiles Feld zu schaffen . Flüchtige Felder sind ein Zeichen dafür, dass Sie etwas geradezu Verrücktes tun: Sie versuchen, denselben Wert in zwei verschiedenen Threads zu lesen und zu schreiben, ohne eine Sperre einzurichten. Sperren garantieren, dass der in der Sperre gelesene oder geänderte Speicher konsistent ist, Sperren garantieren, dass jeweils nur ein Thread auf einen bestimmten Speicherblock zugreift, und so weiter. Die Anzahl der Situationen, in denen eine Sperre zu langsam ist, ist sehr gering, und die Wahrscheinlichkeit, dass Sie den Code falsch verstehen, weil Sie das genaue Speichermodell nicht verstehen, ist sehr groß. Ich versuche nicht, einen Low-Lock-Code zu schreiben, außer für die trivialsten Verwendungen von Interlocked-Operationen. Ich überlasse die Verwendung von "volatile" echten Experten.
Weitere Informationen finden Sie unter:
volatilewerden aufgrund der Sperre vorhanden sein
Wenn Sie etwas mehr über die Funktionsweise des flüchtigen Schlüsselworts erfahren möchten, ziehen Sie das folgende Programm in Betracht (ich verwende DevStudio 2005):
#include <iostream>
void main()
{
int j = 0;
for (int i = 0 ; i < 100 ; ++i)
{
j += i;
}
for (volatile int i = 0 ; i < 100 ; ++i)
{
j += i;
}
std::cout << j;
}
Unter Verwendung der standardmäßig optimierten (Release-) Compilereinstellungen erstellt der Compiler den folgenden Assembler (IA32):
void main()
{
00401000 push ecx
int j = 0;
00401001 xor ecx,ecx
for (int i = 0 ; i < 100 ; ++i)
00401003 xor eax,eax
00401005 mov edx,1
0040100A lea ebx,[ebx]
{
j += i;
00401010 add ecx,eax
00401012 add eax,edx
00401014 cmp eax,64h
00401017 jl main+10h (401010h)
}
for (volatile int i = 0 ; i < 100 ; ++i)
00401019 mov dword ptr [esp],0
00401020 mov eax,dword ptr [esp]
00401023 cmp eax,64h
00401026 jge main+3Eh (40103Eh)
00401028 jmp main+30h (401030h)
0040102A lea ebx,[ebx]
{
j += i;
00401030 add ecx,dword ptr [esp]
00401033 add dword ptr [esp],edx
00401036 mov eax,dword ptr [esp]
00401039 cmp eax,64h
0040103C jl main+30h (401030h)
}
std::cout << j;
0040103E push ecx
0040103F mov ecx,dword ptr [__imp_std::cout (40203Ch)]
00401045 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (402038h)]
}
0040104B xor eax,eax
0040104D pop ecx
0040104E ret
Mit Blick auf die Ausgabe hat der Compiler beschlossen, das ecx-Register zum Speichern des Werts der Variablen j zu verwenden. Für die nichtflüchtige Schleife (die erste) hat der Compiler i dem eax-Register zugewiesen. Ziemliech direkt. Es gibt jedoch ein paar interessante Bits - der Befehl lea ebx, [ebx] ist effektiv ein Multibyte-NOP-Befehl, so dass die Schleife zu einer 16-Byte-ausgerichteten Speicheradresse springt. Das andere ist die Verwendung von edx zum Inkrementieren des Schleifenzählers anstelle eines inc eax-Befehls. Der Befehl add reg, reg hat auf einigen IA32-Kernen eine geringere Latenz als der Befehl inc reg, jedoch nie eine höhere Latenz.
Nun zur Schleife mit dem flüchtigen Schleifenzähler. Der Zähler wird bei [esp] gespeichert und das flüchtige Schlüsselwort teilt dem Compiler mit, dass der Wert immer aus dem Speicher gelesen / in den Speicher geschrieben und niemals einem Register zugewiesen werden soll. Der Compiler geht sogar so weit, beim Aktualisieren des Zählerwerts kein Laden / Inkrementieren / Speichern in drei verschiedenen Schritten (Laden von eax, inc eax, Speichern von eax) durchzuführen, sondern der Speicher wird direkt in einer einzelnen Anweisung (einem Add Mem) geändert , reg). Die Art und Weise, wie der Code erstellt wurde, stellt sicher, dass der Wert des Schleifenzählers im Kontext eines einzelnen CPU-Kerns immer aktuell ist. Keine Operation an den Daten kann zu Beschädigung oder Datenverlust führen (daher wird das Laden / Inc / Store nicht verwendet, da sich der Wert während des Inc ändern kann und somit im Store verloren geht). Da Interrupts erst nach Abschluss des aktuellen Befehls bedient werden können,
Sobald Sie eine zweite CPU in das System einführen, schützt das flüchtige Schlüsselwort nicht davor, dass die Daten gleichzeitig von einer anderen CPU aktualisiert werden. Im obigen Beispiel müssten die Daten nicht ausgerichtet sein, um eine mögliche Beschädigung zu erhalten. Das flüchtige Schlüsselwort verhindert keine mögliche Beschädigung, wenn die Daten nicht atomar verarbeitet werden können. Wenn der Schleifenzähler beispielsweise vom Typ long long (64 Bit) ist, sind zwei 32-Bit-Operationen erforderlich, um den Wert in der Mitte zu aktualisieren was ein Interrupt auftreten kann und die Daten ändern.
Das flüchtige Schlüsselwort eignet sich also nur für ausgerichtete Daten, die kleiner oder gleich der Größe der nativen Register sind, sodass Operationen immer atomar sind.
Das flüchtige Schlüsselwort wurde für die Verwendung bei E / A-Operationen konzipiert, bei denen sich die E / A ständig ändern, aber eine konstante Adresse haben, z. B. ein UART-Gerät mit Speicherzuordnung, und der Compiler sollte den ersten von der Adresse gelesenen Wert nicht weiter verwenden.
Wenn Sie große Datenmengen verarbeiten oder über mehrere CPUs verfügen, benötigen Sie ein Sperrsystem auf höherer Ebene (OS), um den Datenzugriff ordnungsgemäß zu handhaben.
Wenn Sie .NET 1.1 verwenden, wird das Schlüsselwort volatile benötigt, wenn Sie das Sperren doppelt überprüfen. Warum? Denn vor .NET 2.0 kann das folgende Szenario dazu führen, dass ein zweiter Thread auf ein nicht null, jedoch nicht vollständig erstelltes Objekt zugreift:
Vor .NET 2.0 konnte this.foo die neue Instanz von Foo zugewiesen werden, bevor der Konstruktor ausgeführt wurde. In diesem Fall könnte ein zweiter Thread (während des Aufrufs von Thread 1 an den Konstruktor von Foo) eingehen und Folgendes erfahren:
Vor .NET 2.0 konnten Sie this.foo als flüchtig deklarieren, um dieses Problem zu umgehen. Seit .NET 2.0 müssen Sie das flüchtige Schlüsselwort nicht mehr verwenden, um eine doppelt überprüfte Sperrung durchzuführen.
Wikipedia hat tatsächlich einen guten Artikel über Double Checked Locking und geht kurz auf dieses Thema ein: http://en.wikipedia.org/wiki/Double-checked_locking
foo? Ist Thread 1 nicht this.bargesperrt und daher kann nur Thread 1 foo zu einem bestimmten Zeitpunkt initialisieren? Ich meine, Sie überprüfen den Wert, nachdem die Sperre wieder
Manchmal optimiert der Compiler ein Feld und verwendet ein Register, um es zu speichern. Wenn Thread 1 in das Feld schreibt und ein anderer Thread darauf zugreift, da der Update in einem Register (und nicht im Speicher) gespeichert wurde, erhält der 2. Thread veraltete Daten.
Sie können sich das flüchtige Schlüsselwort so vorstellen, dass es dem Compiler sagt: "Ich möchte, dass Sie diesen Wert im Speicher speichern." Dies garantiert, dass der 2. Thread den neuesten Wert abruft.
Von MSDN : Der flüchtige Modifikator wird normalerweise für ein Feld verwendet, auf das mehrere Threads zugreifen, ohne die lock-Anweisung zum Serialisieren des Zugriffs zu verwenden. Durch die Verwendung des flüchtigen Modifikators wird sichergestellt, dass ein Thread den aktuellsten Wert abruft, der von einem anderen Thread geschrieben wurde.
Die CLR optimiert gerne Anweisungen. Wenn Sie also auf ein Feld im Code zugreifen, greift sie möglicherweise nicht immer auf den aktuellen Wert des Felds zu (möglicherweise vom Stapel usw.). Durch Markieren eines Feldes als wird volatilesichergestellt, dass der Befehl auf den aktuellen Wert des Feldes zugreift. Dies ist nützlich, wenn der Wert (in einem nicht sperrenden Szenario) durch einen gleichzeitigen Thread in Ihrem Programm oder einen anderen im Betriebssystem ausgeführten Code geändert werden kann.
Sie verlieren offensichtlich einige Optimierungen, aber es hält den Code einfacher.
Ich fand diesen Artikel von Joydip Kanjilal sehr hilfreich!
When you mark an object or a variable as volatile, it becomes a candidate for volatile reads and writes. It should be noted that in C# all memory writes are volatile irrespective of whether you are writing data to a volatile or a non-volatile object. However, the ambiguity happens when you are reading data. When you are reading data that is non-volatile, the executing thread may or may not always get the latest value. If the object is volatile, the thread always gets the most up-to-date value
Ich lasse es einfach hier als Referenz
Der Compiler ändert manchmal die Reihenfolge der Anweisungen im Code, um sie zu optimieren. Normalerweise ist dies in einer Umgebung mit einem Thread kein Problem, in einer Umgebung mit mehreren Threads kann dies jedoch ein Problem sein. Siehe folgendes Beispiel:
private static int _flag = 0;
private static int _value = 0;
var t1 = Task.Run(() =>
{
_value = 10; /* compiler could switch these lines */
_flag = 5;
});
var t2 = Task.Run(() =>
{
if (_flag == 5)
{
Console.WriteLine("Value: {0}", _value);
}
});
Wenn Sie t1 und t2 ausführen, erwarten Sie keine Ausgabe oder "Wert: 10" als Ergebnis. Es kann sein, dass der Compiler die Zeile innerhalb der Funktion t1 wechselt. Wenn t2 dann ausgeführt wird, könnte es sein, dass _flag den Wert 5 hat, aber _value den Wert 0. Die erwartete Logik könnte also unterbrochen werden.
Um dies zu beheben, können Sie ein flüchtiges Schlüsselwort verwenden, das Sie auf das Feld anwenden können. Diese Anweisung deaktiviert die Compiler-Optimierungen, sodass Sie die richtige Reihenfolge in Ihrem Code erzwingen können.
private static volatile int _flag = 0;
Sie sollten volatile nur verwenden, wenn Sie es wirklich benötigen, da es bestimmte Compiler-Optimierungen deaktiviert und die Leistung beeinträchtigt. Es wird auch nicht von allen .NET-Sprachen unterstützt (Visual Basic unterstützt es nicht), sodass die Sprachinteroperabilität beeinträchtigt wird.
Zusammenfassend lässt sich sagen, dass die richtige Antwort auf die Frage lautet: Wenn Ihr Code zur Laufzeit 2.0 oder höher ausgeführt wird, wird das flüchtige Schlüsselwort fast nie benötigt und schadet mehr als es nützt, wenn es unnötig verwendet wird. IE Verwenden Sie es nie. ABER in früheren Versionen der Laufzeit wird es für eine ordnungsgemäße Überprüfung der statischen Felder benötigt. Insbesondere statische Felder, deren Klasse über einen statischen Klasseninitialisierungscode verfügt.
Mehrere Threads können auf eine Variable zugreifen. Das neueste Update wird für die Variable sein