Przygotowanie do SMP na Androida

Platformy z Androidem 3.0 i nowszymi wersjami są zoptymalizowane pod kątem obsługi architektur wieloprocesorowych. W tym dokumencie omawiamy problemy, które mogą wystąpić przy pisaniu kodu wielowątkowego dla symetrycznych systemów wieloprocesorowych w C, C++ i Java języka programowania (zwanego dalej po prostu „Java”, ponieważ i zwięzłości). Ten artykuł ma służyć jako wstęp dla deweloperów aplikacji na Androida, a nie jako kompletny dyskusję na ten temat.

Wprowadzenie

SMP to akronim angielskiej nazwy „Symmetric Multi-Processor”. Opisuje on projekt które 2 lub więcej identycznych rdzeni procesora mają dostęp do pamięci głównej. Jeszcze kilka lat temu wszystkie urządzenia z Androidem były UP (Uni-Processor).

Większość (jeśli nie wszystkie) urządzeń z Androidem zawsze miała wiele procesorów, ale w przeszłości tylko jeden z nich był używany do uruchamiania aplikacji, a pozostałe zarządzały różnymi elementami sprzętu urządzenia (np. radiem). Procesory mogą mieć różne architektury, działające na nich programy nie mogą używać pamięci głównej do komunikowania się inne.

Większość obecnie sprzedawanych urządzeń z Androidem jest zbudowana na podstawie projektu SMP, co komplikuje nieco pracę deweloperów oprogramowania. Warunki wyścigu w programie wielowątkowym nie może powodować widocznych problemów na jednoprocesorze, lecz regularnie kończy się niepowodzeniem, jeśli co najmniej 2 wątki które działają jednocześnie w różnych rdzeniach. Co więcej, kod może być bardziej lub mniej podatny na błędy podczas uruchamiania na różnych architekturach procesora, a nawet na różnych implementacjach tej samej architektury. Kod, który został dokładnie przetestowany pod architekturą x86, może nie działać prawidłowo w architekturze ARM. Po skompilowaniu kodu z użyciem bardziej nowoczesnego kompilatora może wystąpić błąd.

W dalszej części tego dokumentu znajdziesz wyjaśnienie, dlaczego tak jest i co musisz zrobić. aby zapewnić prawidłowe działanie kodu.

Modele spójności pamięci: dlaczego platformy SMP różnią się od siebie

Szybkie, przejrzyste omówienie złożonego tematu. W niektórych obszarach być niepełne, ale żadne z nich nie powinno być mylące ani błędne. Gdy zauważymy w następnej sekcji, to zwykle nie są istotne.

Więcej informacji znajdziesz w sekcji Więcej informacji na końcu dokumentu. i wskaźniki do bardziej szczegółowych metod leczenia.

Modele spójności pamięci, często po prostu „modele pamięci”, opisują gwarantuje język programowania lub architekturę sprzętu o dostępie do pamięci. Przykład: jeśli wpiszesz wartość adresową A, a następnie wpiszesz wartość pod adresem B, model może zagwarantować, że każdy rdzeń procesora wykryje, że te zapisy mają miejsce zamówienie.

Model, do którego przyzwyczaiła się większość programistów, to sekwencja spójność, która jest opisana w ten sposób (Adve & Gharachorloo):

  • Wszystkie operacje w pamięci są wykonywane pojedynczo
  • Wszystkie operacje w jednym wątku wyglądają na wykonywane w opisanej kolejności przez program danego podmiotu przetwarzającego dane.

Przyjmijmy na chwilę, że mamy bardzo prosty kompilator lub interpreter, który nie powoduje żadnych niespodzianek: przekształca przypisania w źródle w instrukcje wczytywania i przechowywania dokładnie w odpowiedniej kolejności, po jednej instrukcji na dostęp. Przyjmijmy także, – każdy wątek jest wykonywany na własnym procesorze.

Jeśli spojrzysz na fragment kodu i zauważysz, że odczyty i zapisy w sekwencyjnej spójnej architekturze procesora, wiesz, że kod odczyty i zapisy w oczekiwanej kolejności. Jest możliwe, że Procesor w rzeczywistości zmienia kolejność instrukcji i opóźnia odczyty i zapisy, ale nie jest możliwe, aby kod uruchomiony na urządzeniu wiedział, że procesor coś robi niż wykonanie instrukcji w prosty sposób. (Będziemy ignorować I/O sterownika urządzenia z mapowaną pamięcią).

Aby zilustrować te kwestie, warto przyjrzeć się krótkim fragmentom kodu, określa się je często jako testy lakmusowe.

Oto prosty przykład z kodem działającym w 2 wątkach:

Wątek 1 Wątek 2
A = 3
B = 5
reg0 = B
reg1 = A

W tym i wszystkich przyszłych przykładach litmus lokalizacje pamięci są reprezentowane przez wielkie litery (A, B, C), a rejestry procesora zaczynają się od „reg”. Cała pamięć to początkowo zero. Instrukcje są wykonywane od góry do dołu. Wątek 1 zapisuje wartość 3 w miejscu A, a potem wartość 5 w miejscu B. Wątek 2 wczytuje wartość z lokalizacji B do ciągu reg0, a następnie wczytuje wartość z z lokalizacji A do reg1. (pamiętaj, że piszemy w jednej kolejności i czytamy w lub inna).

Przyjmuje się, że wątki 1 i 2 są wykonywane na różnych rdzeniach procesora. Ty zawsze powinni o tym pamiętać, analizując w kodzie wielowątkowym.

Spójność sekwencyjna gwarantuje, że po zakończeniu obu wątków rejestr będzie miał jeden z tych stanów:

Rejestracje Stany
reg0=5, reg1=3 możliwe (najpierw uruchomiony wątek 1)
reg0=0, reg1=0 możliwe (wątek 2 został uruchomiony jako pierwszy)
reg0=0, reg1=3 możliwe (wykonanie równoczesne)
reg0=5, reg1=0 nigdy

Aby przejść do sytuacji, w której przed przekazaniem przez sklep A do punktu A bierzemy pod uwagę wartość B=5, odczyty lub zapisy muszą następować w niewłaściwej kolejności. Dzień w przypadku systemu sekwencyjnego, to nie może się zdarzyć.

Jednoprocesory, w tym x86 i ARM, zazwyczaj działają sekwencyjnie. Wątki wyglądają na wykonywanie przeplatanych procesów, gdy następuje przełączenie jądra systemu operacyjnego między nimi. Większość systemów SMP, w tym x86 i ARM, nie są sekwencyjne. Na przykład często dla sprzęt do buforowania w drodze do pamięci, nie uzyskują od razu dostępu do pamięci i nie stają się widoczne dla innych rdzeni.

Szczegóły znacznie się różnią. Na przykład x86, ale nie sekwencyjnie spójne, nadal gwarantuje, że reg0 = 5 i reg1 = 0 pozostanie niemożliwe. Sklepy są buforowane, ale ich kolejność zostaje zachowana. Z kolei ARM ich nie ma. Kolejność buforowanych sklepów nie jest i sklepy mogą nie docierać do wszystkich innych rdzeni jednocześnie. Te różnice są ważne dla programistów montażowych. Jednak, jak widać poniżej, programiści w C, C++ i Java i należy ją programować w sposób ukrywający takie różnice architektoniczne.

Dotychczas założyliśmy, że tylko sprzęt może zmieniać kolejność instrukcji. W rzeczywistości kompilator zmienia też kolejność instrukcji, aby zwiększyć wydajność. W naszym przykładzie kompilator może uznać, że później kod w Thread 2 wymagał wartości reg1, zanim potrzebny był kod reg0, a tym samym się Najpierw reg1. Możliwe też, że część z wcześniejszych kodów wczytała już kod A, a kompilator może zdecydować się na ponowne wykorzystanie tej wartości, zamiast ponownie wczytać wartość A. W obu przypadkach może być zmieniana kolejność załadowań do reg0 i reg1.

Zmiana kolejności dostępu do różnych lokalizacji pamięci (na poziomie sprzętu lub kompilatora) jest dozwolona, ponieważ nie wpływa na wykonanie pojedynczego wątku i może znacznie poprawić wydajność. Jak przekonamy się, z pewną starannością możemy też zapobiec wpływowi na wyniki programów wielowątkowych.

