Potrzeba zmiennego modyfikatora w podwójnie sprawdzanym blokowaniu w .NET

85

Wiele tekstów mówi, że przy implementacji podwójnie zaznaczonego blokowania w .NET pole, na którym blokujesz, powinno mieć zastosowany modyfikator volatile. Ale dlaczego dokładnie? Biorąc pod uwagę następujący przykład:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

dlaczego "lock (syncRoot)" nie zapewnia niezbędnej spójności pamięci? Czy nie jest prawdą, że po wyrażeniu „lock” zarówno odczyt, jak i zapis byłyby niestabilne, a więc wymagana spójność została osiągnięta?

Konstantin
źródło
2
To było już wielokrotnie przeżuwane. yoda.arachsys.com/csharp/singleton.html
Hans Passant
1
Niestety w tym artykule Jon dwukrotnie odwołuje się do słowa „volatile” i żadne z nich nie odnosi się bezpośrednio do podanych przez niego przykładów kodu.
Dan Esparza,
Zobacz ten artykuł, aby zrozumieć problem: igoro.com/archive/volatile-keyword-in-c-memory-model-explained Zasadniczo JIT może TEORETYCZNIE używać rejestru procesora dla zmiennej instancji - zwłaszcza jeśli potrzebujesz trochę dodatkowego kodu. W ten sposób dwukrotne wykonanie instrukcji if może potencjalnie zwrócić tę samą wartość, niezależnie od jej zmiany w innym wątku. W rzeczywistości odpowiedź jest trochę skomplikowana, a instrukcja blokady może, ale nie musi, być odpowiedzialna za poprawę sytuacji (ciąg dalszy)
user2685937
(patrz poprzedni komentarz ciąg dalszy) - Oto, co myślę, że naprawdę się dzieje - Zasadniczo każdy kod, który robi coś bardziej złożonego niż odczyt lub ustawienie zmiennej, może spowodować, że JIT powie: zapomnij o próbie optymalizacji tego, po prostu załaduj i zapisz do pamięci, ponieważ jeśli funkcja nazywa się JIT, potencjalnie musiałby zapisać i przeładować rejestr, gdyby zapisywał go tam za każdym razem, zamiast robić to po prostu zapisuje i odczytuje za każdym razem bezpośrednio z pamięci. Skąd mam wiedzieć, że zamek to nic specjalnego? Spójrz na link, który zamieściłem w poprzednim komentarzu Igora (ciąg dalszy, następny komentarz)
user2685937
(patrz wyżej 2 komentarze) - Przetestowałem kod Igora i kiedy tworzył nowy wątek dodałem wokół niego kłódkę, a nawet zrobiłem pętlę. Nadal nie spowodowałoby to wyjścia kodu, ponieważ zmienna instancji została wyciągnięta z pętli. Dodanie do pętli while prostego zestawu zmiennych lokalnych nadal podnosiło zmienną z pętli - teraz coś bardziej skomplikowanego, na przykład instrukcje if lub wywołanie metody lub tak, nawet wywołanie blokady uniemożliwiłoby optymalizację, a tym samym sprawiłoby, że zadziała. Zatem każdy złożony kod często wymusza bezpośredni dostęp do zmiennych, zamiast pozwalać JIT na optymalizację. (ciąg dalszy następny komentarz)
user2685937

Odpowiedzi:

59

Niestabilność jest niepotrzebna. Cóż, jakby **

volatilesłuży do tworzenia bariery pamięci * między odczytami i zapisami w zmiennej.
lock, gdy jest używany, powoduje tworzenie się barier pamięciowych wokół bloku wewnątrz lock, oprócz ograniczenia dostępu do bloku do jednego wątku.
Bariery pamięci sprawiają, że każdy wątek odczytuje najbardziej aktualną wartość zmiennej (a nie wartość lokalną przechowywaną w jakimś rejestrze) i że kompilator nie zmienia kolejności instrukcji. Używanie volatilejest niepotrzebne **, ponieważ masz już blokadę.

Joseph Albahari wyjaśnia to lepiej niż ja kiedykolwiek potrafiłem.

I koniecznie sprawdź przewodnik Jona Skeeta na temat implementacji singletona w C #


update :
* volatilepowoduje, że odczyty zmiennej są VolatileReads, a zapisy VolatileWrites, które na x86 i x64 w CLR są implementowane z rozszerzeniem MemoryBarrier. W innych systemach mogą być drobniejsze.

