Przygotowanie do SMP na Androida

Platforma Android 3.0 i nowsze wersje są zoptymalizowane pod kątem wieloprocesorowych architektur. 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. Do kilka lat temu wszystkie urządzenia z Androidem miały uni-procesory.

Większość urządzeń z Androidem (jeśli nie wszystkie) zawsze miała kilka procesorów, w przeszłości tylko jeden z nich służył do uruchamiania aplikacji, natomiast inny służył do zarządzania różnymi sprzęt (np. radio). 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ść sprzedawanych obecnie urządzeń z Androidem bazuje na SMP, co nieco komplikuje programistów. 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ć mniej lub bardziej podatny na awarie, gdy działa na różnych architektur procesora, a nawet w różnych implementacjach tego samego i architekturą. 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.

Załóżmy tymczasowo, że mamy bardzo prosty kompilator lub tłumacz który się nie dziwi. Tłumaczy w kodzie źródłowym, aby wczytywać i przechowywać instrukcje dokładnie w odpowiednie zamówienie, po 1 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. Tutaj, wątek 1 przechowuje wartość 3 w lokalizacji A, a następnie wartość 5 w lokalizacji 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 (najpierw uruchomiony wątek 2)
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.

Do tej pory założyliśmy nierealistycznie, że tylko sprzęt, powoduje zmianę kolejności instrukcji. W rzeczywistości kompilator zmienia też kolejność instrukcji, poprawić 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 sprzęcie lub w kompilatorze, jest dozwolony, ponieważ nie wpływa na wykonanie pojedynczego wątku, może to 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 kompilator nie zdecyduje się na zmianę kolejności, nigdy nie zaobserwują 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 jest zwykle łatwy sposób, by uniknąć myślenia o którymś te szczegóły. 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”?

Wyścig danych ma miejsce, gdy dostęp do danych mają jednocześnie co najmniej 2 wątki te same zwykłe dane, a co najmniej 1 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ż kolejność operacji nie jest zmieniana, oba warunki zostaną ocenione jako fałsz, a żadna z nich nie jest aktualizowana. 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. Zarówno język C++, jak i język C++ biblioteki standardowej oraz biblioteki kolekcji Java są napisane, aby umożliwić Ci również rozważanie 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. Należy Ci się to najpopularniejsze narzędzie 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 r. języki C i C++ atomic zmiennych i pól 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.

Zmienne atomic w języku C/C++ lub zmienne Java volatile może służyć do zapobiegania wyścigom 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 wyścigu danych zwykle eliminuje konieczność w których występują problemy z porządkowaniem dostępu do pamięci. Istnieje jednak kilka przypadków która staje się widoczna:
  1. Jeśli w Twoim programie występuje błąd powodujący niezamierzony wyścig danych, przekształcenia kompilatora i sprzętu mogą stać się widoczne, programu mogą 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 dotyczącego dostępu zmianę kolejności modeli pamięci sprzętowej i jej rozumienie. 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 robić w języku C

Przedstawiamy tu kilka przykładów nieprawidłowego kodu oraz proste sposoby i je poprawić. Zanim to zrobimy, omówimy wykorzystanie podstawowego języka funkcji.

C/C++ i parametr „volatile”

Deklaracje volatile w C i C++ to narzędzie specjalne. Uniemożliwiają kompilatorowi zmianę kolejności i usuwanie elementów volatile dostęp. 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 usługi volatile nie można używać do udostępniania danych między wątkami w przenośnym kodzie, nawet w przypadku pojedynczego procesora. C volatile zwykle nie uniemożliwia zmianę ustawień dostępu przez sprzęt. Sama w sobie jest jeszcze mniej przydatna, wielowątkowych środowisk SMP, 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 sprawdza się to w przypadku danych, które pasują w rejestrze maszynowym, pod warunkiem że jest on używany z wyraźnymi ogrodzeniami lub w przypadkach, w których kolejność pamięci nie jest istotna. 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
        ...
    }
}

Chodzi o to, aby przydzielać strukturę, inicjować jej pola i w momencie, w którym „publikujemy” go w zmiennej globalnej. W tym momencie każdy inny wątek może go zobaczyć, ale to nie problem, ponieważ został w pełni zainicjowany, prawda?

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 wyścig danych na trasie 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 we właściwej 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. I to niewielka ilość starannie napisanego kodu Java, który działa poprawnie w obecności wyścigów danych. 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ą się „synchronizować” w ten sam obiekt, jeden z nich czeka na zakończenie pracy drugiego.

