Kann jemand eine gute Erklärung des volatile-Schlüsselworts in C# bieten? Welche Probleme löst es und welche nicht? In welchen Fällen erspart es mir die Verwendung von Sperren?
Antworten
Zu viele Anzeigen?Ich glaube nicht, dass es eine bessere Person für diese Frage gibt als Eric Lippert (Hervorhebung im Original):
In C# bedeutet "flüchtig" nicht nur "stellen Sie sicher, dass der Compiler und die Jitter keine Umordnung des Codes vornehmen Optimierungen an dieser Variablen vornehmen". Es bedeutet auch "sag den Prozessoren, dass sie tun, was immer sie tun müssen, um sicherzustellen, dass ich den neuesten Wert zu lesen, auch wenn das bedeutet, andere Prozessoren anzuhalten und Hauptspeicher mit ihren Caches zu synchronisieren".
Eigentlich ist der letzte Teil eine Lüge. Die wahre Semantik von flüchtigen Lese- und Schreibvorgängen und Schreibvorgängen ist wesentlich komplexer, als ich hier skizziert habe; in tatsächlich sie garantieren nicht, dass jeder Prozessor das anhält, was er tut und aktualisiert Caches in/aus dem Hauptspeicher. Eher nicht, t schwächere Garantien darüber, wie Speicherzugriffe vor und nach Lese- und Schreibvorgängen in Bezug auf die Reihenfolge zueinander beobachtet werden können . Bestimmte Vorgänge wie das Erstellen eines neuen Threads, das Eingeben einer Sperre oder Verwendung eines der Interlocked f Garantien für die Einhaltung der Reihenfolge. Wenn Sie mehr Details wünschen, lesen Sie die Abschnitte 3.10 und 10.5.3 der C# 4.0 Spezifikation.
Offen gesagt, Ich rate Ihnen davon ab, jemals ein unbeständiges Feld zu schaffen . Flüchtige Felder sind ein Zeichen dafür, dass Sie etwas geradezu Verrücktes tun: Sie versuchen versuchen, denselben Wert in zwei verschiedenen Threads zu lesen und zu schreiben ohne eine Sperre einzurichten. Sperren garantieren, dass Speicher, der gelesen oder Sperren garantieren, dass Speicher, der innerhalb der Sperre gelesen oder geändert wird, konsistent ist, Sperren garantieren dass jeweils nur ein Thread auf einen bestimmten Speicherbereich zugreift, und so und so weiter. Die Zahl der Situationen, in denen eine Sperre zu langsam ist, ist sehr sehr gering, und die Wahrscheinlichkeit, dass man den Code falsch versteht weil man das genaue Speichermodell nicht versteht, ist sehr groß. I versuche nicht, Low-Lock-Code zu schreiben, außer für die trivialsten trivialsten Verwendungen von Interlocked-Operationen. Die Verwendung von "volatile" überlasse ich echten Experten.
Für weitere Informationen siehe:
Wenn Sie etwas genauer wissen wollen, was das Schlüsselwort volatile bewirkt, sehen Sie sich das folgende Programm an (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äßigen optimierten (Release-)Compiler-Einstellungen 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
Die Ausgabe zeigt, dass der Compiler beschlossen hat, das Register ecx zu verwenden, um den Wert der Variablen j zu speichern. Für die nichtflüchtige Schleife (die erste) hat der Compiler i dem Register eax zugewiesen. Ziemlich geradlinig. Es gibt jedoch ein paar interessante Bits - die Anweisung lea ebx,[ebx] ist praktisch eine Multibyte-Nop-Anweisung, so dass die Schleife zu einer 16 Byte ausgerichteten Speicheradresse springt. Die andere ist die Verwendung von edx, um den Schleifenzähler zu erhöhen, anstatt eine inc eax-Anweisung zu verwenden. Die Anweisung add reg,reg hat auf einigen IA32-Kernen eine geringere Latenzzeit als die Anweisung inc reg, aber niemals eine höhere Latenzzeit.
Nun zur Schleife mit dem flüchtigen Schleifenzähler. Der Zähler wird unter [esp] gespeichert, und das Schlüsselwort "volatile" teilt dem Compiler mit, dass der Wert immer aus dem Speicher gelesen bzw. in den Speicher geschrieben und niemals einem Register zugewiesen werden soll. Der Compiler geht sogar so weit, dass er bei der Aktualisierung des Zählerwerts kein Laden/Erhöhen/Speichern in drei verschiedenen Schritten (load eax, inc eax, save eax) durchführt, sondern den Speicher direkt mit einem einzigen Befehl (add mem,reg) verändert. 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 einer Verfälschung oder einem Datenverlust führen (daher auch der Verzicht auf load/inc/store, da sich der Wert während des inc ändern kann und beim store verloren geht). Da Interrupts erst bedient werden können, wenn die aktuelle Anweisung abgeschlossen ist, können die Daten auch bei nicht ausgerichteten Speichern nie beschädigt werden.
Sobald Sie eine zweite CPU in das System einführen, schützt das Schlüsselwort volatile nicht mehr davor, dass die Daten gleichzeitig von einer anderen CPU aktualisiert werden. Im obigen Beispiel müssten die Daten unaligned sein, um eine mögliche Beschädigung zu erhalten. Das Schlüsselwort "volatile" verhindert eine mögliche Beschädigung nicht, wenn die Daten nicht atomar verarbeitet werden können, z. B. wenn der Schleifenzähler vom Typ long long (64 Bits) wäre, dann wären zwei 32-Bit-Operationen erforderlich, um den Wert zu aktualisieren, wobei in der Mitte eine Unterbrechung auftreten und die Daten ändern kann.
Das Schlüsselwort volatile ist also nur für ausgerichtete Daten geeignet, die kleiner oder gleich der Größe der nativen Register sind, so dass die Operationen immer atomar sind.
Das volatile-Schlüsselwort wurde für IO-Operationen konzipiert, bei denen sich das IO ständig ändert, aber eine konstante Adresse hat, wie z.B. bei einem UART-Gerät, das im Speicher abgebildet wird, und der Compiler nicht den ersten Wert, der von der Adresse gelesen wird, wieder verwenden sollte.
Wenn Sie große Datenmengen verarbeiten oder mehrere CPUs haben, benötigen Sie ein Sperrsystem auf höherer Ebene (Betriebssystem), um den Datenzugriff ordnungsgemäß abzuwickeln.
Wenn Sie .NET 1.1 verwenden, ist das Schlüsselwort volatile erforderlich, wenn Sie doppelt geprüftes Sperren durchführen. Warum? Weil vor .NET 2.0 das folgende Szenario dazu führen konnte, dass ein zweiter Thread auf ein nicht leeres, aber nicht vollständig konstruiertes Objekt zugriff:
- Thread 1 fragt, ob eine Variable Null ist. //if(this.foo == null)
- Thread 1 stellt fest, dass die Variable null ist, und gibt eine Sperre ein. //sperren(dies.bar)
- Thread 1 fragt noch einmal, ob die Variable Null ist. //if(this.foo == null)
- Thread 1 stellt immer noch fest, dass die Variable null ist, ruft also einen Konstruktor auf und weist der Variablen den Wert zu. //this.foo = new Foo();
Vor .NET 2.0 konnte this.foo die neue Instanz von Foo zugewiesen werden, bevor der Konstruktor ausgeführt wurde. In diesem Fall konnte ein zweiter Thread (während des Aufrufs von Thread 1 an den Konstruktor von Foo) eintreten und Folgendes erleben:
- Thread 2 fragt, ob die Variable Null ist. //if(this.foo == null)
- Thread 2 stellt fest, dass die Variable NICHT null ist, und versucht, sie zu verwenden. //this.foo.MakeFoo()
Vor .NET 2.0 konnten Sie this.foo als flüchtig deklarieren, um dieses Problem zu umgehen. Seit .NET 2.0 brauchen Sie das Schlüsselwort volatile nicht mehr zu verwenden, um eine doppelt geprüfte Sperrung zu erreichen.
Wikipedia hat einen guten Artikel über Double Checked Locking und geht kurz auf dieses Thema ein: http://en.wikipedia.org/wiki/Double-checked_locking
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, würde der zweite Thread veraltete Daten erhalten, da die Aktualisierung in einem Register (und nicht im Speicher) gespeichert wurde.
Man kann sich das Schlüsselwort volatile so vorstellen, dass es dem Compiler sagt: "Ich möchte, dass du diesen Wert im Speicher speicherst". Dies garantiert, dass der 2. Thread den neuesten Wert abruft.
En MSDN : Der flüchtige Modifikator wird in der Regel für ein Feld verwendet, auf das mehrere Threads zugreifen, ohne die Sperranweisung zur Serialisierung des Zugriffs zu verwenden. Die Verwendung des flüchtigen Modifikators stellt sicher, dass ein Thread den aktuellsten Wert abruft, der von einem anderen Thread geschrieben wurde.
- See previous answers
- Weitere Antworten anzeigen