** moja odpowiedź jest poprawna tylko wtedy, gdy używasz CLR na procesorach x86 i x64. Może to być prawdą w innych modelach pamięci, takich jak Mono (i inne implementacje), Itanium64 i przyszły sprzęt. To jest to, do czego odnosi się Jon w swoim artykule w "pułapkach" podwójnie sprawdzanego blokowania.

Wykonanie jednego z {oznaczanie zmiennej jako volatile, odczytywanie jej Thread.VolatileReadlub wstawianie wywołania Thread.MemoryBarrier} może być konieczne, aby kod działał poprawnie w sytuacji ze słabym modelem pamięci.

Z tego, co rozumiem, w CLR (nawet na IA64) zapisy nigdy nie są zmieniane (zapisy zawsze mają semantykę wydania). Jednak na IA64 odczyty mogą być zmieniane w kolejności przed zapisami, chyba że są oznaczone jako lotne. Niestety nie mam dostępu do sprzętu IA64 do zabawy, więc cokolwiek na ten temat powiem, byłoby spekulacją.

Przydały mi się również te artykuły:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
artykuł vance'a morrisona (wszystko się do tego odwołuje, mówi o podwójnie sprawdzanym blokowaniu)
artykuł chrisa brumme'a (wszystkie linki do tego )
Joe Duffy: Zepsute warianty podwójnie sprawdzanego blokowania

Seria luis abreu na temat wielowątkowości również daje ładny przegląd pojęć
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http: // msmvps. pl / blogs / luisabreu / archive / 2009/07/03 / multithreading-introducing-memory-fences.aspx

dan
źródło
Jon Skeet tak naprawdę mówi, że zmienny modyfikator jest potrzebny do stworzenia odpowiedniej bariery pamięci, podczas gdy autor pierwszego linku twierdzi, że wystarczyłaby blokada (Monitor.Enter). Kto właściwie ma rację ???
Konstantin
@Konstantin wydaje się, że Jon odnosił się do modelu pamięci na procesorach Itanium 64, więc w takim przypadku użycie volatile może być konieczne. Jednak nietrwałość nie jest konieczna w przypadku procesorów x86 i x64. Zaktualizuję więcej za chwilę.
dan
Jeśli zamek rzeczywiście tworzy barierę pamięci i jeśli faktycznie bariera pamięci dotyczy zarówno kolejności instrukcji, jak i unieważniania pamięci podręcznej, to powinna działać na wszystkich procesorach. Zresztą to takie dziwne, że taka podstawowa sprawa powoduje tyle zamieszania ...
Konstantin
2
Ta odpowiedź wydaje mi się błędna. Jeśli volatilebyła niepotrzebna na dowolnej platformie wtedy oznaczałoby to JIT nie mógł zoptymalizować obciążenia pamięci object s1 = syncRoot; object s2 = syncRoot;, aby object s1 = syncRoot; object s2 = s1;na tej platformie. Wydaje mi się to mało prawdopodobne.
user541686
1
Nawet jeśli CLR nie zmieniłby kolejności zapisów (wątpię, że to prawda, można w ten sposób zrobić wiele bardzo dobrych optymalizacji), nadal byłoby błędne, o ile możemy wbudować wywołanie konstruktora i utworzyć obiekt w miejscu (mogliśmy zobaczyć w połowie zainicjalizowany obiekt). Niezależnie od modelu pamięci, którego używa procesor! Według Erica Lipperta CLR na Intela przynajmniej wprowadza membarrier po konstruktorach, który zaprzecza tej optymalizacji, ale nie jest to wymagane przez specyfikację i nie liczyłbym na to samo, co dzieje się na przykład na ARM ...
Voo
34

Jest sposób na wdrożenie go bez volatilepola. Wyjaśnię to ...

Myślę, że to zmiana kolejności dostępu do pamięci wewnątrz zamka jest niebezpieczna, tak że można uzyskać niecałkowicie zainicjowaną instancję poza zamkiem. Aby tego uniknąć, robię to:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Zrozumienie kodu

Wyobraź sobie, że wewnątrz konstruktora klasy Singleton znajduje się kod inicjujący. Jeśli kolejność tych instrukcji zostanie zmieniona po ustawieniu pola na adres nowego obiektu, oznacza to, że masz niekompletną instancję ... wyobraź sobie, że klasa ma taki kod:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Teraz wyobraź sobie wywołanie konstruktora za pomocą nowego operatora:

instance = new Singleton();

Można to rozszerzyć na następujące operacje:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

