Kiedy zmienne słowo kluczowe powinno być używane w C #?


299

Czy ktoś może podać dobre wyjaśnienie niestabilnego słowa kluczowego w języku C #? Które problemy rozwiązuje, a które nie? W jakich przypadkach zaoszczędzi mi to korzystania z blokowania?


6
Dlaczego chcesz zaoszczędzić na korzystaniu z blokady? Bezkontaktowe blokady dodają kilka nanosekund do twojego programu. Czy naprawdę nie stać Cię na kilka nanosekund?
Eric Lippert,

Odpowiedzi:


273

Nie sądzę, że jest lepsza osoba, aby odpowiedzieć na to pytanie niż Eric Lippert (podkreślenie w oryginale):

W języku C # „niestabilny” oznacza nie tylko „upewnij się, że kompilator i jitter nie wykonują żadnej zmiany kolejności kodu ani nie rejestrują optymalizacji buforowania dla tej zmiennej”. Oznacza to również „powiedz procesorom, aby zrobiły wszystko, co trzeba, aby upewnić się, że czytam najnowszą wartość, nawet jeśli oznacza to zatrzymanie innych procesorów i zmusienie ich do synchronizacji pamięci głównej z ich pamięciami podręcznymi”.

Właściwie ten ostatni kawałek to kłamstwo. Prawdziwa semantyka niestabilnych odczytów i zapisów jest znacznie bardziej złożona, niż tu nakreśliłem; w rzeczywistości nie gwarantują, że każdy procesor zatrzyma to, co robi i zaktualizuje pamięci podręczne do / z pamięci głównej. Dają raczej słabsze gwarancje dotyczące tego, w jaki sposób dostęp do pamięci przed i po odczytach i zapisach można zaobserwować względem siebie . Niektóre operacje, takie jak utworzenie nowego wątku, wejście do zamka lub użycie jednej z rodziny metod Interlocked, dają silniejsze gwarancje dotyczące obserwacji zamówienia. Jeśli chcesz uzyskać więcej informacji, przeczytaj sekcje 3.10 i 10.5.3 specyfikacji C # 4.0.

Szczerze mówiąc, zniechęcam cię do tworzenia zmiennego pola . Zmienne pola są znakiem, że robisz coś wręcz szalonego: próbujesz odczytać i zapisać tę samą wartość w dwóch różnych wątkach bez blokowania. Zamki gwarantują, że pamięć odczytana lub zmodyfikowana wewnątrz zamka jest spójna, zamki gwarantują, że tylko jeden wątek uzyskuje dostęp do danego fragmentu pamięci na raz i tak dalej. Liczba sytuacji, w których blokada jest zbyt wolna, jest bardzo mała, a prawdopodobieństwo, że kod zostanie błędnie spowodowany, ponieważ nie rozumiesz dokładnego modelu pamięci, jest bardzo duże. Nie próbuję pisać kodu o niskiej blokadzie, z wyjątkiem najbardziej trywialnych zastosowań operacji Interlocked. Używanie „niestabilności” pozostawiam prawdziwym ekspertom.

Więcej informacji można znaleźć w:


29
Głosowałbym za tym, gdybym mógł. Jest tam wiele interesujących informacji, ale tak naprawdę nie odpowiada na jego pytanie. Pyta o użycie niestabilnego słowa kluczowego, ponieważ odnosi się ono do blokowania. Przez pewien czas (przed wersją 2.0 RT) konieczne było użycie niestabilnego słowa kluczowego, aby poprawnie zabezpieczyć wątek pola statycznego jako bezpieczny, jeśli instancja pola miała jakiś kod inicjujący w konstruktorze (patrz odpowiedź AndrewTka). W środowisku produkcyjnym wciąż jest dużo kodu 1.1 RT, a deweloperzy, którzy go utrzymują, powinni wiedzieć, dlaczego to słowo kluczowe istnieje i czy można je bezpiecznie usunąć.
Paweł Wielkanoc

