Podwójnie sprawdzane zamknięcie: sprytne, ale zepsute

Od wysoko cenionych elementów stylu Java po strony JavaWorld (patrz Java Tip 67), wielu guru Java o dobrych intencjach zachęca do używania idiomu podwójnie sprawdzanego blokowania (DCL). Jest tylko jeden problem - ten sprytny idiom może nie działać.

Podwójnie sprawdzone zamknięcie może być niebezpieczne dla twojego kodu!

W tym tygodniu JavaWorld skupia się na zagrożeniach związanych z idiomem podwójnie sprawdzanego blokowania. Przeczytaj więcej o tym, jak ten pozornie nieszkodliwy skrót może siać spustoszenie w Twoim kodzie:
  • „Ostrzeżenie! Wątki w świecie wieloprocesorowym”, Allen Holub
  • Podwójnie sprawdzone zamknięcie: sprytne, ale zepsute ”, Brian Goetz
  • Aby dowiedzieć się więcej na temat podwójnie sprawdzanego blokowania, przejdź do dyskusji na temat teorii programowania i praktyki Allena Holuba

Co to jest DCL?

Idiom DCL został zaprojektowany do obsługi leniwej inicjalizacji, która występuje, gdy klasa odracza inicjalizację posiadanego obiektu, dopóki nie będzie faktycznie potrzebny:

class SomeClass {zasób prywatnego zasobu = null; public Resource getResource () {if (resource == null) resource = new Resource (); zwrot zasobów; }}

Dlaczego chcesz odroczyć inicjalizację? Być może tworzenie a Resourcejest kosztowną operacją, a użytkownicy SomeClassmogą w rzeczywistości nie wywoływać getResource()w żadnym przebiegu. W takim przypadku możesz Resourcecałkowicie uniknąć tworzenia pliku. Niezależnie od tego, SomeClassobiekt można stworzyć szybciej, jeśli nie musi on również tworzyć Resourcew czasie budowy. Opóźnienie niektórych operacji inicjalizacyjnych do czasu, gdy użytkownik faktycznie będzie potrzebował ich wyników, może przyspieszyć uruchamianie programów.

Co jeśli spróbujesz użyć SomeClassw aplikacji wielowątkowej? Następnie pojawia się warunek wyścigu: dwa wątki mogą jednocześnie wykonać test, aby sprawdzić, czy resourcejest pusty, iw rezultacie zainicjować resourcedwukrotnie. W środowisku wielowątkowym należy zadeklarować, getResource()że jest synchronized.

Niestety, metody zsynchronizowane działają znacznie wolniej - nawet 100 razy wolniej - niż zwykłe metody niezsynchronizowane. Jedną z motywacji do leniwej inicjalizacji jest wydajność, ale wydaje się, że aby osiągnąć szybsze uruchamianie programu, trzeba zaakceptować wolniejszy czas wykonywania po uruchomieniu programu. To nie brzmi jak świetny kompromis.

DCL twierdzi, że daje nam to, co najlepsze z obu światów. Używając DCL, getResource()metoda wyglądałaby następująco:

class SomeClass {zasób prywatnego zasobu = null; Zasób publiczny getResource () {if (zasób == null) {zsynchronizowany {if (zasób == null) zasób = nowy Zasób (); }} zwraca zasób; }}

Po pierwszym wywołaniu getResource(), resourcejest już zainicjowany, co pozwala uniknąć trafienia synchronizacji w najpopularniejszej ścieżce kodu. DCL zapobiega również warunkom wyścigu, sprawdzając resourcepo raz drugi wewnątrz zsynchronizowanego bloku; zapewnia to, że tylko jeden wątek będzie próbował zainicjować resource. DCL wydaje się sprytną optymalizacją - ale nie działa.

Poznaj model pamięci Java

Dokładniej, DCL nie gwarantuje działania. Aby zrozumieć, dlaczego, musimy przyjrzeć się relacji między maszyną JVM a środowiskiem komputerowym, w którym działa. W szczególności musimy przyjrzeć się modelowi pamięci Java (JMM), zdefiniowanemu w rozdziale 17 specyfikacji języka Java przez Billa Joy, Guy Steele, Jamesa Goslinga i Gilada Brachę (Addison-Wesley, 2000), który szczegółowo opisuje, jak Java obsługuje interakcję między wątkami a pamięcią.