Co się stanie, jeśli zmienię kolejność tych instrukcji w ten sposób:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

Czy to robi różnicę? NIE, jeśli myślisz o pojedynczym wątku. TAK, jeśli myślisz o wielu wątkach ... co jeśli wątek zostanie przerwany tuż po set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

Tego właśnie unika bariera pamięci, nie zezwalając na zmianę kolejności dostępu do pamięci:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

Miłego kodowania!

Miguel Angelo
źródło
1
Jeśli środowisko CLR umożliwia dostęp do obiektu przed jego zainicjowaniem, jest to luka w zabezpieczeniach. Wyobraź sobie uprzywilejowaną klasę, której jedyny publiczny konstruktor ustawia „SecureMode = 1” i której metody instancji to sprawdzają. Jeśli możesz wywołać te metody instancji bez uruchomionego konstruktora, możesz wyrwać się z modelu zabezpieczeń i naruszyć piaskownicę.
MichaelGG
1
@MichaelGG: w przypadku, który opisałeś, jeśli ta klasa obsługuje wiele wątków, aby uzyskać do niej dostęp, to jest problem. Jeśli wywołanie konstruktora jest wbudowane przez jitter, wówczas CPU może zmienić kolejność instrukcji w taki sposób, że przechowywane odniesienie wskazuje na nie w pełni zainicjowaną instancję. Nie jest to problem bezpieczeństwa CLR, ponieważ można go uniknąć, programista jest odpowiedzialny za użycie: blokowanych, barier pamięciowych, blokad i / lub pól nietrwałych, wewnątrz konstruktora takiej klasy.
Miguel Angelo
2
Bariera wewnątrz ktora tego nie naprawia. Jeśli środowisko CLR przypisuje odwołanie do nowo przydzielonego obiektu przed zakończeniem działania ctora i nie wstawia elementu membarrier, wówczas inny wątek może wykonać metodę wystąpienia na częściowo zainicjowanym obiekcie.
MichaelGG
To jest „alternatywny wzorzec”, który ReSharper 2016/2017 sugeruje w przypadku DCL w C #. OTOH, Java ma gwarancji, że wynik newjest w pełni zainicjowany ..
user2864740
Wiem, że implementacja MS .net stawia barierę pamięci na końcu konstruktora ... ale lepiej być bezpiecznym niż żałować.
Miguel Angelo
7

Myślę, że nikt nie odpowiedział na to pytanie , więc spróbuję.

Zmienne i pierwsze if (instance == null)nie są „konieczne”. Blokada sprawi, że ten kod będzie bezpieczny dla wątków.

Więc pytanie brzmi: dlaczego miałbyś dodać pierwszy if (instance == null)?

Powodem jest przypuszczalnie unikanie niepotrzebnego wykonywania zablokowanej sekcji kodu. Podczas wykonywania kodu wewnątrz zamka każdy inny wątek, który próbuje również wykonać ten kod, jest blokowany, co spowolni twój program, jeśli będziesz często próbował uzyskać dostęp do singletona z wielu wątków. W zależności od języka / platformy mogą również występować narzuty związane z samym zamkiem, których chcesz uniknąć.

Tak więc pierwsze sprawdzenie zerowe jest dodawane jako naprawdę szybki sposób sprawdzenia, czy potrzebujesz blokady. Jeśli nie musisz tworzyć singletona, możesz całkowicie uniknąć blokady.

Ale nie możesz sprawdzić, czy odwołanie jest zerowe bez blokowania go w jakiś sposób, ponieważ z powodu buforowania procesora inny wątek mógłby go zmienić i odczytać „nieaktualną” wartość, która prowadziłaby do niepotrzebnego wejścia do blokady. Ale próbujesz uniknąć blokady!

Więc sprawiasz, że singleton jest zmienny, aby mieć pewność, że czytasz najnowszą wartość, bez konieczności używania blokady.

Nadal potrzebujesz wewnętrznego zamka, ponieważ niestabilność chroni Cię tylko podczas pojedynczego dostępu do zmiennej - nie możesz go bezpiecznie przetestować i ustawić bez użycia zamka.

Czy to rzeczywiście przydatne?

Cóż, powiedziałbym „w większości przypadków nie”.

Jeśli Singleton.Instance może powodować nieefektywność z powodu blokad, to dlaczego dzwonisz do niego tak często, że byłby to znaczący problem ? Cały sens singletona polega na tym, że jest tylko jeden, więc kod może raz odczytać i buforować pojedyncze odniesienie.