3
@PaulEaster fakt, że można go użyć do blokowania sprawdzonego przez doulbe'a (zwykle we wzorze singletonu), nie oznacza, że powinien . Poleganie na modelu pamięci .NET jest prawdopodobnie złą praktyką - zamiast tego powinieneś polegać na modelu ECMA. Na przykład, możesz chcieć przenieść jeden dzień na mono, co może mieć inny model. Muszę również zrozumieć, że różne architektury sprzętu mogą coś zmienić. Aby uzyskać więcej informacji, zobacz: stackoverflow.com/a/7230679/67824 . Aby uzyskać lepsze alternatywy dla singletonów (dla wszystkich wersji .NET), patrz: csharpindepth.com/articles/general/singleton.aspx
Ohad Schneider

6
Innymi słowy, prawidłowa odpowiedź na pytanie brzmi: jeśli kod działa w środowisku wykonawczym 2.0 lub nowszym, zmienne słowo kluczowe prawie nigdy nie jest potrzebne i niepotrzebnie wyrządza więcej szkody niż pożytku. Ale we wcześniejszych wersjach środowiska wykonawczego jest ono potrzebne do prawidłowego blokowania podwójnego sprawdzania w polach statycznych.
Paul Easter

3
czy to oznacza, że ​​blokady i zmienne zmienne wzajemnie się wykluczają w tym sensie: jeśli użyłem blokad wokół jakiejś zmiennej, nie ma już potrzeby deklarowania tej zmiennej jako zmiennej?
giorgim

4
@Giorgi tak - bariery pamięci gwarantowane przez volatilebędą istniały dzięki zamkowi
Ohad Schneider

54

Jeśli chcesz uzyskać nieco więcej informacji technicznych na temat tego, co robi zmienne słowo kluczowe, rozważ następujący program (używam 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;
}