Jak wspomnieliśmy powyżej, volatile T w Javie jest analogem Pole 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.

Istnieje jedna istotna różnica w stosunku do atomic w C++: Jeśli napiszemy volatile int x; w Javie wartość x++ jest taka sama jak x = x + 1; jego wykonuje obciążenie atomowe, zwiększa wynik, a następnie przeprowadza sklepu. 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 deklarować parametr get() jako zsynchronizowano, można było zadeklarować mValue z wartością „volatile”. (Uwaga incr() musi nadal używać synchronize od mValue++ w innym przypadku nie jest pojedynczą 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. (Zobacz również: AtomicInteger, aby dowiedzieć się, jak i usunąć zsynchronizowany blok).

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 tylko odwołanie do sGoodies jest zmienną. 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.

Popularniejszym idiomem w programowaniu w języku Java jest słynne „podwójne sprawdzanie”, zamykam”:

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, że chcemy mieć tylko jedno wystąpienie Helper obiekt powiązany z instancją 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. Jest taka możliwość ustawiono równolegle z funkcją helper == null w innym wątku.

Aby sprawdzić, w jaki sposób może to spowodować błąd, ten sam kod lekko przepisany, jakby został skompilowany do języka C (Dodano kilka pól z liczbami całkowitymi reprezentujące wartość Helper’s działanie konstruktora):

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 mógł znaleźć helper ma wartość inną niż null, ale jej pola nie są jeszcze ustawione i gotowe do użycia. Więcej informacji i trybów awarii znajdziesz w artykule link „Blokada jest złamana” w dodatku do szczegółowych informacji lub w artykule 71 („Używaj ostrożnie leniwego inicjowania”) w artykule „Effective Java” Josha Blocha. Wydanie drugiej.

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ć . Na przykład należy pamiętać, że zmienna warunku „oczekuje” może przypadkowo nie będzie sygnalizowany i powinna pojawić się w pętli.

Należy unikać bezpośredniego używania funkcji atomowych, chyba że struktura danych jest niezwykle prosty, podobnie jak licznik zdarzeń. Zamykam odblokowanie muteksu pthreadx wymaga pojedynczej operacji niepodzielnej, a często są one niższe niż w przypadku więc nie zaoszczędzisz dużo, zastępując połączenia mutex w operacjach atomowych. 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 stały, pamiętaj, że przekazanie go innemu bez żadnej synchronizacji to wyścig 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 języku C++ przekazywanie wskaźnika lub do stałego obiektu bez odpowiedniej synchronizacji, jak każdy wyścig danych, to błąd. 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 nie ma ani istniejącej, ani stałej klasy instrukcja Java synchronized lub C++ Do zabezpieczenia należy używać lock_guard / unique_lock dostęp do wszystkich pól, do których dostęp ma 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 będą pozwalają uniknąć typowych błędów programistycznych, ale pomogą uniknąć tajemniczych błędów związanych z optymalizacją kompilatorów i platformy SMP wpadek.

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 złagodzenia sekwencji gwarantuje spójność w programach bez wyścigów 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 lazySet w Java.util.concurrent.atomic są podobne do sklepów C++ w języku: memory_order_release. 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 atomu 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 języka C++.

Niewyścigi

Często zdarza się, że zmienna jest niepodzielna, ponieważ czasami odczyt równocześnie z zapisem, jednak ten problem nie występuje we wszystkich rodzajach dostępu. Przykład: zmienna muszą być atomowe, ponieważ są odczytywane poza sekcją krytyczną, ale wszystkie aktualizacje są chronione blokadą. W takim przypadku odczyt, który zdaje się być chroniony tym samym zamkiem nie mogą ścigać się, ponieważ nie mogą być równocześnie zapisywane. W takim przypadku parametr dostęp inny niż wyścigowy (w tym przypadku ładowanie), może być oznaczony 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 wyścigowego tylko do wygenerowania podpowiedzi, zwykle jest to dozwolone. nie wymuszać żadnej kolejności pamięci podczas ładowania. 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. Wynikowa wartość jest odczytywana tylko na końcu, po zakończeniu wszystkich aktualizacji.

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 tak powszechny, więc warto zwrócić uwagę na ten przypadek:

  • 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. (Podobnie jak w przypadku podobnego przypadku, C++ gwarantuje, że drugie wczytanie takiego licznika nie będzie zwraca wartość mniejszą niż wcześniejsze wczytanie 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. Obciążenie operacje 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. Bardziej czytelnym rozwiązaniem może być:

    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 Naprawdę najkorzystniejsza optymalizacja w tym miejscu może być wprowadzeniem myHelper w celu wyeliminowania ale w przyszłości może to zrobić się automatycznie.

Zamawianie przez nabycie/wydanie nie zapobiega widocznemu wyświetlaniu się sklepów opóźniony i nie zapewnia, że sklepy są widoczne dla innych wątków w ustalonej kolejności. Nie obsługuje więc ale dość powszechny wzorzec kodowania, czego przykładem jest wzajemne wykluczenie Dekkera algorytm: wszystkie wątki najpierw ustawiają flagę wskazującą, że chcą to zrobić. coś, jeśli wątek T wykryje, że żaden inny wątek nie jest może bezpiecznie kontynuować, wiedząc, że nie spowoduje żadnych zakłóceń. Żaden inny wątek nie będzie może kontynuować, bo flaga t jest nadal ustawiona. Nie udało się jeśli dostęp do flagi jest uzyskiwany przy użyciu kolejności pobierania/zwalania, ponieważ nie jest to zapobiegać wyświetlaniu flagi wątku po upływie pewnego czasu postępował omyłkowo. Domyślna wartość memory_order_seq_cst temu zapobiega.

Pola stałe

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 blokad:

  • 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 C++ „Przeczytaj raz” to po prostu dobra praktyka.
  • 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 końcowe pola Java że jeśli czytelnik zobaczy odniesienie, zobaczy też zainicjowanych pól końcowych. 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 Java i C++ są wyrażone w postaci relację happens-before, która określa, kiedy gwarantowane są 2 działania. 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 mamy do czynienia z tym, że żaden z tych zdarzeń nie występuje wcześniej. 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).
  • Zapoznaj się z dostępnymi interfejsami API java.util.concurrent i java.util.concurrent.atomic. Rozważ użycie adnotacje równoczesności, takie jak @ThreadSafe i @GuardedBy (z net.jcip.annotations).

Sekcja Więcej informacji w dodatku zawiera linki do dokumenty i strony internetowe, które zawierają więcej informacji.

Dodatek

Wdrażanie 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, i sprzętu obsługiwanego przez Android, zwykłe wczytywanie i instrukcje przechowywania gwarantują, będzie widoczna w całości lub nie będzie widoczna dla innego wczytujący tę samą lokalizację. W związku z tym pewne podstawowe pojęcie „atomowość” jest dostępny bezpłatnie.

Jak widzieliśmy, to za mało. 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 czym wspomnieliśmy wcześniej. flag1 i flag2 to język C++ atomic lub zmiennych volatile w Javie, 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 nabywców zgody 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)

