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

299

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?

Doron Yaacoby
quelle
6
Warum möchten Sie bei der Verwendung von Sperren sparen? Unerwünschte Sperren verlängern Ihr Programm um einige Nanosekunden. Können Sie sich wirklich nicht ein paar Nanosekunden leisten?
Eric Lippert

Antworten:

273

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:

Ohad Schneider
quelle
29
Ich würde dies ablehnen, wenn ich könnte. Es gibt viele interessante Informationen, aber sie beantworten seine Frage nicht wirklich. Er fragt nach der Verwendung des flüchtigen Schlüsselworts im Zusammenhang mit dem Sperren. Für eine Weile (vor 2.0 RT) musste das flüchtige Schlüsselwort verwendet werden, um einen statischen Feldthread ordnungsgemäß sicher zu machen, wenn die Feldinstanz einen Initialisierungscode im Konstruktor hatte (siehe AndrewTeks Antwort). In Produktionsumgebungen befindet sich noch viel 1.1 RT-Code, und die Entwickler, die ihn verwalten, sollten wissen, warum dieses Schlüsselwort vorhanden ist und ob es sicher zu entfernen ist.
Paul Easter
3
@PaulEaster der Tatsache , dass es kann für doulbe-geprüft Verriegelung ( in der Regel in dem Singletonmuster) verwendet werden , bedeutet nicht , dass es sollte . Sich auf das .NET-Speichermodell zu verlassen, ist wahrscheinlich eine schlechte Praxis - Sie sollten sich stattdessen auf das ECMA-Modell verlassen. Beispielsweise möchten Sie möglicherweise eines Tages auf Mono portieren, das möglicherweise ein anderes Modell hat. Ich muss auch verstehen, dass verschiedene Hardware-Architekturen Dinge ändern können. Weitere Informationen finden Sie unter: stackoverflow.com/a/7230679/67824 . Für bessere Singleton-Alternativen (für alle .NET-Versionen) siehe: csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider
6
Mit anderen Worten, die richtige Antwort auf die Frage lautet: Wenn Ihr Code zur 2.0-Laufzeit 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. In früheren Versionen der Laufzeit ist dies jedoch erforderlich, um statische Felder ordnungsgemäß zu überprüfen.
Paul Easter
3
Bedeutet dies, dass Sperren und flüchtige Variablen sich im folgenden Sinne gegenseitig ausschließen: Wenn ich Sperren für eine Variable verwendet habe, muss diese Variable nicht mehr als flüchtig deklariert werden?
Giorgim
4
@Giorgi ja - die von garantierten Speicherbarrieren volatilewerden aufgrund der Sperre vorhanden sein
Ohad Schneider
54

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.

Skizz
quelle
Dies ist C ++, aber das Prinzip gilt für C #.
Skizz
6
Eric Lippert schreibt, dass flüchtig in C ++ den Compiler nur daran hindert, einige Optimierungen durchzuführen, während flüchtig in C # zusätzlich eine gewisse Kommunikation zwischen den anderen Kernen / Prozessoren durchführt, um sicherzustellen, dass der neueste Wert gelesen wird.
Peter Huber
44

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:

  1. Thread 1 fragt, ob eine Variable null ist. //if(this.foo == null)
  2. Thread 1 bestimmt, dass die Variable null ist, und gibt daher eine Sperre ein. //lock(this.bar)
  3. Thread 1 fragt WIEDER, ob die Variable null ist. //if(this.foo == null)
  4. Thread 1 bestimmt weiterhin, 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 könnte ein zweiter Thread (während des Aufrufs von Thread 1 an den Konstruktor von Foo) eingehen und Folgendes erfahren:

  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 daher, 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 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

AndrewTek
quelle
2
Genau das sehe ich in einem Legacy-Code und habe mich darüber gewundert. Deshalb habe ich eine tiefere Forschung begonnen. Vielen Dank!
Peter Porfy
1
Ich verstehe nicht, wie Thread 2 Wert zuweisen würde 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
aufgehoben wurde
24

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.

Benoit
quelle
21

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.

Dr. Bob
quelle
13

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.

Joseph Daigle
quelle
3

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

Christina Katsakoula
quelle
0

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.

Aliaksei Maniuk
quelle
2
Dein Beispiel ist wirklich schlecht. Der Programmierer sollte niemals eine Erwartung an den Wert von _flag in der t2-Task haben, basierend auf der Tatsache, dass der Code von t1 zuerst geschrieben wird. Zuerst geschrieben! = Zuerst ausgeführt. Es spielt keine Rolle, ob der Compiler diese beiden Zeilen in t1 schaltet. Selbst wenn der Compiler diese Anweisungen nicht geändert hat, wird Ihre Console.WriteLne im Zweig else möglicherweise auch mit dem flüchtigen Schlüsselwort auf _flag ausgeführt.
Jakotheshadows
@jakotheshadows, du hast recht, ich habe meine Antwort bearbeitet. Meine Hauptidee war es zu zeigen, dass die erwartete Logik gebrochen werden kann, wenn wir t1 und t2 gleichzeitig
ausführen
0

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.

Paul Ostern
quelle
-4

Mehrere Threads können auf eine Variable zugreifen. Das neueste Update wird für die Variable sein

user2943601
quelle