Jedynym przypadkiem, w którym mogę wymyślić, gdzie to buforowanie nie byłoby możliwe, byłby, gdy masz dużą liczbę wątków (np. Serwer używający nowego wątku do przetwarzania każdego żądania może tworzyć miliony bardzo krótko działających wątków, każdy z co musiałoby raz zadzwonić do Singleton.Instance).

Podejrzewam więc, że podwójnie sprawdzane blokowanie jest mechanizmem, który ma realne miejsce w bardzo specyficznych przypadkach krytycznych dla wydajności, a następnie każdy wdrapał się na modę „to jest właściwy sposób”, nie zastanawiając się, co robi będzie faktycznie konieczne w przypadku, gdy go używają.

Jason Williams
źródło
6
To jest gdzieś pomiędzy błędem a brakiem sensu. volatilenie ma nic wspólnego z semantyką blokad w blokowaniu podwójnie sprawdzanym, ma to związek z modelem pamięci i spójnością pamięci podręcznej. Jego celem jest zapewnienie, że jeden wątek nie otrzyma wartości, która jest nadal inicjowana przez inny wątek, czego nie zapobiega wzorzec blokady podwójnego sprawdzenia. W Javie zdecydowanie potrzebujesz volatilesłowa kluczowego; w .NET jest mętny, ponieważ jest zły według ECMA, ale poprawny według środowiska wykonawczego. Tak czy inaczej, lockzdecydowanie się tym nie zajmuje.
Aaronaught
Co? Nie widzę, gdzie twoje stwierdzenie nie zgadza się z tym, co powiedziałem, ani nie powiedziałem, że zmienność jest w jakikolwiek sposób związana z semantyką zamków.
Jason Williams
6
Twoja odpowiedź, podobnie jak kilka innych instrukcji w tym wątku, twierdzi, że locksprawia, że ​​kod jest bezpieczny dla wątków. Ta część jest prawdą, ale wzorzec podwójnego sprawdzania blokady może uczynić ją niebezpieczną . Wydaje się, że tego właśnie brakuje. Ta odpowiedź wydaje się błądzić o znaczeniu i celu podwójnego sprawdzenia blokady bez rozwiązywania problemów związanych z bezpieczeństwem wątków, które są tego przyczyną volatile.
Aaronaught
1
Jak może uczynić niebezpiecznym, jeśli instancejest oznaczony volatile?
UserControl
5

Powinieneś używać volatile ze wzorem podwójnej blokady.

Większość ludzi wskazuje ten artykuł jako dowód, że nie potrzebujesz ulotności: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Ale nie czytają do końca: „ Ostatnie słowo ostrzeżenia - zgaduję tylko model pamięci x86 na podstawie zaobserwowanego zachowania na istniejących procesorach. Dlatego techniki low-lock są również delikatne, ponieważ sprzęt i kompilatory mogą z czasem stać się bardziej agresywne . Oto kilka strategii minimalizowania wpływu tej kruchości na twój kod. Po pierwsze, jeśli to możliwe, unikaj technik o niskiej blokadzie. (...) Na koniec załóż najsłabszy możliwy model pamięci, używając deklaracji nietrwałych, zamiast polegać na niejawnych gwarancjach ”.

Jeśli potrzebujesz więcej przekonywania, przeczytaj ten artykuł na temat specyfikacji ECMA, która będzie używana na innych platformach: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Jeśli potrzebujesz dalszych przekonań, przeczytaj ten nowszy artykuł, że można wprowadzić optymalizacje, które uniemożliwiają działanie bez ulotności: msdn.microsoft.com/en-us/magazine/jj883956.aspx

Podsumowując, "może" zadziałać dla ciebie bez ulotności w tej chwili, ale nie ryzykuj, że napisze odpowiedni kod i albo użyje metody volatile lub volatileread / write. Artykuły, które sugerują, że należy postąpić inaczej, czasami pomijają niektóre z możliwych zagrożeń związanych z optymalizacją JIT / kompilatora, które mogą mieć wpływ na Twój kod, a także z naszymi przyszłymi optymalizacjami, które mogą się zdarzyć, które mogą spowodować uszkodzenie kodu. Również, jak wspomniano w poprzednim artykule, poprzednie założenia pracy bez zmienności mogą już nie wytrzymać ARM.

user2685937
źródło
1
Dobra odpowiedź. Jedyna prawidłowa odpowiedź na to pytanie brzmi: „Nie”. W związku z tym przyjęta odpowiedź jest błędna.
Dennis Kassel
3

