373 Stimmen

Wann sollte das Schlüsselwort volatile in C# verwendet werden?

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?

313voto

Ohad Schneider Punkte 34748

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:

54voto

Skizz Punkte 66931

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.

48voto

AndrewTek Punkte 471

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:

  1. Thread 1 fragt, ob eine Variable Null ist. //if(this.foo == null)
  2. Thread 1 stellt fest, dass die Variable null ist, und gibt eine Sperre ein. //sperren(dies.bar)
  3. Thread 1 fragt noch einmal, ob die Variable Null ist. //if(this.foo == null)
  4. 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:

  1. Thread 2 fragt, ob die Variable Null ist. //if(this.foo == null)
  2. 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

35voto

Benoit Punkte 36144

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.

24voto

Dr. Bob Punkte 534

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.

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X