W przeciwieństwie do większości innych języków, Java definiuje swój związek ze sprzętem bazowym za pomocą formalnego modelu pamięci, który powinien działać na wszystkich platformach Java, umożliwiając Java obietnicę „Napisz raz, uruchom w dowolnym miejscu”. Dla porównania, innym językom, takim jak C i C ++, brakuje formalnego modelu pamięci; w takich językach programy dziedziczą model pamięci platformy sprzętowej, na której działa program.

Podczas pracy w środowisku synchronicznym (jednowątkowym) interakcja programu z pamięcią jest dość prosta, a przynajmniej tak się wydaje. Programy przechowują elementy w lokalizacjach pamięci i oczekują, że będą tam nadal, gdy te lokalizacje zostaną zbadane następnym razem.

W rzeczywistości prawda jest zupełnie inna, ale ukrywa ją przed nami skomplikowana iluzja utrzymywana przez kompilator, JVM i sprzęt. Chociaż myślimy o programach jako o wykonywaniu sekwencyjnym - w kolejności określonej przez kod programu - to nie zawsze się zdarza. Kompilatory, procesory i pamięci podręczne mogą swobodnie korzystać z naszych programów i danych, o ile nie wpływają one na wynik obliczeń. Na przykład kompilatory mogą generować instrukcje w innej kolejności niż oczywista interpretacja sugerowana przez program i przechowywać zmienne w rejestrach zamiast w pamięci; procesory mogą wykonywać instrukcje równolegle lub poza kolejnością; a bufory mogą różnić się kolejnością, w jakiej zapisuje zatwierdzenie do pamięci głównej. JMM mówi, że wszystkie te różne zmiany kolejności i optymalizacje są dopuszczalne,tak długo, jak długo zachowuje środowiskosemantyka as-if-serial - to znaczy tak długo, jak długo uzyskuje się taki sam wynik, jak gdyby instrukcje były wykonywane w ściśle sekwencyjnym środowisku.

Kompilatory, procesory i pamięci podręczne zmieniają kolejność operacji programu w celu uzyskania wyższej wydajności. W ostatnich latach widzieliśmy ogromną poprawę wydajności obliczeniowej. Podczas gdy zwiększone częstotliwości taktowania procesora znacząco przyczyniły się do wyższej wydajności, głównym czynnikiem był również zwiększony paralelizm (w postaci potokowych i superskalarnych jednostek wykonawczych, dynamicznego planowania instrukcji i wykonywania spekulatywnego oraz wyrafinowanych wielopoziomowych pamięci podręcznych). Jednocześnie zadanie pisania kompilatorów stało się znacznie bardziej skomplikowane, ponieważ kompilator musi chronić programistę przed taką złożonością.

Podczas pisania programów jednowątkowych nie można zobaczyć efektów tych różnych zmian kolejności instrukcji lub operacji pamięciowych. Jednak w przypadku programów wielowątkowych sytuacja jest zupełnie inna - jeden wątek może odczytać lokalizacje pamięci zapisane w innym wątku. Jeśli wątek A modyfikuje niektóre zmienne w określonej kolejności, w przypadku braku synchronizacji, wątek B może nie widzieć ich w tej samej kolejności - lub może ich nie widzieć w ogóle. Może to wynikać z tego, że kompilator zmienił kolejność instrukcji lub tymczasowo zapisał zmienną w rejestrze i zapisała ją później w pamięci; lub ponieważ procesor wykonywał instrukcje równolegle lub w innej kolejności niż określona przez kompilator; lub ponieważ instrukcje znajdowały się w różnych obszarach pamięci,a pamięć podręczna zaktualizowała odpowiednie lokalizacje pamięci głównej w innej kolejności niż ta, w której zostały zapisane. Niezależnie od okoliczności programy wielowątkowe są z natury mniej przewidywalne, chyba że wyraźnie zapewnisz, że wątki mają spójny obraz pamięci, używając synchronizacji.

Co tak naprawdę oznacza synchronizacja?