Ponieważ kompilatory również mogą zmieniać kolejność dostępu do pamięci, ten problem nie są nowością w platformach SMP. Nawet w przypadku jednoprocesorowego kompilatora może zmienić kolejność wczytywania reg0 i reg1 w naszym przykładzie, a wątek 1 można zaplanować pomiędzy instrukcje na zmianę kolejności. Jeśli jednak nasz kompilator nie zmodyfikuje kolejności, możemy nigdy nie zauważyć tego problemu. Na większości platform SMP z architekturą ARM, nawet bez kompilatora zmiany kolejności będą zwykle zauważalne po bardzo dużych liczby udanych uruchomień. Chyba że zajmujesz się programowaniem asemblera platformy SMP zazwyczaj zwiększają prawdopodobieństwo wystąpienia problemów, przez cały czas.

Programowanie bez wyścigów danych

Na szczęście zwykle istnieje łatwy sposób, aby uniknąć rozmyślania o tych szczegółach. Jeśli przestrzegasz prostych zasad, zwykle jest to bezpieczne. aby zapomnieć całą poprzednią sekcję z wyjątkiem „spójności sekwencyjnej” Pozostałe widżety mogą być widoczne, jeśli przypadkowego naruszenia tych zasad.

Współczesne języki programowania zachęcają do tzw. „wolnego od wyścigu danych”. stylu programowania. O ile obiecasz nie wprowadzać „wyścigów danych”, i unikaj kilku elementów, które informują kompilatora inaczej, a także sprzęt, z których można korzystać sekwencyjnie. Nie aby unikać zmiany kolejności dostępu do pamięci. Oznacza to, że jeśli przestrzegaj reguł, których nie będziesz w stanie stwierdzić, że dostęp do pamięci jest zmieniono kolejność. To jak powiedzenie, że kiełbasa jest pyszna, jeśli obiecujesz sobie nie odwiedzić fabryki kiełbasy. Wyścigi danych ujawniają brzydką prawdę o pamięci zmienić ich kolejność.

Co to jest „wyścig danych”?

Konkurencja danych występuje, gdy co najmniej 2 wątki mają jednoczesny dostęp do tych samych zwykłych danych, a co najmniej jeden z nich je modyfikuje. Zwykły i danych”. Nie jest to natomiast obiekt synchronizacji który służy do komunikacji w wątkach. Wyciszenie, zmienne warunków, Java obiekty zmienne lub obiekty atomowe w C++ nie są danymi zwykłymi, a ich dostępy mogą się ścigać. Służą one do zapobiegania wyścigom danych na innych platformach obiektów.

Aby określić, czy 2 wątki uzyskują jednocześnie dostęp do tego samego lokalizacji pamięci, możemy zignorować wspomnianą powyżej dyskusję na temat zmiany kolejności pamięci i zakładać spójność sekwencyjną. Ten program nie ma wyścigu danych jeśli A i B są zwykłymi zmiennymi logicznymi, które są Początkowo false (fałsz):

Wątek 1 Wątek 2
if (A) B = true if (B) A = true

Ponieważ operacje nie są zmieniane, oba warunki będą miały wartość „fałsz”, a żadna z zmiennych nigdy się nie zaktualizuje. Dlatego nie może dojść do wyścigu danych. Jest nie musisz zastanawiać się, co może się stać, jeśli obciążenie z A i zapisz do: B w Wątek 1 został w jakiś sposób zmieniony. Kompilator nie ma uprawnień do zmiany kolejności wątku 1, przepisując go na „B = true; if (!A) B = false”. Byłoby to jak robienie kiełbaski na środku miasta w pełnym słońcu.

Wyścigi danych są oficjalnie zdefiniowane na podstawie podstawowych wbudowanych typów, takich jak liczby całkowite i odniesienia lub wskaźniki. Przypisuję jednocześnie do elementu int czytanie tego w innym wątku to rasa danych. Jednak zarówno standardowa biblioteka C++, jak i biblioteki kolekcji Javy zostały napisane tak, aby umożliwić Ci rozważanie kolizji danych na poziomie biblioteki. Obiecują nie wprowadzać wyścigów danych chyba że istnieją jednoczesny dostęp do tego samego kontenera, co najmniej jeden z który ją aktualizuje. Aktualizuję zasób set<T> w 1 wątku podczas czytanie jej jednocześnie w innym umożliwia bibliotece wprowadzenie dlatego można ją traktować nieformalnie jako „wyścig danych na poziomie biblioteki”. I odwrotnie: aktualizowanie 1 elementu set<T> w jednym wątku podczas czytania co się różni, nie prowadzi do wyścigu danych, zobowiązuje się nie wprowadzać w tym przypadku (niskiego poziomu) wyścigu danych.

Normalnie równoczesny dostęp do różnych pól w strukturze danych nie możemy wprowadzić wyścigu danych. Występuje jednak jeden istotny wyjątek, ta reguła: ciągłe sekwencje pól bitowych w języku C lub C++ są traktowane jako pojedynczą „lokalizację pamięci”. Uzyskiwanie dostępu do dowolnego pola bitowego w takiej sekwencji jest traktowany jako dostęp do wszystkich istnienie wyścigu danych. Odzwierciedla to brak możliwości wspólnego sprzętu aktualizować poszczególne bity bez konieczności odczytywania i ponownego zapisywania sąsiednich bitów. Programiści Java nie mają analogicznych problemów.

Unikanie wyścigów danych

Nowoczesne języki programowania zapewniają pewną liczbę synchronizacji mechanizmów unikania wyścigów danych. Najbardziej podstawowe narzędzia to:

Blokady i wyciszenia
Wyciszenia (C++11 std::mutex lub pthread_mutex_t) lub można zastosować bloki synchronized w Javie, aby określone nie działają równolegle z innymi sekcjami kodu uzyskującymi dostęp i korzystać z tych samych danych. Te i inne podobne obiekty będziemy ogólnie nazywać jako „zamki”. Konsekwentne nabieranie konkretnej blokady przed uzyskaniem dostępu do udostępnionego elementu strukturę danych i publikując je w późniejszym okresie, zapobiega powstawaniu wyścigów się danych podczas uzyskiwania dostępu do struktury danych. Zapewnia też niejednoznaczny charakter aktualizacji i dostępów, tzn. nie inne aktualizacje struktury danych mogą być przeprowadzane w środku. To jedno z najczęstszych narzędzi do zapobiegania wyścigom danych. korzystanie z języka Java; synchronized bloków lub C++ lock_guard lub unique_lock sprawdź, czy blokady są prawidłowo zwalniane zdarzenia wyjątku.
Zmienne zmienne/atomowe
Java udostępnia volatile pól, które obsługują dostęp równoczesny bez wprowadzania wyścigów danych. Od 2011 roku języki C i C++ obsługują zmienne i pola atomic o podobnej semantyce. Są to są zwykle trudniejsze w użyciu niż blokady, ponieważ zapewniają jedynie poszczególne przypadki dostępu do pojedynczej zmiennej mają charakter atomowy. (W C++ ten kod obejmuje proste operacje odczytu, zmiany i zapisu, takie jak przyrosty. Plik Java wymaga do tego specjalnych metod). W przeciwieństwie do blokad zmienne volatile lub atomic nie mogą: używany bezpośrednio, aby inne wątki nie zakłócały dłuższych sekwencji kodu.

Pamiętaj, że kategoria volatile znacznie się różni znaczenia w C++ i Javie. W C++ interfejs volatile nie zapobiega przesyłaniu danych chociaż starszy kod często używa go jako obejściem braku atomic obiektów. Nie jest to już zalecane. cale C++, użyj atomic<T> w przypadku zmiennych, które mogą być używane równocześnie są dostępne w wielu wątkach. C++ volatile jest przeznaczony dla rejestracji urządzeń itp.

Zmiennych C/C++ atomic lub zmiennych Java volatile można używać do zapobiegania rywalizacji danych w przypadku innych zmiennych. Jeśli flag to zadeklarowano, że ma typ atomic<bool> lub atomic_bool(C/C++) lub volatile boolean (Java), i początkowo ma wartość false, to ten fragment nie zawiera wyścigów danych:

Wątek 1 Wątek 2
A = ...
  flag = true
while (!flag) {}
... = A

Ponieważ Thread 2 czeka na skonfigurowanie ustawień flag, dostęp Działanie A w wątku 2 musi nastąpić po przypisanie do użytkownika A w wątku 1. Dlatego nie ma wyścigu danych A Wyścig na drodze flag nie liczy się jako wyścig danych, bo niezmienne/niepodzielne dostępy nie są zwykłymi dostępami do pamięci.

