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?
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?
Odpowiedzi:
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:
volatile
będą istniały dzięki zamkowi
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.
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:
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ć:
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
foo
? Czy wątek 1 nie jest blokowany this.bar
i 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.
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ść.
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 volatile
gwarantuje, ż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.
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
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.
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.
wiele wątków może uzyskać dostęp do zmiennej. Najnowsza aktualizacja będzie dotyczyła zmiennej