C++
Model pamięci C ++ 11
Szukaj…
Uwagi
Różne wątki próbujące uzyskać dostęp do tej samej lokalizacji pamięci uczestniczą w wyścigu danych, jeśli przynajmniej jedna z operacji jest modyfikacją (znaną również jako operacja przechowywania ). Te wyścigi danych powodują niezdefiniowane zachowanie . Aby ich uniknąć, należy uniemożliwić tym wątkom jednoczesne wykonywanie takich sprzecznych operacji.
Operacje podstawowe synchronizacji (muteks, sekcja krytyczna i tym podobne) mogą chronić takie dostępy. Model pamięci wprowadzony w C ++ 11 definiuje dwa nowe przenośne sposoby synchronizacji dostępu do pamięci w środowisku wielowątkowym: operacje atomowe i ogrodzenia.
Operacje atomowe
Można teraz odczytywać i zapisywać w danych lokalizacjach pamięci za pomocą operacji ładowania atomowego i przechowywania atomowego . Dla wygody są one zawarte w klasie szablonów std::atomic<t>
. Ta klasa otacza wartość typu t
ale tym razem ładuje i przechowuje w obiekcie atomowy.
Szablon nie jest dostępny dla wszystkich typów. Dostępne typy zależą od implementacji, ale zazwyczaj obejmują większość (lub wszystkie) dostępne typy całek, a także typy wskaźników. Aby std::atomic<unsigned>
i std::atomic<std::vector<foo> *>
były dostępne, podczas gdy std::atomic<std::pair<bool,char>>
najprawdopodobniej nie będzie.
Operacje atomowe mają następujące właściwości:
- Wszystkie operacje atomowe mogą być wykonywane jednocześnie z wielu wątków bez powodowania niezdefiniowanego zachowania.
- Ładunek atomowy zobaczy albo wartość początkową, z jaką został zbudowany obiekt atomowy, albo wartość zapisaną do niego za pomocą operacji składowania atomowego .
- Magazyny atomowe dla tego samego obiektu atomowego są uporządkowane tak samo we wszystkich wątkach. Jeśli wątek już widział wartość niektórych operacji składowania atomowego , kolejne operacje ładowania atomowego zobaczą tę samą wartość lub wartość przechowywaną podczas kolejnej operacji magazynowania atomowego .
- Atomowe operacje odczytu-modyfikacji-zapisu pozwalają na ładowanie atomowe i składowanie atomowe bez innych magazynów atomowych pomiędzy nimi. Na przykład można atomowo zwiększyć licznik z wielu wątków i żaden przyrost nie zostanie utracony niezależnie od rywalizacji między wątkami.
- Operacje atomowe otrzymują opcjonalny parametr
std::memory_order
, który określa, jakie dodatkowe właściwości ma operacja w odniesieniu do innych lokalizacji pamięci.
std :: memory_order | Znaczenie |
---|---|
std::memory_order_relaxed | bez dodatkowych ograniczeń |
std::memory_order_release → std::memory_order_acquire | jeśli load-acquire widzi wartość przechowywaną przez store-release to przechowuje sekwencyjnie, zanim nastąpi store-release , zanim ładunki zostaną zsekwencjonowane po uzyskaniu obciążenia |
std::memory_order_consume | jak memory_order_acquire ale tylko dla obciążeń zależnych |
std::memory_order_acq_rel | łączy w sobie funkcje load-acquire i store-release |
std::memory_order_seq_cst | sekwencyjna spójność |
Te znaczniki kolejności pamięci pozwalają na trzy różne dyscypliny porządkowania pamięci: spójność sekwencyjna , rozluźnienie i uwolnienie-uwolnienie dzięki rodzeństwu -zużycie-zużycie .
Spójność sekwencyjna
Jeśli nie określono kolejności pamięci dla operacji atomowej, domyślnie kolejność jest zgodna z sekwencją . Ten tryb można również jawnie wybrać, std::memory_order_seq_cst
operację za pomocą std::memory_order_seq_cst
.
W tym porządku żadna operacja pamięci nie może przekroczyć operacji atomowej. Wszystkie operacje pamięci sekwencjonowane przed operacją atomową mają miejsce przed operacją atomową, a operacja atomowa ma miejsce przed wszystkimi operacjami pamięci, które są zsekwencjonowane po niej. Ten tryb jest prawdopodobnie najłatwiejszym do uzasadnienia, ale prowadzi również do największej kary za wydajność. Zapobiega to również wszelkim optymalizacjom kompilatora, które w innym przypadku mogłyby próbować zmienić kolejność operacji po operacji atomowej.
Zrelaksowane zamawianie
Przeciwieństwem sekwencyjnej spójności jest zrelaksowany zamawiania pamięci. Jest on wybierany za pomocą znacznika std::memory_order_relaxed
. Zrelaksowana operacja atomowa nie nakłada żadnych ograniczeń na inne operacje pamięci. Jedynym efektem, jaki pozostaje, jest to, że sama operacja jest nadal atomowa.
Zamawianie z przejęciem wydania
Operację magazynu atomowego można oznaczyć za pomocą std::memory_order_release
a operację ładowania atomowego można oznaczyć za pomocą std::memory_order_acquire
. Pierwsza operacja nazywa się (atomowa) zwalnianiem magazynu, a druga nazywana jest (atomowym) ładowaniem .
Kiedy load-acquire zobaczy wartość zapisaną przez wydanie wydania, następuje: wszystkie operacje przechowywania zsekwencjonowane przed wydaniem wydania stają się widoczne dla (wykonywanych przed ) operacji ładowania, które są zsekwencjonowane po przejęciu obciążenia .
Atomowe operacje odczytu-modyfikacji-zapisu mogą również odbierać znacznik skumulowany std::memory_order_acq_rel
. To sprawia, że atomowej Portion LOAD operacji atomowej obciążenia-rejestracja gdy porcja sklep atomowy się atomowej sklepu uwalnianiu.
Kompilator nie może przenosić operacji magazynu po operacji atomowej zwalniania magazynu . Niedozwolone jest również przenoszenie operacji obciążenia przed ładowaniem atomowym (lub ładowaniem-zużyciem ).
Zauważ również, że nie ma atomowego zwolnienia obciążenia ani atomowego zapisu do magazynu . Próba utworzenia takich operacji powoduje, że są one zrelaksowane .
Zamawianie w wersji konsumenckiej
Ta kombinacja jest podobna do wydania-pozyskiwania , ale tym razem ładunek atomowy jest oznaczony jako std::memory_order_consume
i staje się std::memory_order_consume
(atomową) ładowaniem- std::memory_order_consume
. Ten tryb jest taki sam, jak w przypadku zwolnienia-pobierania, z tą różnicą, że wśród operacji ładowania sekwencyjnych po obciążeniu-obciążeniu zlecane są tylko te w zależności od wartości ładowanej przez obciążenie-zużycie .
Ogrodzenia
Ogrodzenia umożliwiają także porządkowanie operacji pamięci między wątkami. Ogrodzenie jest ogrodzeniem zwalniającym lub nabywa ogrodzenie.
Jeśli ogrodzenie zwalniające nastąpi przed ogrodzeniem przejmującym, wówczas sklepy zsekwencjonowane przed ogrodzeniem zwalniającym są widoczne dla obciążeń zsekwencjonowanych po ogrodzeniu przejmującym. Aby zagwarantować, że ogrodzenie zwalniające nastąpi przed uzyskaniem ogrodzenia, można zastosować inne operacje podstawowe synchronizacji, w tym zrelaksowane operacje atomowe.
Need for Memory Model
int x, y;
bool ready = false;
void init()
{
x = 2;
y = 3;
ready = true;
}
void use()
{
if (ready)
std::cout << x + y;
}
Jeden wątek wywołuje funkcję init()
podczas gdy inny wątek (lub moduł obsługi sygnałów) wywołuje funkcję use()
. Można się spodziewać, że funkcja use()
wydrukuje 5
lub nic nie zrobi. Nie zawsze może tak być z kilku powodów:
Procesor może zmienić kolejność zapisów zachodzących w
init()
aby kod, który faktycznie wykonuje, mógł wyglądać następująco:void init() { ready = true; x = 2; y = 3; }
CPU może zmienić kolejność odczytów, które mają miejsce w
use()
tak aby faktycznie wykonany kod mógł stać się:void use() { int local_x = x; int local_y = y; if (ready) std::cout << local_x + local_y; }
Optymalizujący kompilator C ++ może zdecydować o zmianie kolejności programu w podobny sposób.
Taka zmiana kolejności nie może zmienić zachowania programu działającego w jednym wątku, ponieważ wątek nie może przeplatać wywołań funkcji init()
i use()
. Z drugiej strony w ustawieniu wielowątkowym jeden wątek może widzieć część zapisów wykonywanych przez drugi wątek, przy czym może się zdarzyć, że use()
może zobaczyć ready==true
i śmieci w x
lub y
lub w obu.
Model pamięci C ++ pozwala programiście określić, które operacje zmiany kolejności są dozwolone, a które nie, tak aby program wielowątkowy mógł również zachowywać się zgodnie z oczekiwaniami. Powyższy przykład można przepisać w sposób bezpieczny dla wątków:
int x, y;
std::atomic<bool> ready{false};
void init()
{
x = 2;
y = 3;
ready.store(true, std::memory_order_release);
}
void use()
{
if (ready.load(std::memory_order_acquire))
std::cout << x + y;
}
Tutaj init()
wykonuje atomową operację zwalniania magazynu . To nie tylko przechowuje wartość true
w stanie ready
, ale także informuje kompilator, że nie może przenieść tej operacji przed operacjami zapisu, które są przed nią sekwencjonowane .
Funkcja use()
wykonuje operację ładowania atomowego . Odczytuje bieżącą wartość ready
a także zabrania kompilatorowi umieszczania operacji odczytu, które są sekwencjonowane po tym, jak to się stanie, zanim nastąpi ładowanie atomowe .
Te operacje atomowe powodują również, że kompilator wstawia instrukcje sprzętowe potrzebne do poinformowania procesora o niechcianym ponownym uporządkowaniu.
Ponieważ wydanie atomowej pamięci masowej znajduje się w tym samym miejscu pamięci co atomowy ładowanie pamięci, model pamięci stanowi, że jeśli operacja ładowania pamięci zobaczy wartość zapisaną przez operację zwolnienia pamięci , wówczas wszystkie zapisy wykonywane przez init()
' Wątek przed tą wersją magazynu będzie widoczny dla obciążeń, które wykonują wątek use()
po jego pobraniu . To znaczy, jeśli use()
widzi ready==true
, to gwarantuje, że zobaczysz x==2
y==3
.
Zauważ, że kompilator i CPU nadal mogą zapisywać do y
przed zapisaniem do x
, i podobnie odczyty tych zmiennych w use()
mogą zachodzić w dowolnej kolejności.
Przykład ogrodzenia
Powyższy przykład można również zaimplementować za pomocą ogrodzeń i zrelaksowanych operacji atomowych:
int x, y;
std::atomic<bool> ready{false};
void init()
{
x = 2;
y = 3;
atomic_thread_fence(std::memory_order_release);
ready.store(true, std::memory_order_relaxed);
}
void use()
{
if (ready.load(std::memory_order_relaxed))
{
atomic_thread_fence(std::memory_order_acquire);
std::cout << x + y;
}
}
Jeśli operacja ładunek atomowy widzi wartość zapisaną przez sklep atomowej następnie przechowywać dzieje się przed obciążeniem, tak jak i ogrodzenia: ogrodzenie uwolnienie się dzieje przed ogrodzeniem, uzyskały dokonującej zapisy do x
i y
, które poprzedzają ogrodzenie zwalniającą stać się widoczny do instrukcji std::cout
następującej po ogrodzeniu przejmowania.
Ogrodzenie może być korzystne, jeśli może zmniejszyć ogólną liczbę operacji pozyskiwania, zwalniania lub innych operacji synchronizacji. Na przykład:
void block_and_use()
{
while (!ready.load(std::memory_order_relaxed))
;
atomic_thread_fence(std::memory_order_acquire);
std::cout << x + y;
}
Funkcja block_and_use()
obraca się, dopóki flaga ready
zostanie ustawiona za pomocą zrelaksowanego obciążenia atomowego. Następnie stosuje się pojedyncze ogrodzenie pozyskania w celu zapewnienia potrzebnego uporządkowania pamięci.