Implementacja jest wymagana, aby zapobiec zmianie kolejności pamięci lub ją ukryć w wystarczającym stopniu, aby kod podobny do poprzedniego testu lakmusowego działał zgodnie z oczekiwaniami. Zwykle powoduje to niezmienne/niepodzielne dostęp do pamięci znacznie droższe niż zwykłe.

Chociaż poprzedni przykład nie uwzględnia wyścigu z danymi, blokuje się razem z Object.wait() w Javie lub zmiennych warunków w C/C++ zwykle pozwala znaleźć lepsze rozwiązanie, które nie wymaga zapętlenia wyczerpywanie się baterii.

Gdy zmiana kolejności pamięci staje się widoczna

Programowanie bez walki danych zwykle pozwala uniknąć konieczności rozwiązywania problemów z przestawianiem kolejności dostępu do pamięci. Istnieje jednak kilka przypadków która staje się widoczna:
  1. Jeśli w programie występuje błąd powodujący niezamierzone współzawodnictwo danych, mogą być widoczne przekształcenia kompilatora i sprzętu, a zachowanie programu może być zaskakujące. Na przykład jeśli zapomnimy zadeklarować, W poprzednim przykładzie zmienna flag może być zmienna, Wątek 2 może zobaczyć błąd niezainicjowano A. Kompilator może też uznać, że flaga nie może zmienić w trakcie pętli Thread 2 i przekształcić program na
    Wątek 1 Wątek 2
    A = ...
      flag = true
    reg0 = flaga; podczas gdy (!reg0) {}
    ... = A
    Podczas debugowania może się zdarzyć, że pętla będzie trwać bez końca, mimo fakt, że flag jest prawdziwe.
  2. C++ oferuje elementy relaksacyjne spójność sekwencyjną, nawet jeśli nie ma wyścigów. Operacje atomowe może przyjmować wyraźne argumenty memory_order_.... Podobnie Pakiet java.util.concurrent.atomic zapewnia bardziej ograniczone możliwości zestaw podobnych obiektów, w szczególności lazySet(). I Java programiści czasami wykorzystują celowe wyścigi danych, aby uzyskać podobny efekt. Wszystkie te rozwiązania poprawiają wydajność i kosztów związanych ze złożonością programowania. Omawiamy je tylko krótko poniżej.
  3. Część kodu w C i C++ jest napisana w starszym stylu, niezupełnie zgodne z aktualnymi standardami językowymi, według których volatile używane są zmienne zamiast atomic, a kolejność pamięci jest ułożona jest wyraźnie zabroniony przez wstawienie tzw. ogrodzenia lub bariery. Wymaga to wyraźnego uzasadnienia zmiany kolejności dostępu i zrozumienia modeli pamięci sprzętowej. stylu kodowania, które są wciąż używane w jądrze Linuksa. Nie w nowych aplikacjach na Androida. Nie jest też tutaj omawiane.

Nauka

Debugowanie problemów ze spójnością pamięci może być bardzo trudne. W przypadku braku blokada, deklaracje atomic lub volatile za pomocą kodu odczytujące nieaktualne dane, aby dowiedzieć się, dlaczego tak się dzieje, przeanalizuj zrzuty pamięci przy użyciu debugera. Zanim będzie można zapytanie debugera może spowodować, że wszystkie rdzenie procesora zaobserwowały pełny zestaw dostępu, zawartość pamięci i rejestry procesora będą widoczne w stanie „niemożliwego”.

Czego nie należy robić w języku C

Przedstawiamy tu kilka przykładów nieprawidłowego kodu oraz proste sposoby i je poprawić. Zanim to zrobimy, musimy omówić podstawową funkcję językową.

C/C++ i parametr „volatile”

Deklaracje volatile w C i C++ to narzędzie specjalne. Zapobiegają one kompilatorowi zmienianiu kolejności dostępów ulotnych lub ich usuwaniu. Może to być pomocne w przypadku kodu uzyskującego dostęp do rejestrów urządzeń, pamięci zmapowanej na więcej niż jedną lokalizację lub w połączeniu z setjmp Ale C i C++ volatile, w przeciwieństwie do Javy Aplikacja volatile nie służy do komunikacji w wątkach.

W C i C++ uzyskuje dostęp do volatile można zmieniać kolejność danych z dostępem do danych nieulotnych. Nie ma sensu gwarancje unikania atomów. Dlatego volatile nie można używać do udostępniania danych między wątkami w przenośnym kodzie, nawet na procesorze jednordzeniowym. C volatile zwykle nie zapobiega zmianie kolejności dostępu przez sprzęt, więc sama w sobie jest jeszcze mniej przydatna w środowiskach SMP z wielowątkowymi wątkami. Dlatego wsparcie C11 i C++11 atomic obiekty. Należy ich używać.

Wiele starszych kodów w C i C++ nadal wykorzystuje protokół volatile w wątku komunikacji między usługami. Często działa to prawidłowo w przypadku danych, które mieszczą się w rejestrze maszyny, pod warunkiem że są one używane z wyraźnymi barierami lub w przypadkach, gdy kolejność pamięci nie ma znaczenia. Nie gwarantujemy jednak, że narzędzie zadziała, z przyszłymi kompilatorami.

Przykłady

W większości przypadków lepiej jest mieć blokadę (np. pthread_mutex_t lub C++11 std::mutex) zamiast operacji atomowej, ale użyjemy tego drugiego, aby zilustrować proces w kontekście praktycznym.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

Polega to na przydzieleniu struktury, zainicjowaniu jej pól i na końcu „opublikowaniu” jej przez zapisanie w zmiennej globalnej. W tym momencie każdy inny wątek może go zobaczyć, ale to nie ma znaczenia, ponieważ jest on w pełni zainicjowany.

Problem polega na tym, że udało się zaobserwować połączenie ze sklepem gGlobalThing. przed zainicjowaniem pól, zwykle dlatego, że kompilator lub firma obsługująca płatności zmieniła kolejność sklepów na gGlobalThing i thing->x Inny czytany wątek od użytkownika thing->x mógł wartości 5, 0, a nawet dane niezainicjowane.

Głównym problemem jest tu wyścig danych w przypadku gGlobalThing. Jeśli Thread 1 wywołuje initGlobalThing(), a Thread 2 łączy się z: useGlobalThing(), gGlobalThing może być czytanych w trakcie pisania.

Aby rozwiązać ten problem, zadeklaruj gGlobalThing jako atomowe. W C++11:

atomic<MyThing*> gGlobalThing(NULL);

Dzięki temu zapisy będą widoczne dla innych wątków w odpowiedniej kolejności. Gwarantuje też zapobieganie innym awariom. tryby, które w innych przypadkach są dozwolone, ale mało prawdopodobne, aby wystąpiły w rzeczywistości. Wyposażenie sprzętowe Androida. Dzięki temu na przykład nie zobaczymy Wskaźnik gGlobalThing, który został napisany tylko częściowo.

Czego nie robić w Javie

Nie omówiliśmy jeszcze niektórych funkcji języka Java, więc pokażemy spójrzmy na te pierwsze.

Środowisko Java z technicznego punktu widzenia nie wymaga, by kod nie był oparty na wyścigu danych. Jest też niewielka ilość bardzo starannie napisanego kodu Java, który działa prawidłowo w obecności danych wyścigów. Napisanie takiego kodu jest jednak niezwykle jest trudne i omawiamy to krótko poniżej. Ważne Co gorsza, eksperci, którzy określili znaczenie takiego kodu, nie wierzą już jest prawidłowa. Specyfikacja sprawdza się w przypadku odtwarzania ).

Na razie będziemy stosować się do modelu bez wyścigu danych, w którym Gwarantują one zasadniczo takie same jak w C i C++. Jak już wspomniano, język zapewnia pewne elementy podstawowe, które jawnie złagodzą spójność sekwencyjną, a zwłaszcza Połączenia: lazySet() i weakCompareAndSet() w aplikacji java.util.concurrent.atomic. Tak jak w C i C++, na razie pominiemy je.

„Synchronizacja” języka Java i „zmienne” słowa kluczowe