Korzystając ze standardowych zoptymalizowanych (kompilacyjnych) ustawień kompilatora, kompilator tworzy następujący asembler (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              

Patrząc na dane wyjściowe, kompilator zdecydował się użyć rejestru ecx do przechowywania wartości zmiennej j. W przypadku pętli nieulotnej (pierwsza) kompilator przypisał i do rejestru eax. Dość bezpośredni. Jest jednak kilka interesujących bitów - instrukcja lea ebx, [ebx] jest w rzeczywistości instrukcją wielobajtową nop, więc pętla przeskakuje do 16-bajtowego wyrównanego adresu pamięci. Drugim jest użycie edx do zwiększenia licznika pętli zamiast użycia instrukcji inc eax. Instrukcja add reg, reg ma mniejsze opóźnienie na kilku rdzeniach IA32 w porównaniu z instrukcją inc reg, ale nigdy nie ma większego opóźnienia.

Teraz dla pętli z lotnym licznikiem pętli. Licznik jest przechowywany w [esp], a słowo niestabilne mówi kompilatorowi, że wartość należy zawsze odczytywać / zapisywać w pamięci i nigdy nie przypisywać do rejestru. Kompilator posuwa się nawet tak daleko, że nie wykonuje ładowania / przyrostu / przechowywania jako trzy odrębne kroki (load eax, inc eax, save eax) podczas aktualizacji wartości licznika, zamiast tego pamięć jest modyfikowana bezpośrednio w jednej instrukcji (add mem , reg). Sposób utworzenia kodu zapewnia, że ​​wartość licznika pętli jest zawsze aktualna w kontekście pojedynczego rdzenia procesora. Żadna operacja na danych nie może spowodować uszkodzenia lub utraty danych (stąd nieużywanie load / inc / store, ponieważ wartość może się zmienić podczas inc, a zatem zostanie utracona w sklepie). Ponieważ przerwania można obsłużyć dopiero po zakończeniu bieżącej instrukcji,

Po wprowadzeniu drugiego procesora do systemu zmienne słowo kluczowe nie chroni przed aktualizacją danych przez inny procesor w tym samym czasie. W powyższym przykładzie trzeba by wyrównać dane, aby uzyskać potencjalne uszkodzenie. Zmienne słowo kluczowe nie zapobiegnie potencjalnemu uszkodzeniu, jeśli dane nie będą mogły być przetwarzane atomowo, na przykład, jeśli licznik pętli był typu długiego (64 bity), wymagałoby to dwóch 32-bitowych operacji, aby zaktualizować wartość, w środku w którym może wystąpić przerwanie i zmiana danych.

Zatem niestabilne słowo kluczowe jest dobre tylko dla wyrównanych danych, które są mniejsze lub równe rozmiarowi rodzimych rejestrów, tak że operacje są zawsze atomowe.

Zmienne słowo kluczowe zostało opracowane z myślą o operacjach IO, w których IO ciągle się zmienia, ale ma stały adres, taki jak urządzenie UART zamapowane w pamięci, a kompilator nie powinien ponownie wykorzystywać pierwszej wartości odczytanej z adresu.

Jeśli masz do czynienia z dużymi danymi lub masz wiele procesorów, będziesz potrzebować systemu blokującego na wyższym poziomie (OS), aby poprawnie obsługiwać dostęp do danych.


To jest C ++, ale zasada dotyczy C #.
Skizz

6
Eric Lippert pisze, że niestabilność w C ++ uniemożliwia kompilatorowi wykonanie pewnych optymalizacji, podczas gdy w C # niestabilna dodatkowo zapewnia pewną komunikację między innymi rdzeniami / procesorami, aby zapewnić odczytanie najnowszej wartości.
Peter Huber

44

Jeśli używasz .NET 1.1, zmienne słowo kluczowe jest potrzebne podczas podwójnego sprawdzania blokady. Dlaczego? Ponieważ przed wersją .NET 2.0 następujący scenariusz mógł spowodować, że drugi wątek uzyska dostęp do obiektu niepustego, ale jeszcze nie w pełni zbudowanego:

  1. Wątek 1 pyta, czy zmienna ma wartość NULL. //if(this.foo == null)
  2. Wątek 1 określa, że ​​zmienna ma wartość null, więc wchodzi w blokadę. //lock(this.bar)
  3. Wątek 1 pyta PONOWNIE, czy zmienna ma wartość NULL. //if(this.foo == null)
  4. Wątek 1 nadal określa, że ​​zmienna ma wartość NULL, więc wywołuje konstruktor i przypisuje wartość do zmiennej. //this.foo = new Foo ();

Przed wersją .NET 2.0, this.foo można było przypisać nową instancję Foo, zanim konstruktor przestanie działać. W takim przypadku może wejść drugi wątek (podczas wywołania wątku 1 do konstruktora Foo) i doświadczyć:

  1. Wątek 2 pyta, czy zmienna ma wartość null. //if(this.foo == null)
  2. Wątek 2 określa, że ​​zmienna NIE jest pusta, więc próbuje jej użyć. //this.foo.MakeFoo ()

Przed wersją .NET 2.0 można było zadeklarować this.foo jako niestabilny, aby obejść ten problem. Od wersji .NET 2.0 nie trzeba już używać niestabilnego słowa kluczowego, aby uzyskać podwójne sprawdzanie blokady.

Wikipedia rzeczywiście ma dobry artykuł na temat podwójnego sprawdzania blokady i krótko porusza ten temat: http://en.wikipedia.org/wiki/Double-checked_locking


2
dokładnie to widzę w starszym kodzie i zastanawiałem się nad tym. dlatego zacząłem głębsze badania. Dzięki!
Peter Porfy

1
Nie rozumiem, w jaki sposób wątek 2 przypisałby wartość foo? Czy wątek 1 nie jest blokowany this.bari dlatego tylko wątek 1 będzie w stanie zainicjować foo w danym momencie? To znaczy, sprawdzasz wartość po ponownym zwolnieniu blokady, kiedy i tak powinna ona mieć nową wartość z wątku 1.
gilmishal

24

Czasami kompilator zoptymalizuje pole i użyje rejestru, aby go zapisać. Jeśli wątek 1 dokonuje zapisu w polu, a inny wątek uzyskuje do niego dostęp, ponieważ aktualizacja została zapisana w rejestrze (a nie w pamięci), drugi wątek otrzymałby nieaktualne dane.

Możesz pomyśleć o niestabilnym słowie kluczowym, które mówi kompilatorowi: „Chcę, abyś zapisał tę wartość w pamięci”. Gwarantuje to, że drugi wątek pobierze najnowszą wartość.


21

Z MSDN : Zmienny modyfikator jest zwykle używany w polu, do którego dostęp ma wiele wątków, bez użycia instrukcji lock do szeregowania dostępu. Użycie zmiennego modyfikatora zapewnia, że ​​jeden wątek pobiera najbardziej aktualną wartość zapisaną przez inny wątek.


13

CLR lubi optymalizować instrukcje, więc kiedy uzyskujesz dostęp do pola w kodzie, nie zawsze może on uzyskać dostęp do bieżącej wartości pola (może pochodzić ze stosu itp.). Oznaczenie pola jako volatilegwarantuje, że instrukcja uzyska dostęp do bieżącej wartości pola. Jest to przydatne, gdy wartość można zmodyfikować (w scenariuszu nieblokującym) za pomocą współbieżnego wątku w programie lub innego kodu działającego w systemie operacyjnym.

Oczywiście tracisz trochę optymalizacji, ale dzięki temu kod jest prostszy.


3

Uważam ten artykuł Joydipa Kanjilala za bardzo pomocny!

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

Zostawię to tutaj w celach informacyjnych


0

Kompilator czasami zmienia kolejność instrukcji w kodzie, aby go zoptymalizować. Zwykle nie jest to problem w środowisku jednowątkowym, ale może to być problem w środowisku wielowątkowym. Zobacz następujący przykład:

 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);
     }
 });

