Blokada z podwójnym zatwierdzeniem (ang. double-checked locking lub double-checked locking optimization[1]) – wzorzec projektowy stworzony w celu redukcji czasu uzyskania blokady poprzez testowanie najpierw warunku blokady (ang. lock hint) w sposób niebezpieczny, a potem dopiero – tylko w razie sukcesu – przeprowadzanie całego procesu uzyskiwania blokady.

Taki wzorzec jest niebezpieczny, bo może być (w zależności od pewnych konfiguracji językowo-sprzętowych) antywzorcem.

Jego typowe użycie to redukcja czasu uzyskiwania blokady poprzez zastosowanie wzorca leniwej inicjalizacji w wielowątkowym środowisku, szczególnie jako część wzorca singletonu. Leniwa inicjalizacja ma na celu unikanie czasochłonnej inicjalizacji zmiennej aż do momentu jej pierwszego użycia.

Implementacja w Java edytuj

Rozważmy dla przykładu poniższy kawałek kodu napisanego w Javie. Ten kawałek pochodzi (tak jak inne) ze strony[2].

// Wersja jednowątkowa
class Foo {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null)
            helper = new Helper();
        return helper;
    }

    // inne funkcje i metody...
}

Problemem jest to, że ten kod nie pracuje poprawnie wtedy, gdy jest wiele takich wątków działających w tym samym czasie. Blokada musi być uzyskana w sytuacji, gdy dwa wątki wywołują jednocześnie getHelper(). W przeciwnym razie, oba te wątki mogą albo próbować stworzyć nowy obiekt w tym samym czasie albo jeden z nich dostanie referencję do nie w pełni zainicjowanego obiektu. Problem rozwiązuje się poprzez synchronizację, tak jak w poniższym przykładzie.

//Poprawna, ale kosztowna wersja wielowątkowa
class Foo {
    private Helper helper = null;
    public synchronized Helper getHelper() {
        if (helper == null)
            helper = new Helper();
        return helper;
    }

    // inne funkcje i metody...
}

Tutaj tylko pierwsze wywołanie getHelper() stworzy obiekt. W tym czasie pewna niewielka liczba wątków próbujących uzyskać dostęp musi być zsynchronizowana. Potem (po tej fazie) już wszystkie wątki uzyskają tylko referencję do pola klasy. Synchronizacja metody może obniżyć wydajność o czynnik 100 lub więcej. W tym przypadku ten koszt wydaje się być niepotrzebny, ponieważ inicjalizacja jest dokonywana tylko raz. Uzyskiwanie i zwalnianie blokady za każdym wywołaniem metody, gdy obiekt już jest utworzony, wydaje się być niepotrzebna. Wielu programistów próbowało zoptymalizować to w następujący sposób:

  1. Sprawdź, czy zmienna jest zainicjowana (bez uzyskiwania blokady). Jeśli jest zainicjowana, to zwróć ją natychmiast,
  2. Uzyskaj blokadę,
  3. Sprawdź dwa razy, czy jest właśnie zainicjowana. Jeśli inny wątek uzyskał blokadę pierwszy, to może zainicjował już tę zmienną. Jeśli tak, to natychmiast zwróć zainicjowaną zmienną,
  4. W przeciwnym razie, zainicjuj i zwróć tę zmienną.
// Błędna wersja wielowątkowa
// "Blokada z podwójnym zatwierdzeniem"
class Foo {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized(this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }

    // inne funkcje i metody...
}

Intuicyjnie ten algorytm wydaje się być efektywnym rozwiązaniem problemu. Jednak ten sposób niesie ze sobą wiele subtelnych problemów i powinien być unikany. Przykładowo, rozważmy poniższą sekwencję zdarzeń:

  1. Wątek A zauważa, że zmienna nie jest zainicjowana, więc uzyskuje blokadę i zaczyna inicjalizować zmienną,
  2. W związku z semantyką części języków programowania, kod generowany przez kompilator może uaktualnić współdzieloną zmienną, by wskazać na częściowo skonstruowany obiekt zanim wątek A zakończy inicjalizację,
  3. Wątek B zauważa, że współdzielona zmienna została zainicjowana (lub wydaje się być taką) i zwraca wartość. Ponieważ wątek B myśli, że zmienna została właśnie zainicjowana, więc nie uzyskuje na nią blokady. Jeśli zmienna jest używana zanim A zakończy jej inicjalizację, to program prawdopodobnie się „wysypie”.

Jedno z niebezpieczeństw używania blokady z podwójnym zatwierdzeniem w języku Java w wersjach 1.4 lub wcześniejszych jest to, że często wydaje się działać. Nie jest łatwo odróżnić poprawną implementację techniki od takiej, która ma jakiś subtelne problemy. Zależy to od kompilatora, planisty procesów i wątków i sposobu działania innych części wielowątkowego systemu. Odtworzenie takich błędów może być zatem trudne.

Ten problem został rozwiązany w Javie od wersji 1.5. Teraz słowo kluczowe volatile zapewnia poprawną obsługę instancji singletonu nawet w sytuacji dużej ilości tych samych wątków. Nowy idiom programistyczny jest opisany w[2]:

// Źle działa pod Java 1.4 i wcześniejszą
class Foo {
    private volatile Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized(this) {
                if (helper == null)
                    helper = new Helper();
            }
        }
        return helper;
    }

    // inne funkcje i metody...
}

Zaproponowano wiele wersji idiomu blokady z podwójnym zatwierdzeniem nieużywających jawnych metod w stylu volatile lub synchronizacji. Wszystkie są błędne[3][2].

Implementacja w Microsoft Visual C++ edytuj

Blokada z podwójnym zatwierdzeniem może być zaimplementowana w Visual C++ 2005, jeśli wskaźnik na zasób jest zadeklarowany ze słowem kluczowym C++ volatile. Visual C++ 2005 gwarantuje, iż zmienna typu volatile zachowa się podobnie jak w J2SE 5.0. Takiej gwarancji nie ma w poprzednich wersjach. Jeśli wskaźnik na zasób jest widoczny w dużych partiach kodu i jest typu volatile, to wtedy może na tym ucierpieć wydajność kodu.

Zobacz też edytuj

Przypisy edytuj

  1. Schmidt, D et al Pattern-Oriented Software Architecture Vol 2, 2000 pp353-363.
  2. a b c The „Double-Checked Locking is Broken” Declaration [online], www.cs.umd.edu [dostęp 2017-11-22].
  3. Java Concurrency (&c): Double-Checked Locking and the Problem with Wikipedia [online], jeremymanson.blogspot.com [dostęp 2017-11-22].

Linki zewnętrzne edytuj