Słowo kluczowe „zsynchronizowane” udostępnia wbudowaną funkcję blokowania w języku Java. . Każdy obiekt ma powiązany „monitor”, który może służyć do zbierania danych wzajemnie wykluczającego się dostępu. Jeśli 2 wątki próbują „zsynchronizować” ten sam obiekt, jeden z nich będzie czekać, aż drugi dokończy działania.

Jak wspomnieliśmy powyżej, volatile T w języku Java jest odpowiednikiem atomic<T> w C++11. Równoczesny dostęp do Pola volatile są dozwolone i nie powodują wyścigów danych. Ignorowanie: lazySet() i in. i wyścigów danych, zadaniem maszyny wirtualnej Java Dopilnuj, aby wyniki w dalszym ciągu były spójne.

W szczególności jeśli wątek 1 zapisuje w polu volatile, wątek 2 odczytuje następnie z tego samego pola i widzi nowo napisany tekst , to w wątku 2 będą również widoczne wszystkie zapisy wykonane wcześniej przez wątek 1. Jeśli chodzi o efekt pamięci, pisanie zmienna jest analogiczna do wersji monitora; odczyt z wartości zmiennych jest jak pozyskiwanie danych przez monitor.

Jest jedna znacząca różnica w porównaniu z C++: jeśli w Javie napiszemy volatile int x;, to x++ jest takie samo jak x = x + 1; wykonuje atomowe wczytanie, zwiększa wynik, a potem wykonuje atomowe zapisywanie. W przeciwieństwie do języka C++ przyrost wartości nie jest oddzielny. Operacje przyrostu atomowego są dostarczane przez java.util.concurrent.atomic.

Przykłady

Oto prosta, nieprawidłowa implementacja licznika monotonnego: (Java teoria i praktyka: „Zarządzanie zmiennością”).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

Przyjmij, że funkcja get() i incr() są wywoływane z wielu wątków i chcemy mieć pewność, że każdy wątek będzie odczytywał aktualną liczbę Funkcja get() jest wywoływana. Najbardziej rażącym problemem jest to, mValue++ to w rzeczywistości 3 operacje:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

Jeśli w projekcie incr() jednocześnie są wykonywane 2 wątki, jeden z nich aktualizacje mogły zostać utracone. Aby przyrost atomowy był atomowy, musimy zadeklarować Zsynchronizowano tabelę incr().

Nadal jednak nie działa, szczególnie na SMP. Nadal trwa wyścig danych, w tym, że get() ma dostęp do mValue równocześnie z incr() Zgodnie z regułami Javy wywołanie get() może być wyglądają na zmienioną kolejność w odniesieniu do innego kodu. Na przykład jeśli czytamy dwa w wierszu, wyniki mogą być niespójne ponieważ wywołania funkcji get() zmieniły kolejność elementów (przez sprzęt lub kompilatora. Możemy rozwiązać ten problem, zadeklarując użytkownika get() jako . Po wprowadzeniu tej zmiany kod jest bez wątpienia prawidłowy.

Niestety wprowadziliśmy możliwość rywalizacji o blokadę, może negatywnie wpłynąć na skuteczność. Zamiast deklarowania get() jako synchronizowanej, możemy zadeklarować mValue jako „niestabilną”. (Uwaga: incr() musi nadal używać synchronize, ponieważ w przeciwnym razie mValue++ nie będzie operacją atomową). Pozwala to też uniknąć wszystkich wyścigów danych, dzięki czemu spójność sekwencyjna jest zachowywana. Funkcja incr() będzie działać nieco wolniej, ponieważ obejmuje zarówno wejście/wyjście monitorowania, i inne koszty związane ze zmiennymi. Usługa get() będzie szybsza, więc nawet w przypadku braku rywalizacji jest to wygra się, jeśli czyta znacznie więcej niż pisze. (sposób całkowitego usunięcia synchronizowanego bloku znajdziesz też w artykule AtomicInteger).

Oto kolejny przykład podobny do wcześniejszych przykładów w języku C:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

Jest to taki sam problem, jak w przypadku kodu C, czyli że występuje wyścigu danych w sGoodies. Dlatego też przypisanie udziału w konwersji sGoodies = goods można zaobserwowano przed zainicjowaniem goods. Jeśli zadeklarujesz sGoodies z parametrem volatile słowo kluczowe, spójność sekwencyjna została przywrócona i wszystko będzie działać zgodnie z oczekiwaniami.

Pamiętaj, że zmienna jest tylko sama referencja sGoodies. a nie dostęp do znajdujących się w nim pól. Gdy sGoodies będzie volatile, a kolejność pamięci jest poprawnie zachowana, pola usługi nie mogą być jednocześnie używane. Instrukcja z = sGoodies.x wykonuje ładowanie zmiennych o wartości MyClass.sGoodies a następnie obciążenie nieulotne o wartości sGoodies.x. Jeśli utworzysz odniesie się do MyGoodies localGoods = sGoodies, wówczas kolejny element z = localGoods.x nie będzie dokonywać żadnych ładowania zmiennych.

W programowaniu w Javie bardziej popularnym idiomem jest niesławne „podwójne blokowanie”:

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

Chodzi o to, aby powiązać pojedynczy obiekt Helper z jednym wystąpieniem obiektu MyClass. Możemy tworzyć tylko go jednorazowo, więc tworzymy go i zwracamy za pomocą dedykowanego getHelper() . Aby uniknąć wyścigu, w którym 2 wątki tworzą instancję, musimy synchronizować proces tworzenia obiektu. Nie chcemy jednak płacić za to „zsynchronizowany” blok przy każdym wywołaniu, więc tę część wykonujemy tylko wtedy, helper ma obecnie wartość null.

Na polu helper trwa wyścig danych. Można go ustawić jednocześnie z helper == null w innym wątku.

Aby zobaczyć, jak może to nie zadziałać, rozważ ten sam kod, który został nieco przerobiony, tak jakby został skompilowany w języku podobnej do C (dodano kilka pól całkowitych, aby reprezentować aktywność konstruktora Helper’s):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

Nic nie stoi na przeszkodzie sprzętowi ani kompilatorowi od zmiany kolejności sklepu na helper x z y pól. Inny wątek może znaleźć helper nierówne zeru, ale jego pola nie są jeszcze ustawione i gotowe do użycia. Więcej informacji i więcej sposobów alokacji znajdziesz w załączniku „Double Checked Locking is Broken Declaration” lub w punkcie 71 („Use lazy initialization judiciously”) w książce Josha Blocha Effective Java, 2nd Edition.

Możesz to zmienić na 2 sposoby:

  1. Zrób to proste i usuń zewnętrzny przycisk kontroli. Dzięki temu nigdy sprawdzanie wartości helper poza zsynchronizowanym blokiem.
  2. Zadeklaruj zmienną helper. Dzięki tej niewielkiej zmianie w przykładzie J-3 będzie działać prawidłowo w Javie 1.5 i nowszych. (Warto również przez minutę na przekonanie się, że to prawda).

Oto kolejna ilustracja działania funkcji volatile:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

Jeśli Thread 2 nie wykryło jeszcze błędu useValues(), zaktualizuj ją do wersji vol1, nie będzie wiedzieć, czy data1 lub Ustawienie data2 zostało już ustawione. Po wyświetleniu aktualizacji do vol1 wie, że można bezpiecznie uzyskać dostęp do witryny data1 i prawidłowo czytać, nie wywołując wyścigu danych. Pamiętaj jednak: nie może wyciągnąć żadnych założeń na temat tego sklepu (data2), ponieważ był on i przeprowadzane po zapisywaniu zmiennych.

Pamiętaj, że nie można użyć opcji volatile, aby zapobiec zmianie kolejności. dostępu do pamięci, które ścigają się ze sobą. Nie ma gwarancji, że: wygenerować instrukcję ogrodzenia pamięci maszyny. Może pomóc w zapobieganiu wyścigi danych przez wykonywanie kodu tylko wtedy, gdy inny wątek spełnił warunki określony warunek.

Co możesz zrobić

W języku C/C++ preferuj C++11 klas synchronizacji, takich jak std::mutex. Jeśli nie, użyj odpowiednie operacje pthread. Obejmują one odpowiednie zabezpieczenia pamięci, o ile nie określono inaczej) i wydajne działanie na wszystkich wersjach platformy Androida. Pamiętaj, by z nich korzystać . Pamiętaj na przykład, że oczekiwanie na zmienną warunku może być nieoczekiwanie zwracane bez sygnału, dlatego powinno być używane w pętli.