Rzeczywiste architektury maszyn zapewniają zwykle wiele typów które porządkują różne rodzaje dostępu różne koszty. 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 w architekturze x86 tylko ostatnie ogrodzenie (3) i generatywnej AI. 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
Threads – podstawy
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 roku i szczegółowo obejmuje szeroką gamę tematów. Zdecydowanie zalecany dla każdego, kto pisze wielowątkowy kod w Javie.
http://www.javaconcurrencyinpractice.com
Najczęstsze pytania dotyczące JSR-133 (Java Memory Model)
Krótkie wprowadzenie do modelu pamięci Java, w tym wyjaśnienie synchronizacji, zmiennych zmiennych i tworzenia pól końcowych. (Raczej przestarzały, szczególnie w przypadku omawiania 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
Deklaracja „Dwukrotnie sprawdzone blokowanie jest uszkodzone”
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. Opisuje też instrukcje dotyczące barier pamięci w kodzie wykonywalnym (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 przydatne podsumowanie informacji o adresach i zależnościach kontroli.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Co każdy programista powinien wiedzieć o pamięci
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 zapewnią pewną wiedzę. Niestety, omówione tutaj 4 odmiany płotu nie są dobre. dla architektur obsługiwanych przez Androida oraz powyższe mapowania na C++11 są teraz lepszym źródłem dokładnych przepisów, nawet w przypadku języka Java.
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