AFAIK (i - uważaj na to, nie robię wielu rzeczy jednocześnie) nie. Blokada po prostu zapewnia synchronizację między wieloma rywalami (wątkami).

niestabilny z drugiej strony mówi twojemu komputerowi, aby za każdym razem ponownie oceniał wartość, abyś nie natknął się na buforowaną (i złą) wartość.

Zobacz http://msdn.microsoft.com/en-us/library/ms998558.aspx i zwróć uwagę na następujący cytat:

Ponadto zmienna jest zadeklarowana jako zmienna, aby zapewnić, że przypisanie do zmiennej wystąpienia zakończy się przed uzyskaniem dostępu do zmiennej wystąpienia.

Opis volatile: http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

Benjamin Podszun
źródło
2
„Blokada” zapewnia również barierę pamięci, taką samą (lub lepszą niż) ulotną.
Henk Holterman
2

Myślę, że znalazłem to, czego szukałem. Szczegóły w tym artykule - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10 .

Podsumowując - w .NET modyfikator lotny rzeczywiście nie jest potrzebny w tej sytuacji. Jednak w słabszych modelach pamięci zapisy wykonane w konstruktorze leniwie zainicjowanego obiektu mogą być opóźnione po zapisie do pola, więc inne wątki mogą odczytać uszkodzoną instancję niezerową w pierwszej instrukcji if.

Konstantin
źródło
1
Na samym dole tego artykułu przeczytaj uważnie, zwłaszcza ostatnie zdanie, które autor stwierdza: „Ostatnie słowo ostrzeżenia - zgaduję tylko model pamięci x86 na podstawie obserwowanego zachowania na istniejących procesorach. Dlatego techniki low-lock są również kruche, ponieważ sprzęt i kompilatory mogą z czasem stać się bardziej agresywne. Oto kilka strategii minimalizowania wpływu tej kruchości na kod. Po pierwsze, jeśli to możliwe, unikaj technik o niskim poziomie blokowania. (...) Na koniec załóż najsłabszy możliwy model pamięci, używanie zmiennych deklaracji zamiast polegania na niejawnych gwarancjach. "
user2685937
1
Jeśli potrzebujesz więcej przekonywania, przeczytaj ten artykuł na temat specyfikacji ECMA, która będzie używana na innych platformach: msdn.microsoft.com/en-us/magazine/jj863136.aspx Jeśli potrzebujesz dalszych przekonań, przeczytaj ten nowszy artykuł, że optymalizacje można wprowadzić które uniemożliwiają mu pracę bez ulotności: msdn.microsoft.com/en-us/magazine/jj883956.aspx Podsumowując, "może" działać dla ciebie bez ulotności na chwilę, ale nie ryzykuj, że napisze odpowiedni kod i albo użyj volatile lub volatileread / write.
user2685937
1

lockJest wystarczająca. Sama specyfikacja języka MS (3.0) wspomina dokładnie o tym scenariuszu w §8.12, bez żadnej wzmianki o volatile:

Lepszym podejściem jest synchronizacja dostępu do danych statycznych przez zablokowanie prywatnego obiektu statycznego. Na przykład:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
Marc Gravell
źródło
Jon Skeet w swoim artykule ( yoda.arachsys.com/csharp/singleton.html ) mówi, że w tym przypadku dla właściwej bariery pamięci potrzebna jest zmienność. Marc, możesz to skomentować?
Konstantin
Ach, nie zauważyłem podwójnie sprawdzonego zamka; po prostu: nie rób tego ;-p
Marc Gravell
Właściwie myślę, że podwójnie sprawdzona blokada jest dobra pod względem wydajności. Również jeśli konieczne jest, aby pole było niestabilne, gdy jest ono dostępne w obrębie zamka, wówczas zamek z podwójnym sprawdzaniem nie jest dużo gorszy niż jakikolwiek inny zamek ...
Konstantin
Ale czy jest tak dobre, jak podejście do oddzielnych klas, o którym wspomina Jon?
Marc Gravell
-3

To całkiem niezły post o używaniu volatile z podwójnie sprawdzonym blokowaniem:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

W Javie, jeśli celem jest ochrona zmiennej, nie musisz jej blokować, jeśli jest oznaczona jako niestabilna

Mark Pope
źródło
3
Ciekawe, ale niekoniecznie bardzo pomocne. Model pamięci JVM i model pamięci CLR to nie to samo.
bcat