Należy unikać bezpośredniego używania funkcji atomowych, chyba że struktura danych jest niezwykle prosty, podobnie jak licznik zdarzeń. Blokowanie i odblokowywanie pthread mutex wymagają wykonania pojedynczej operacji atomowej, która często kosztuje mniej niż pojedynczy błąd w pamięci podręcznej, jeśli nie ma rywalizacji. Zatem nie zyskasz wiele, zastępując wywołania mutexu operacjami atomowymi. Konstrukcja niewymagająca blokady w przypadku prostych struktur danych wymaga aby zapewnić, że operacje wyższego poziomu na strukturze danych wydają się być atomowe (jako całość, a nie tylko ich wyraźnie atomowe).

Jeśli wykonujesz operacje atomowe, rozluźniając kolejność Skuteczność: memory_order... lub lazySet() korzyści, ale wymaga głębszego zrozumienia, niż to podpowiadaliśmy do tej pory. Duża część istniejącego kodu korzysta z ale później zostaje wykryte w nich błędy. W miarę możliwości unikaj ich. Jeśli Twoje zastosowania nie pasują do żadnego z wymienionych w następnej sekcji, upewnij się, że jesteś ekspertem,

Unikaj używania interfejsu volatile do komunikacji w wątkach w języku C/C++.

W Javie problemy z równoczesnością często najlepiej rozwiązać poprzez za pomocą odpowiedniej klasy narzędzi pakiet java.util.concurrent. Kod jest dobrze napisany przetestowanych na platformie SMP.

Być może najbezpieczniej jest zapewnić niezmienność obiektów. Obiekty z klas, takich jak ciąg znaków w Javie i dane blokady liczby całkowitej, których nie można zmienić raz co pozwala uniknąć generowania wyścigów danych na tych obiektach. Książka Effective Java, 2nd Ed. zawiera szczegółowe instrukcje w artykule „Element 15: Minimalizuj zmienność”. Notatka w Szczególnie ważne jest zadeklarowanie pól Java jako „final” (Bloch).

Nawet jeśli obiekt jest niezmienny, pamiętaj, że przekazywanie go do innego wątku bez synchronizacji jest rywalizacją danych. Czasami może się to zdarzyć. jest akceptowalna w Javie (patrz poniżej), ale wymaga dużej uwagi i może spowodować lub fragmentami kodu. Jeśli nie jest to szczególnie ważne, dodaj parametr volatile. W C++ przekazywanie wskaźnika lub odwołania do niezmiennego obiektu bez odpowiedniej synchronizacji, podobnie jak dowolna rywalizacja danych, jest błędem. W takim przypadku jest uzasadnione prawdopodobieństwo wystąpienia okresowych awarii, ponieważ na przykład wątek odbierający może zobaczyć niezainicjowaną tabelę metod z powodu zmiany kolejności sklepu.

Jeśli ani istniejąca klasa biblioteki, ani klasa niezmienna nie jest odpowiednia, należy użyć instrukcji Java synchronized lub C++ lock_guard / unique_lock, aby zabezpieczyć dostęp do dowolnego pola, do którego dostęp może mieć więcej niż 1 wątek. Co się dzieje z muteksami sprawdzi się w Twojej sytuacji, zadeklaruj udostępnione pola volatile lub atomic, ale musisz uważać na rozumienia interakcji między wątkami. Te deklaracje nie uchronią Cię przed typowymi błędami w programowaniu równoległym, ale pomogą uniknąć tajemniczych awarii związanych z kompilatorami optymalizującymi i awariami SMP.

Unikaj „publikowanie” odwołaniem do obiektu, czyli udostępnienie go innym i wątkach w konstruktorze. W języku C++ jest to mniej istotne. o wyścigach danych, prowadzone w języku Java. Zawsze jest to jednak dobra rada, ponieważ jest kluczowe, jeśli używany jest kod w Javie działają w innych kontekstach, w których model zabezpieczeń Java ma znaczenie, i nie są zaufane. przez uzyskanie dostępu do „wyciekującego” kodu może spowodować wyścig danych odwołania do obiektu. Niezbędne jest też, jeśli zignorujesz nasze ostrzeżenia i skorzystasz z niektórych metod w następnej sekcji. Zapoznaj się z artykułem (Bezpieczne techniki budowlane w Javie): szczegóły

Kilka informacji o zamówieniach dotyczących słabej pamięci

C++11 i nowsze wersje zawierają jawne mechanizmy, które umożliwiają złagodzenie gwarancji spójności sekwencyjnej w przypadku programów bez rywalizacji danych. Wulgaryzmy memory_order_relaxed, memory_order_acquire (wczytania tylko) i memory_order_release(tylko magazyny) argumentów dla atomów poszczególne operacje dają ściśle słabsze gwarancje niż domyślne, zwykle niejawna, memory_order_seq_cst. memory_order_acq_rel udostępnia zarówno memory_order_acquire, jak i Gwarancje memory_order_release niepodzielnego zapisu w trybie odczytu i modyfikacji operacji. memory_order_consume nie jest jeszcze wystarczająco zostały prawidłowo określone lub wdrożone jako przydatne. Na razie należy je zignorować.

Metody lazySetJava.util.concurrent.atomic są podobne do metod memory_order_release w C++. Java zmienne zwykłe są czasami używane zamiast memory_order_relaxed dostęp, chociaż są to jeszcze słabszy. W przeciwieństwie do języka C++ nie ma prawdziwego mechanizmu dla uzyskuje dostęp do zmiennych zadeklarowanych jako volatile.

Należy unikać takich działań, chyba że istnieją istotne czynniki mające na celu zwiększenie skuteczności, korzystanie z nich. Słabo uporządkowane architektury maszyn, takie jak ARM, zwykle zapisuje się na rzędzie kilkudziesiąt cykli maszyny na każdą operację atomową. W systemach x86 wzrost skuteczności jest ograniczony do sklepów i prawdopodobnie będzie mniejszy jest zauważalna. Raczej wbrew intuicji, korzyść może się zmniejszyć przy większej liczbie rdzeni, gdy system pamięci staje się czynnikiem ograniczającym.

Pełna semantyka słabo uporządkowanych elementów atomowych jest skomplikowana. Na ogół wymagają dokładne zrozumienie reguł językowych, co będziemy nie należy tutaj wprowadzać. Na przykład:

  • Kompilator lub sprzęt mogą przenieść plik memory_order_relaxed uzyskuje dostęp do (ale nie poza) sekcji krytycznej zamkniętej kłódką pozyskanie i udostępnienie treści. Oznacza to, że dwa memory_order_relaxed sklepy mogą stać się niewidoczne, nawet jeśli są oddzielone sekcją krytyczną.
  • Może być wyświetlana zwykła zmienna Java, jeśli jest używana jako wspólny licznik do innego wątku w celu zmniejszenia, mimo że jest zwiększony tylko o jeden w innym wątku. Nie dotyczy to jednak atomowych instrukcji w C++.memory_order_relaxed

W ramach ostrzeżenia omówimy tu niewielką liczbę idiomów, które zdają się obejmować wiele zastosowania w przypadkach słabo uporządkowanego atomu. Wiele z nich dotyczy tylko C++.

Niewyścigi

Zmienna jest często atomowa, ponieważ czasami jest odczytywana równolegle z zapisem, ale nie wszystkie dostępy mają ten problem. Przykład: zmienna muszą być atomowe, ponieważ są odczytywane poza sekcją krytyczną, ale wszystkie aktualizacje są chronione blokadą. W takim przypadku operacja odczytu chroniona przez ten sam blokadę nie może być wykonywana równolegle, ponieważ nie może być równoczesnych operacji zapisu. W takim przypadku dostęp bez rywalizacji (w tym przypadku wczytywanie) może być opatrzony adnotacją memory_order_relaxed bez zmiany poprawności kodu C++. Implementacja blokady wymusza już wymaganą kolejność pamięci w odniesieniu do dostępu dla innych wątków, a także memory_order_relaxed wskazuje, że zasadniczo nie trzeba stosować żadnych dodatkowych ograniczeń porządkujących jest wymuszane na potrzeby niepodzielnego dostępu.

W Javie nie ma czegoś takiego jak analog.

Poprawność wyniku nie jest traktowana jako traktowana

