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 |
reg0 = B |
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
lubpthread_mutex_t
) lub można zastosować blokisynchronized
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
lubunique_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 zmiennevolatile
lubatomic
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 = ...
|
while (!flag) {}
|
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:- 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 niezainicjowanoA
. Kompilator może też uznać, że flaga nie może zmienić w trakcie pętli Thread 2 i przekształcić program naWątek 1 Wątek 2 A = ...
flag = truereg0 = flaga; podczas gdy (!reg0) {}
... = Aflag
jest prawdziwe. - 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 Pakietjava.util.concurrent.atomic
zapewnia bardziej ograniczone możliwości zestaw podobnych obiektów, w szczególnościlazySet()
. 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. - 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 zamiastatomic
, 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:
reg = mValue
reg = reg + 1
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:
- Zrób to proste i usuń zewnętrzny przycisk kontroli. Dzięki temu nigdy
sprawdzanie wartości
helper
poza zsynchronizowanym blokiem. - 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 dwamemory_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<mutex> 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
anidouble
. W przypadku języka C++ wymagane jest przypisanie atomowe; ale jego konstrukcja nie zadziała, konstrukcja elementuatomic
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
ijava.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 |
flag2 = true |
Spójność sekwencyjna oznacza, że jedno z przypisanych
Metoda flag
n 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 "release" (2) |
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
iatomic
. 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