Jeśli uruchomisz t1 i t2, nie spodziewałbyś się żadnego wyniku lub „Value: 10” jako wyniku. Możliwe, że kompilator przełącza linię wewnątrz funkcji t1. Jeśli t2 zostanie następnie wykonane, może to oznaczać, że _flag ma wartość 5, ale _value ma 0. Tak więc oczekiwana logika może zostać zerwana.

Aby to naprawić, możesz użyć niestabilnego słowa kluczowego, które możesz zastosować w polu. Ta instrukcja wyłącza optymalizacje kompilatora, dzięki czemu można wymusić poprawną kolejność w kodzie.

private static volatile int _flag = 0;

Powinieneś używać volatile tylko wtedy, gdy naprawdę go potrzebujesz, ponieważ wyłącza on pewne optymalizacje kompilatora, obniża wydajność. Nie jest również obsługiwany przez wszystkie języki .NET (Visual Basic go nie obsługuje), więc utrudnia interoperacyjność języka.


2
Twój przykład jest naprawdę zły. Programista nigdy nie powinien oczekiwać żadnych wartości _flag w zadaniu t2 w oparciu o fakt, że kod t1 jest zapisywany jako pierwszy. Najpierw napisane! = Najpierw wykonane. Nie ma znaczenia, czy kompilator przełącza te dwie linie w t1. Nawet jeśli kompilator nie zmienił tych instrukcji, Console.WriteLne w gałęzi else może nadal działać, nawet Z zmiennym słowem kluczowym na _flag.
Jakotheshadows

@ jakotheshadows, masz rację, zredagowałem swoją odpowiedź. Moim głównym pomysłem było pokazanie, że oczekiwana logika może zostać złamana, gdy uruchomimy t1 i t2 jednocześnie
Aliaksei Maniuk

0

Podsumowując, prawidłowa odpowiedź na pytanie brzmi: jeśli kod działa w środowisku wykonawczym 2.0 lub nowszym, zmienne słowo kluczowe prawie nigdy nie jest potrzebne i niepotrzebnie powoduje więcej szkody niż pożytku. IE Nigdy go nie używaj. ALE we wcześniejszych wersjach środowiska wykonawczego, jest on potrzebny do prawidłowego podwójnego sprawdzania blokowania pól statycznych. W szczególności pola statyczne, których klasa ma kod inicjalizacji klasy statycznej.


Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.