Gdy używamy obciążenia szybkiego tylko do wygenerowania podpowiedzi, zazwyczaj nie narzucamy żadnej kolejności pamięci dla obciążenia. Jeśli wartość to nie jest wiarygodne, dlatego nie możemy rzetelnie użyć wyniku do wywnioskowania czegokolwiek innych zmiennych. Dlatego jest to w porządku jeśli kolejność pamięci nie jest gwarantowana, a obciążenie została podana z argumentem memory_order_relaxed.

Częstym wystąpienie tego typu to użycie języka C++ compare_exchange aby atomowo zastąpić element x wartością f(x). Początkowe obciążenie kolumny x do obliczenia wartości f(x) nie muszą być wiarygodne. Jeśli się pomylimy, Błąd compare_exchange zostanie ponowiony. Początkowe wczytywanie aplikacji x może być dozwolone argument memory_order_relaxed; kolejność tylko pamięci dla rzeczywistego compare_exchange ma znaczenie.

Dane atomowo zmodyfikowane, ale nieprzeczytane

Czasami dane są modyfikowane równolegle przez wiele wątków, ale nie jest sprawdzane, dopóki nie zostaną ukończone równoległe obliczenia. Dobra Przykładem tego jest licznik, który jest zwiększany atomowo (np. przy użyciu fetch_add() w C++ lub atomic_fetch_add_explicit() w C) przez wiele wątków równolegle, ale wynik tych wywołań jest zawsze ignorowany. Wartość wynikowa jest odczytywana dopiero na końcu, gdy wszystkie aktualizacje zostaną zakończone.

W takim przypadku nie można określić, czy użytkownik uzyskuje dostęp do tych danych została zmieniona i dlatego kod C++ może zawierać nagłówek memory_order_relaxed .

Częstym przykładem tego są proste liczniki zdarzeń. Ponieważ jest to bardzo częsty przypadek, warto zwrócić uwagę na kilka kwestii:

  • Używanie memory_order_relaxed zwiększa wydajność, ale może nie rozwiązać najistotniejszego problemu ze skutecznością. Każda aktualizacja wymaga wyłącznego dostępu do wiersza pamięci podręcznej zawierającego licznik. Ten powoduje pominięcie w pamięci podręcznej za każdym razem, gdy nowy wątek uzyskuje dostęp do licznika. Znacznie szybsze jest, jeśli aktualizacje są częste i pojawiają się na przemian między wątkami. uniknąć aktualizowania udostępnianego licznika za każdym razem, na przykład przez użycie lokalnych liczników wątków/wątków i zsumowanie ich na końcu.
  • Tę metodę można łączyć z poprzednią sekcją: jednocześnie odczytywać przybliżone i niemiarodajne wartości podczas ich aktualizowania, ze wszystkimi operacjami za pomocą funkcji memory_order_relaxed. Ważne jest jednak, aby uzyskane wartości traktować jako całkowicie zawodne. Sam fakt, że licznik wydaje się być zwiększony raz, nie oznacza, oznacza, że kolejny wątek dotrze do punktu przy którym wykonano przyrost. Zamiast tego przyrost może mieć został ponownie zamówiony z wcześniejszym kodem. (W przypadku podobnej sytuacji, o której wspomnieliśmy wcześniej, C++ gwarantuje, że drugie załadowanie takiego licznika nie zwróci wartości mniejszej niż wcześniejsze załadowanie w tym samym wątku. O ile nie oczywiście licznik przepełnił się).
  • Często można znaleźć kod, który próbuje obliczyć przybliżone wartości licznika, wykonując poszczególne niepodzielne odczyty i zapisy (lub nie), ale a nie jako całościowy przyrost. Zazwyczaj argumentem jest to, to brzmi „wystarczająco blisko” dla liczników skuteczności itp. Zwykle nie jest. Jeśli aktualizacje są dość częste (np. zapewne Ci zależy), znaczna część wyników zgubiony. W przypadku urządzeń czterordzeniowych może dojść do utraty ponad połowy wartości. (Łatwe ćwiczenie: utwórz scenariusz z 2 wątkami, w którym licznik zdarzeń został zaktualizowany milion razy, ale końcowa wartość licznika wynosi jeden).

Prosta komunikacja z użyciem flag

zapisu memory_order_release (lub operacji odczytu, zmiany i zapisu) gwarantuje, że w przypadku późniejszego obciążenia memory_order_acquire (lub operacja odczyt-modyfikacja-zapis) odczytuje zapisaną wartość, a następnie zaobserwowane również wszelkie magazyny (zwykłe lub niepodzielne), które poprzedzały Sklep w firmie memory_order_release. I odwrotnie, wszystkie wczytywania poprzedzający ciąg memory_order_release nie będzie obserwowany sklepy, w których wystąpiło obciążenie płatnością za memory_order_acquire. W przeciwieństwie do zasady memory_order_relaxed umożliwia to takie operacje niepodzielne służy do informowania o postępach jednego wątku w drugi.

Możemy na przykład zmienić przykład z podwójnym zamkiem z powyżej w C++ jako

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

Magazyn pobierania i zwalniania zagwarantuje, że jeśli pojawi się wartość inna niż null helper, jego pola również zostaną prawidłowo zainicjowane. Włączyliśmy również wcześniejsze obserwacje, że ładunki inne niż wyścigowe może używać elementu memory_order_relaxed.

Programista w języku Java może w sposób oczywisty przedstawić helper jako java.util.concurrent.atomic.AtomicReference<Helper> i używać lazySet() jako magazynu wersji. Operacje wczytywania będą nadal używać zwykłych wywołań get().

W obu przypadkach poprawa wydajności koncentrowała się na inicjowaniu. które prawdopodobnie nie mają krytycznego znaczenia dla wydajności. Kompromis, który ułatwia czytelność:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

Zapewnia to tę samą szybką ścieżkę, ale powoduje przejście do ustawień domyślnych. sekwencyjnie spójne, operacje w zwolnionym tempie, które nie ma krytycznego znaczenia ścieżki konwersji.

Nawet tutaj helper.load(memory_order_acquire) jest może wygenerować ten sam kod na aktualnie obsługiwanym architektury jako proste (sekwencyjnie spójne) odniesienie do helper Najbardziej korzystną optymalizacją może być wprowadzenie myHelper, aby wyeliminować drugie wczytywanie, ale przyszły kompilator może to zrobić automatycznie.

Kolejność operacji nabywania i zwalniania nie zapobiega opóźnieniom w przetwarzaniu sklepów i nie zapewnia, że będą one widoczne dla innych wątków w konsekwentnej kolejności. W rezultacie nie obsługuje on trudnego, ale dość powszechnego wzoru kodowania, jakim jest algorytm wzajemnego wykluczenia Dekkera: wszystkie wątki najpierw ustawiają flagę wskazującą, że chcą coś zrobić; jeśli wątek t zauważy, że żaden inny wątek nie próbuje czegoś zrobić, może bezpiecznie kontynuować, wiedząc, że nie będzie zakłóceń. Żaden inny wątek nie będzie może kontynuować, bo flaga t jest nadal ustawiona. Ta operacja zakończy się niepowodzeniem, jeśli dostęp do flagi jest uzyskiwany za pomocą kolejności pobierania/zwalniania, ponieważ nie zapobiega to wyświetlaniu flagi wątku innym osobom po tym, jak te osoby błędnie kontynuują działanie. Domyślna wartość memory_order_seq_cst temu zapobiega.

Pola niezmienne