Java traktuje każdy wątek tak, jakby działał na własnym procesorze z własną pamięcią lokalną, a każdy z nich komunikuje się i synchronizuje ze współdzieloną pamięcią główną. Nawet w systemie jednoprocesorowym model ten ma sens ze względu na efekty pamięci podręcznej i wykorzystanie rejestrów procesora do przechowywania zmiennych. Gdy wątek modyfikuje lokalizację w swojej pamięci lokalnej, ta modyfikacja powinna w końcu pojawić się również w pamięci głównej, a JMM definiuje zasady, kiedy JVM musi przesyłać dane między pamięcią lokalną i główną. Architekci Javy zdali sobie sprawę, że nadmiernie restrykcyjny model pamięci poważnie osłabiłby wydajność programu. Próbowali stworzyć model pamięci, który pozwoliłby programom działać dobrze na nowoczesnym sprzęcie komputerowym, jednocześnie zapewniając gwarancje, które pozwoliłyby wątkom na interakcję w przewidywalny sposób.

Głównym narzędziem Java do przewidywalnego renderowania interakcji między wątkami jest synchronizedsłowo kluczowe. Wielu programistów myśli synchronizedściśle w kategoriach wymuszania semafora wzajemnego wykluczania ( mutex ), aby zapobiec wykonywaniu krytycznych sekcji przez więcej niż jeden wątek naraz. Niestety, ta intuicja nie w pełni opisuje, co synchronizedto znaczy.

Semantyka synchronizedrzeczywiście obejmuje wzajemne wykluczanie wykonania na podstawie statusu semafora, ale zawiera także reguły dotyczące interakcji wątku synchronizującego z pamięcią główną. W szczególności nabycie lub zwolnienie blokady wyzwala barierę pamięci - wymuszoną synchronizację między pamięcią lokalną wątku a pamięcią główną. (Niektóre procesory - takie jak Alpha - mają wyraźne instrukcje maszynowe do wykonywania barier pamięci.) Kiedy wątek wychodzi z synchronizedbloku, wykonuje barierę zapisu - musi wypłukać wszelkie zmienne zmodyfikowane w tym bloku do pamięci głównej przed zwolnieniem zamek. Podobnie, gdy wchodzisz dosynchronized blok, wykonuje barierę odczytu - to tak, jakby pamięć lokalna została unieważniona i musi pobrać z pamięci głównej wszelkie zmienne, do których będzie odwoływał się blok.

Właściwe użycie synchronizacji gwarantuje, że jeden wątek zobaczy efekty innego w przewidywalny sposób. Tylko wtedy, gdy wątki A i B zsynchronizują się na tym samym obiekcie, JMM zagwarantuje, że wątek B zobaczy zmiany wprowadzone przez wątek A i że zmiany wprowadzone przez wątek A wewnątrz synchronizedbloku pojawią się atomowo w wątku B (albo cały blok zostanie wykonany, albo żaden tak.) Ponadto JMM zapewnia, że synchronizedbloki synchronizowane na tym samym obiekcie będą wyglądać na wykonywane w tej samej kolejności, w jakiej są wykonywane w programie.

Więc co jest zepsute w DCL?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

Najbardziej skutecznym sposobem naprawienia idiomu DCL jest uniknięcie go. Najprostszym sposobem uniknięcia tego jest oczywiście użycie synchronizacji. Za każdym razem, gdy zmienna zapisywana przez jeden wątek jest odczytywana przez inny, należy użyć synchronizacji, aby zagwarantować, że modyfikacje będą widoczne dla innych wątków w przewidywalny sposób.

Inną możliwością uniknięcia problemów z DCL jest rezygnacja z leniwej inicjalizacji i zamiast tego użycie przyspieszonej inicjalizacji . Zamiast opóźniać inicjalizację do resourcemomentu pierwszego użycia, zainicjuj ją podczas konstruowania. Program ładujący klasy, który synchronizuje się z obiektem klas Class, wykonuje statyczne bloki inicjalizatora w czasie inicjalizacji klasy. Oznacza to, że efekt statycznych inicjatorów jest automatycznie widoczny dla wszystkich wątków, gdy tylko klasa zostanie załadowana.