Jeśli pole obiektu zostanie zainicjowane przy pierwszym użyciu, a potem nigdy nie zostanie zmienione, można ją zainicjować, a następnie odczytać, używając uporządkowanych dostępu. W C++ może być zadeklarowana jako atomic. a dostęp do nich jest możliwy za pomocą języka memory_order_relaxed lub języka Java, można zadeklarować bez użycia metody volatile i uzyskać do niego dostęp bez środków specjalnych. Wymaga to spełnienia wszystkich tych warunków:

  • Wartość powinna być możliwa do określenia na podstawie wartości samego pola czy została już zainicjowana. Aby uzyskać dostęp do tego pola: w przypadku szybkiej ścieżki wartość testu i zwrotu z pola powinna odczytywać pole tylko raz. W Javie najważniejsza jest ta druga opcja. Nawet jeśli testy funkcjonalne zostały zainicjowane, drugie wczytanie może odczytać wcześniejszą niezainicjowaną wartość. W języku C++ reguła „czytaj raz” jest tylko dobrą praktyką.
  • Zarówno inicjowanie, jak i kolejne wczytywanie musi być niepodzielne, że częściowe aktualizacje nie powinny być widoczne. W przypadku Javy pole nie powinien mieć typu long ani double. W przypadku języka C++ wymagane jest przypisanie atomowe; ale jego konstrukcja nie zadziała, konstrukcja elementu atomic nie jest atomowa.
  • Ponowne inicjowanie musi być bezpieczne, ponieważ wiele wątków może jednocześnie odczytywać niezainicjowaną wartość. W C++ zwykle z elementu „łatwo do skopiowania” wymagania nakładane na wszystkie typy atomowe; typów z zagnieżdżonymi wskaźnikami własnymi wymagałyby Deallocation (lokalizacja) w konstruktorem tekstu i nie można ich było kopiować. W Javie: Dopuszczalne są określone typy plików referencyjnych:
  • Odwołania w Javie są ograniczone do typów stałych zawierających tylko ostateczne . Konstruktor typu stałego nie powinien być publikowany odwołaniem do obiektu. W tym przypadku reguły pól końcowych w języku Java zapewniają, że jeśli czytnik zobaczy odwołanie, zobaczy też zainicjowane pola końcowe. C++ nie ma analogu do tych reguł, wskaźniki do obiektów będących własnością również są niedozwolone (w nie tylko naruszają „zasadę prostego kopiowania”, ).

Uwagi końcowe

Choć dokument to coś więcej niż tylko zarysowanie powierzchni, nie jest zbyt płytki. To bardzo szeroki i szczegółowy temat. Niektóre obszary wymagające dalszego zbadania:

  • Rzeczywiste modele pamięci w językach Java i C++ są wyrażane w formie relacji zdarza się wcześniej, która określa, kiedy 2 działania z pewnością wystąpią w określonej kolejności. Gdy definiujemy wyścig danych, mówimy o 2 dostępach do pamięci odbywających się „jednocześnie”. Oficjalnie oznacza to, że żaden z tych procesów nie jest poprzedzony przez inny. Warto poznać faktyczne definicje terminu stanie się przed. i synchronizuje-with w modelu pamięci Java lub C++. Chociaż intuicyjne pojęcie „jednocześnie” jest ogólnie dobra te definicje są pouczające, zwłaszcza jeśli na podstawie słabo uporządkowanych operacji atomowych w C++. (Obecna specyfikacja Java określa tylko lazySet() w sposób nieformalny).
  • Dowiedz się, czym są kompilatory, a czego nie mogą robić podczas zmiany kolejności kodu. (Specyfikacja JSR-133 zawiera świetne przykłady przekształceń prawnych, które prowadzą do nieoczekiwane rezultaty).
  • Dowiedz się, jak pisać klasy stałe w Javie i C++. (To nie wszystko niż tylko „nie zmieniaj niczego po budowie”).
  • Zaimportuj rekomendacje w sekcji Równoczesność w artykule Obowiązujące Java – wersja 2. (Unikaj na przykład wywoływania metod, które: do zastąpienia w zsynchronizowanym bloku).
  • Przejrzyj interfejsy API java.util.concurrentjava.util.concurrent.atomic, aby zobaczyć, co jest dostępne. Rozważ użycie adnotacje równoczesności, takie jak @ThreadSafe i @GuardedBy (z net.jcip.annotations).

W sekcji Przeczytaj więcej w aneksie znajdziesz linki do dokumentów i witryn internetowych, które pomogą Ci lepiej poznać te tematy.

Dodatek

Implementacja magazynów synchronizacji

(nie jest to rozwiązanie dla większości programistów, ale dyskusja jest wciągająca).

W przypadku małych wbudowanych typów, takich jak int, oraz sprzętu obsługiwanego przez Androida zwykłe instrukcje wczytywania i przechowywania zapewniają, że magazyn będzie widoczny w całości lub wcale dla innego procesora wczytującego tę samą lokalizację. W związku z tym pewne podstawowe pojęcie „atomowość” jest dostępny bezpłatnie.

Jak już wspomnieliśmy, to nie wystarczy. Aby zapewnić sekwencyjną kontrolę potrzebną także do zapewnienia spójności działań, aby operacje pamięci były widoczne dla innych procesów w jednym zamówienie. Okazuje się, że to drugie rozwiązanie jest automatyczne na Androidzie. pod warunkiem, że dokonamy przemyślanych wyborów w celu egzekwowania pierwszego więc przeważnie je pominęliśmy.

Kolejność operacji w pamięci jest zachowywana przez zapobieganie zmianie kolejności przez kompilatora i zapobiegać zmianie kolejności przez sprzęt. Skupiamy się na tym, w związku z tym drugim.

Sortowanie pamięci w procesorach ARMv7, x86 i MIPS jest egzekwowane przez „płot” instrukcje, które mniej więcej zapobiega ujawnianiu instrukcji następujących po ogrodzeniu przed instrukcją poprzedzającą ogrodzenie. (Są to również często „bariera” instrukcji, ale wiążą się z tym wątpliwości Barierki w stylu pthread_barrier, które działają znacznie lepiej niż ta wartość). Dokładne znaczenie instrukcje dotyczące ogrodzenia to dość skomplikowany temat, który musi dotyczyć sposób, w jaki gwarancje zapewniane przez wiele różnych rodzajów ogrodzeń współdziałają i łączą się z innymi gwarancjami kolejności zapewnia sprzęt. To ogólne omówienie, więc omówimy nad nimi szczegóły.

Podstawowym rodzajem gwarancji zamówienia jest ta świadczona przez język C++. memory_order_acquire i memory_order_release niepodzielne operacje: operacje w pamięci poprzedzające magazyn wersji powinna być widoczna po wczytaniu wczytywania. W architekturze ARMv7 wyegzekwowane przez:

  • Poprzedzenie instrukcji w sklepie odpowiednią instrukcją dotyczącą ogrodzenia Uniemożliwia to zmienianie kolejności wszystkich wcześniejszych dostępów do pamięci za pomocą z instrukcją obsługi klienta. (Ponadto niepotrzebnie zapobiega ponownemu przesyłaniu za pomocą później sklepu).
  • Postępując zgodnie z instrukcjami dotyczącymi obciążenia i właściwą instrukcją dotyczącą ogrodzeń, co zapobiega zmianie kolejności wczytywania przy kolejnych dostępach. (po raz kolejny należy podać niepotrzebne wartości w kolejności z co najmniej wcześniejszym wczytywaniem).

Łącznie wystarczają one do porządkowania informacji o pozyskaniu/wydaniach w C++. Są one konieczne, ale nie wystarczają do obsługi języka Java volatile lub C++ sekwencyjnie atomic.

Żeby dowiedzieć się, czego jeszcze potrzebujemy, przyjrzyjmy się fragmentowi algorytmu Dekkera o których wspomnieliśmy wcześniej. flag1flag2 to zmienne C++ atomic lub Java volatile, obie początkowo mają wartość false.

Wątek 1 Wątek 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

Spójność sekwencyjna oznacza, że jedno z przypisanych Metoda flagn musi zostać najpierw uruchomiona i musi zostać wyświetlona test można znaleźć w drugim wątku. Zatem nigdy nie zobaczymy żeby jednocześnie wykonywać „krytyczne rzeczy”.

Szermierka wymagana przy zamawianiu nabycia zwolnienia z opodatkowania dodaje tylko ogrodzenia na początku i na końcu każdego wątku, co nie pomaga tutaj. Musimy także upewnić się, że volatile/atomic sklepu następuje po volatile/atomic, ich kolejność nie zmienia się. Zasadniczo jest to egzekwowane przez dodanie ogrodzenia nie bezpośrednio przed w tym samym sklepie, ale także po nim. (To znów jest znacznie silniejsze niż jest wymagane, ponieważ ogrodzenie zazwyczaj wymaga wszystkich wcześniejszych dostępów do pamięci w odniesieniu do wszystkich późniejszych).

Moglibyśmy zamiast tego powiązać dodatkowe ogrodzenie z sekcją stabilnego wczytywania. Sklepy zdarzają się rzadziej, więc konwencja jest bardziej powszechny i używany na Androidzie.

Jak widzieliśmy we wcześniejszej sekcji, musimy wstawić barierę sklep/obciążenia między tymi dwoma operacjami. Kod wykonywany w maszynie wirtualnej w celu zapewnienia niezmiennego dostępu będzie wyglądać mniej więcej tak:

obciążenie zmienne magazyn zmiennych
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Architektury prawdziwych maszyn często zapewniają wiele typów zabezpieczeń, które porządkują różne typy dostępów i mogą mieć różny koszt. Wybór między nimi jest subtelny i ma wpływ przez konieczność zapewnienia, że sklepy są widoczne dla innych rdzeni porządek, a kolejność pamięci narzucana przez połączenie różnych płotów będzie poprawne. Aby dowiedzieć się więcej, zobacz stronę Uniwersytetu w Cambridge z zebrano mapowania elementów atomowych na rzeczywiste procesory.

W niektórych architekturach, zwłaszcza x86, funkcja pozyskiwania i „release” Bariery są niepotrzebne, bo sprzęt zawsze egzekwuje wystarczającą kolejność. Dlatego na platformie x86 generowany jest tylko ostatni element ogrodzenia (3). Analogicznie w architekturze x86 niepodzielna sekwencja odczytu-modyfikacja-zapis nie zawsze powinny obejmować solidne ogrodzenie. Dlatego nigdy nie wymagają ogrodzenia. W architekturze ARMv7 wszystkie omówione powyżej ogrodzenia są

ARMv8 udostępnia instrukcje LDAR i STLR, które bezpośrednio wymuszanie sekwencyjnego stosowania wymagań dotyczących zmiennych w języku Java lub języka C++ ładunki i magazyny. Pozwala to uniknąć niepotrzebnych ograniczeń dotyczących kolejności wspomniane powyżej. Są to 64-bitowy kod Androida na procesorach ARM. postanowiliśmy Skupmy się na rozmieszczeniu ogrodzeń ARMv7, ponieważ rzuca ono więcej światła na to, ze względu na rzeczywiste wymagania.

Więcej materiałów

strony internetowe i dokumenty, które są bardziej złożone; Im bardziej ogólnie znajdują się bliżej początku listy.

Modele spójności współdzielonej pamięci: samouczek
Napisane w 1995 roku przez Adve i Gharachorloo, to dobry punkt wyjścia, jeśli chcesz zagłębić się w modele spójności pamięci.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Barierki pamięci
Świetny artykuł podsumowujący problemy.
https://pl.wikipedia.org/wiki/Memory_barrier
Podstawy dotyczące wątków
Wprowadzenie do programowania wielowątkowego w językach C++ i Javy – Hans Boehm. Omówienie wyścigów danych i podstawowych metod synchronizacji.
http://www.hboehm.info/c++mm/threadsintro.html
Równoczesność Javy w praktyce
Książka ta została opublikowana w 2006 r. i omawia szczegółowo wiele różnych zagadnień. Zdecydowanie zalecany dla każdego, kto pisze wielowątkowy kod w Javie.
http://www.javaconcurrencyinpractice.com
JSR-133 (Java Memory Model) – najczęstsze pytania
Krótkie wprowadzenie do modelu pamięci Java, w tym wyjaśnienie synchronizacji, zmiennych zmiennych i tworzenia pól końcowych. (trochę przestarzały, zwłaszcza w części dotyczącej innych języków)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Prawidłowość przekształceń programu w modelu pamięci Java
Raczej techniczne wyjaśnienie pozostałych problemów Model pamięci Java. Te problemy nie dotyczą wyścigu z danymi programów.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
Omówienie pakietu java.util.concurrent
Dokumentacja pakietu java.util.concurrent. U dołu strony znajduje się sekcja zatytułowana „Właściwości spójności pamięci”, w której wyjaśniamy gwarancje udzielane przez różne klasy.
java.util.concurrent Podsumowanie pakietu
Teoria i praktyka Javy: bezpieczne techniki konstrukcyjne w Javie
W tym artykule szczegółowo omawiamy zagrożenia związane z odwołaniami, które mogą uciec podczas tworzenia obiektu, oraz przedstawiamy wskazówki dla konstruktorów bezpiecznych do wątków.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Teoria i praktyka Javy: zarządzanie zmiennością
Przydatny artykuł opisujący, co można, a czego nie można osiągnąć w języku Java, korzystając z pól zmiennych.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
Oświadczenie „Double-Checked Locking is Broken”
Szczegółowe wyjaśnienie Billa Pugha na temat różnych sposobów łamania weryfikacji zamka za pomocą funkcji volatile i atomic. Obejmuje język C/C++ i Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus Tests i książka kucharska
Dyskusja na temat problemów z platformą ARM z platformą SMP wraz z krótkimi fragmentami kodu ARM. Jeśli podane przykłady na tej stronie są zbyt mało konkretne lub chcesz zapoznać się z formalnym opisem instrukcji dotyczącej DMB, przeczytaj je. Zawiera też opis instrukcji używanych do tworzenia barier pamięci w ewidencjonowanym kodzie (może być przydatne, jeśli generujesz kod na bieżąco). Zwróć uwagę, że jest ona starsza od ARMv8, która również obsługuje dodatkowe instrukcje porządkowania pamięci i przechodzi na nieco lepszą modelu pamięci. Szczegółowe informacje znajdziesz w dokumencie „ARM® Architecture Reference Manual ARMv8 for ARMv8-A profile device” (Więcej informacji na ten temat).
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Bariery pamięci jądra systemu Linux
Dokumentacja barier pamięci jądra systemu Linux. Zawiera kilka przydatnych przykładów i grafikę ASCII.
http://www.kernel.org/doc/documentation/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (standardy C++) 14882 (język programowania w C++), sekcja 1.10 i klauzula 29 („Biblioteka działań atomowych”)
Wersja robocza standardu poszczególnych funkcji operacji w C++. Ta wersja jest zbliżony do standardu C++14, który obejmuje niewielkie zmiany w tym obszarze z C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(wprowadzenie: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (standardy C) 9899 (język programowania C), rozdział 7.16 („Atomics <stdatomic.h>”)
Wersja robocza normy ISO/IEC 9899-201x C Szczegóły znajdziesz też w późniejszych raportach o nieudanych zamówieniach.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
Mapowanie C/C++11 na procesory (Uniwersytet Cambridge)
Kolekcja tłumaczeń Jaroslava Sevcika i Petera Sewella atomów języka C++ do różnych zbiorów instrukcji dla typowych procesorów.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Algorytm Dekkera
„Pierwsze znane poprawne rozwiązanie problemu wzajemnego wykluczania w programowaniu równoczesnym”. Artykuł w Wikipedii zawiera pełny algorytm wraz z informacjami o tym, jak trzeba go zaktualizować, aby współpracował z nowoczesnymi kompilatorami optymalizującymi i sprzętem SMP.
https://pl.wikipedia.org/wiki/Algorytm_Dekkera
Komentarze dotyczące ARM i wersji alfa oraz zależności
E-mail na liście adresowej arm-jądro od Catalin Marinas. Zawiera podsumowanie zależności adresów i sterowania.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory
Bardzo długi i szczegółowy artykuł na temat różnych typów pamięci, w szczególności pamięci podręcznych procesora, autorstwa Ulricha Dreppera.
http://www.akkadia.org/drepper/cpumemory.pdf
Przyczyny słabego spójności modelu pamięci ARM
Ten dokument napisali Chong Ishtiaq z firmy ARM, Ltd. Próbuje opisać model pamięci ARM SMP w rygorystyczny, ale przystępny sposób. Zastosowana tutaj definicja „obserwowalności” pochodzi z tej publikacji. Tutaj też jest starsza wersja ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
Książka kucharska JSR-133 dla kompilatorów
Doug Lea napisał to jako dodatek do dokumentacji JSR-133 (Java Memory Model). Zawiera wstępny zestaw wytycznych dotyczących implementacji dla modelu pamięci Java, z którego korzysta wielu twórców kompilacji. są nadal powszechnie cytowane i z dużym prawdopodobieństwem pomogą Ci zrozumieć tę kwestię. Niestety 4 omówione tu rodzaje ogrodzeń nie nadają się do architektur obsługiwanych przez Androida, a mapowania C++11 są teraz lepszym źródłem dokładnych przepisów, nawet w przypadku Javy.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: rygorystyczny i użyteczny model programisty dla wieloprocesorów x86
Dokładny opis modelu pamięci x86. Precyzyjne opisy a model pamięci ARM jest niestety znacznie bardziej skomplikowany.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf