W C ++ 11 normalnie nigdy nie używaj volatile
do tworzenia wątków, tylko dla MMIO
Ale TL: DR, „działa” trochę jak atomic mo_relaxed
na sprzęcie ze spójnymi pamięciami podręcznymi (tj. Ze wszystkim); wystarczy zatrzymać kompilatory przechowujące zmienne w rejestrach. atomic
nie potrzebuje barier pamięciowych do tworzenia atomowości lub widoczności między wątkami, tylko po to, aby bieżący wątek czekał przed / po operacji, aby utworzyć porządek między dostępami tego wątku do różnych zmiennych. mo_relaxed
nigdy nie potrzebuje żadnych barier, wystarczy załadować, przechowywać lub RMW.
Dla atomów typu roll-your-own z volatile
(i inline-asm dla barier) w starych, złych czasach przed C ++ 11 std::atomic
, volatile
był to jedyny dobry sposób, aby niektóre rzeczy działały . Ale zależało to od wielu założeń dotyczących działania wdrożeń i nigdy nie było gwarantowane przez żaden standard.
Na przykład jądro Linuksa nadal używa własnych, ręcznie rozwijanych atomów z rozszerzeniem volatile
, ale obsługuje tylko kilka specyficznych implementacji C (GNU C, clang i być może ICC). Częściowo wynika to z rozszerzeń GNU C oraz składni i semantyki wbudowanego asm, ale także dlatego, że zależy to od pewnych założeń dotyczących działania kompilatorów.
Prawie zawsze jest to zły wybór w przypadku nowych projektów; możesz użyć std::atomic
(z std::memory_order_relaxed
), aby kompilator wyemitował ten sam wydajny kod maszynowy, z którym możesz volatile
. std::atomic
z mo_relaxed
przestarzałymi volatile
do celów gwintowania. (z wyjątkiem być może obejścia błędów po brakującej optymalizacji atomic<double>
w niektórych kompilatorach ).
Wewnętrzna implementacja std::atomic
głównych kompilatorów (takich jak gcc i clang) nie jest wykorzystywana tylko volatile
wewnętrznie; kompilatory bezpośrednio udostępniają funkcje atomowe load, store i RMW. (np. wbudowane GNU C,__atomic
które działają na „zwykłych” obiektach).
Lotny jest użyteczny w praktyce (ale nie rób tego)
To powiedziawszy, volatile
jest użyteczne w praktyce do takich rzeczy, jak exit_now
flaga na wszystkich (?) Istniejących implementacjach C ++ na rzeczywistych procesorach, ze względu na sposób działania procesorów (spójne pamięci podręczne) i wspólne założenia dotyczące tego, jak volatile
powinno działać. Ale niewiele więcej i nie jest zalecane. Celem tej odpowiedzi jest wyjaśnienie, jak faktycznie działają istniejące procesory i implementacje C ++. Jeśli Cię to nie obchodzi, wszystko, co musisz wiedzieć, to to, że std::atomic
z mo_relaxed przestarzałymi wątkami volatile
.
(Norma ISO C ++ jest dość niejasna, mówiąc tylko, że volatile
dostęp powinien być oceniany ściśle według zasad abstrakcyjnej maszyny C ++, a nie zoptymalizowany. Biorąc pod uwagę, że rzeczywiste implementacje używają przestrzeni adresowej pamięci maszyny do modelowania przestrzeni adresowej C ++, oznacza to, że volatile
odczyty i przypisania muszą zostać skompilowane, aby załadować / przechowywać instrukcje, aby uzyskać dostęp do reprezentacji obiektu w pamięci.)
Jak wskazuje inna odpowiedź, exit_now
flaga jest prostym przypadkiem komunikacji między wątkami, która nie wymaga żadnej synchronizacji : nie publikuje, że zawartość tablicy jest gotowa, ani nic w tym stylu. Po prostu sklep, który został natychmiast zauważony przez niezoptymalizowane ładowanie w innym wątku.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Bez zmiennej lub niepodzielnej reguła as-if i założenie braku wyścigu danych UB pozwala kompilatorowi zoptymalizować go do postaci asm, która sprawdza flagę tylko raz , przed wejściem (lub nie) do nieskończonej pętli. To jest dokładnie to, co dzieje się w prawdziwym życiu dla prawdziwych kompilatorów. (I zwykle optymalizuj wiele, do_stuff
ponieważ pętla nigdy nie kończy się, więc każdy późniejszy kod, który mógł użyć wyniku, jest nieosiągalny, jeśli wejdziemy do pętli).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
Program wielowątkowy, który utknął w trybie zoptymalizowanym, ale działa normalnie z -O0, jest przykładem (z opisem wyjścia asm GCC), jak dokładnie to się dzieje z GCC na x86-64. Również programowanie MCU - optymalizacja C ++ O2 przerywa pętlę na elektronice. E pokazuje inny przykład.
Zwykle chcemy agresywnych optymalizacji, które CSE i wyciągi ładują z pętli, w tym dla zmiennych globalnych.
Przed C ++ 11 volatile bool exit_now
był jeden ze sposobów, aby to działało zgodnie z przeznaczeniem (w normalnych implementacjach C ++). Ale w C ++ 11, Data-race UB nadal ma zastosowanie, volatile
więc standard ISO nie gwarantuje , że będzie działać wszędzie, nawet przy założeniu spójnych pamięci podręcznych.
Należy pamiętać, że w przypadku szerszych typów volatile
nie daje gwarancji braku łzawienia. Zignorowałem to rozróżnienie, bool
ponieważ nie jest to problem w przypadku normalnych implementacji. Ale to również część tego, dlaczego volatile
nadal podlega UB wyścigu danych, zamiast być równoważnym zrelaksowanym atomem.
Zauważ, że „zgodnie z przeznaczeniem” nie oznacza, że wątek exit_now
oczekuje na wyjście innego wątku. Lub nawet to, że czeka, aż exit_now=true
magazyn ulotny będzie widoczny globalnie, zanim przejdzie do późniejszych operacji w tym wątku. ( atomic<bool>
z domyślnym ustawieniem mo_seq_cst
będzie czekał przynajmniej przed późniejszym załadowaniem seq_cst. W wielu ISA po prostu otrzymujesz pełną barierę po sklepie).
C ++ 11 zapewnia sposób inny niż UB, który kompiluje to samo
Flaga „kontynuuj działanie” lub „zakończ teraz” powinna być używana std::atomic<bool> flag
zmo_relaxed
Za pomocą
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
da ci dokładnie to samo asm (bez drogich instrukcji dotyczących barier), które dostałeś volatile flag
.
Oprócz braku rozrywania, atomic
daje również możliwość przechowywania w jednym wątku i ładowania w innym bez UB, więc kompilator nie może wyciągnąć obciążenia z pętli. (Założenie o braku wyścigu danych UB jest tym, co pozwala na agresywne optymalizacje, których oczekujemy dla nieatomowych nieulotnych obiektów.) Ta cechaatomic<T>
jest prawie taka sama, jak w volatile
przypadku czystych ładunków i czystych sklepów.
atomic<T>
również zrobić +=
i tak dalej w atomowe operacje RMW (znacznie droższe niż atomowe ładowanie do tymczasowego, operacyjnego, a następnie oddzielnego atomowego magazynu. Jeśli nie chcesz atomowego RMW, napisz swój kod z lokalnym tymczasowym).
Z domyślnym seq_cst
zamówieniem, z którego otrzymałeśwhile(!flag)
, dodaje również gwarancje zamówienia wrt. dostępów nieatomowych i innych dostępów atomowych.
(Teoretycznie, standard ISO C ++ nie wyklucza optymalizacji atomiki w czasie kompilacji. Jednak w praktyce kompilatory tego nie robią, ponieważ nie ma możliwości kontrolowania, kiedy to nie jest w porządku. Jest kilka przypadków, w których nawet volatile atomic<T>
może nie być mieć wystarczającą kontrolę nad optymalizacją atomiki, jeśli kompilatory dokonały optymalizacji, więc na razie kompilatory tego nie robią. Zobacz Dlaczego kompilatory nie łączą redundantnych zapisów std :: atomic? Zauważ, że wg21 / p0062 odradza używanie volatile atomic
w bieżącym kodzie w celu ochrony przed optymalizacją atomics.)
volatile
faktycznie działa w tym przypadku na prawdziwych procesorach (ale nadal go nie używa)
nawet ze słabo uporządkowanymi modelami pamięci (innymi niż x86) . Ale nie używaj go, zamiast tego używaj atomic<T>
z mo_relaxed
!! Celem tej sekcji jest odniesienie się do błędnych przekonań na temat działania rzeczywistych procesorów, a nie uzasadnienie volatile
. Jeśli piszesz kod bez zamka, prawdopodobnie zależy Ci na wydajności. Zrozumienie pamięci podręcznych i kosztów komunikacji między wątkami jest zwykle ważne dla dobrej wydajności.
Prawdziwe procesory mają spójne pamięci podręczne / pamięć współdzieloną: po tym, jak magazyn z jednego rdzenia stanie się globalnie widoczny, żaden inny rdzeń nie może załadować nieaktualnej wartości. (Zobacz także Mity programistów wierzą w pamięć podręczną procesora, która mówi trochę o ulotnych składnikach Java, odpowiednik C ++ atomic<T>
z kolejnością pamięci seq_cst).
Kiedy mówię load , mam na myśli instrukcję asm, która ma dostęp do pamięci. To właśnie volatile
zapewnia dostęp i nie jest tym samym, co konwersja l-wartości do wartości r wartości nieatomowej / nieulotnej zmiennej C ++. (np. local_tmp = flag
lub while(!flag)
).
Jedyną rzeczą, którą musisz pokonać, są optymalizacje w czasie kompilacji, które nie ładują się w ogóle po pierwszym sprawdzeniu. Każde obciążenie + sprawdzenie każdej iteracji jest wystarczające, bez żadnego zamówienia. Bez synchronizacji między tym wątkiem a głównym wątkiem nie ma sensu rozmawiać o tym, kiedy dokładnie nastąpił sklep, ani o kolejności ładowania wrt. inne operacje w pętli. Tylko wtedy, gdy jest to widoczne dla tego wątku, liczy się. Kiedy widzisz ustawioną flagę exit_now, kończysz pracę. Opóźnienie między rdzeniami w typowym Xeonie x86 może wynosić około 40 ns między oddzielnymi rdzeniami fizycznymi .
W teorii: wątki C ++ na sprzęcie bez spójnych pamięci podręcznych
Nie widzę żadnego sposobu, w jaki mogłoby to być zdalnie wydajne, z czystym ISO C ++ bez wymagania od programisty wykonywania jawnych opróżnień w kodzie źródłowym.
Teoretycznie możesz mieć implementację C ++ na maszynie, która nie jest taka, jak ta, wymagająca jawnych opróżnień generowanych przez kompilator, aby rzeczy były widoczne dla innych wątków na innych rdzeniach . (Lub do odczytu, aby nie używać być może nieaktualnej kopii). Standard C ++ nie uniemożliwia tego, ale model pamięci C ++ jest zaprojektowany tak, aby był wydajny na spójnych maszynach z pamięcią współdzieloną. Np. Standard C ++ mówi nawet o „spójności odczytu i odczytu”, „spójności zapisu i odczytu” itp. Jedna uwaga w standardzie wskazuje nawet na połączenie ze sprzętem:
http://eel.is/c++draft/intro.races#19
[Uwaga: Cztery poprzednie wymagania dotyczące spójności skutecznie uniemożliwiają kompilatorowi zmianę kolejności operacji atomowych na pojedynczy obiekt, nawet jeśli obie operacje są obciążeniami zrelaksowanymi. To skutecznie zapewnia spójność pamięci podręcznej zapewnianą przez większość sprzętu dostępnego dla atomowych operacji C ++. - notatka końcowa]
Nie ma mechanizmu, release
który pozwalałby sklepowi na opróżnianie samego siebie i kilku wybranych zakresów adresów: musiałby zsynchronizować wszystko, ponieważ nie wiedziałby, co inne wątki mogłyby chcieć przeczytać, gdyby ich pobieranie-ładowanie zobaczyło ten magazyn wydania (tworząc sekwencja wydania, która ustanawia relację wydarzyło się przed między wątkami, gwarantując, że wcześniejsze operacje nieatomowe wykonywane przez wątek piszący są teraz bezpieczne do odczytu. Chyba że dokonał dalszego zapisu do nich po magazynie wydania ...) Lub kompilatory być naprawdę sprytnym, aby udowodnić, że tylko kilka linii pamięci podręcznej wymaga opróżnienia.
Powiązane: moja odpowiedź na temat Czy mov + mfence jest bezpieczne w NUMA? szczegółowo omawia nieistnienie systemów x86 bez spójnej pamięci współdzielonej. Również powiązane: Ładunki i sklepy zmieniające kolejność w ARM, aby uzyskać więcej informacji o ładunkach / sklepach do tej samej lokalizacji.
Jest to myślę, że klastry z niekoherentnego wspólna pamięć, ale nie są maszyny single-System-image. Każda domena spójności obsługuje oddzielne jądro, więc nie można w niej uruchamiać wątków pojedynczego programu C ++. Zamiast tego uruchamiasz oddzielne instancje programu (każda z własną przestrzenią adresową: wskaźniki w jednej instancji nie są prawidłowe w drugiej).
Aby zmusić ich do komunikowania się ze sobą za pomocą jawnych opróżnień, zwykle używałbyś MPI lub innego interfejsu API do przekazywania komunikatów, aby program określał, które zakresy adresów wymagają opróżnienia.
Prawdziwy sprzęt nie std::thread
przekracza granic spójności pamięci podręcznej:
Istnieją pewne asymetryczne układy ARM ze wspólną fizyczną przestrzenią adresową, ale nie z wewnętrznymi współdzielonymi domenami pamięci podręcznej. Więc nie spójne. (np. komentarz wątek rdzenia A8 i Cortex-M3 jak TI Sitara AM335x).
Ale różne jądra działałyby na tych rdzeniach, a nie pojedynczy obraz systemu, który mógłby uruchamiać wątki na obu rdzeniach. Nie znam żadnych implementacji C ++, które uruchamiają std::thread
wątki na rdzeniach procesora bez spójnych pamięci podręcznych.
W szczególności w przypadku ARM, GCC i clang generują kod przy założeniu, że wszystkie wątki działają w tej samej domenie z możliwością wewnętrznego współużytkowania. W rzeczywistości, podręcznik ARMv7 ISA mówi
Ta architektura (ARMv7) została napisana z założeniem, że wszystkie procesory korzystające z tego samego systemu operacyjnego lub hiperwizora znajdują się w tej samej domenie wewnętrznego udostępniania
Tak więc niespójna pamięć współdzielona między oddzielnymi domenami jest tylko rzeczą do jawnego, specyficznego dla systemu wykorzystania obszarów pamięci współdzielonej do komunikacji między różnymi procesami w różnych jądrach.
Zobacz także tę dyskusję CoreCLR na temat używania kodu generującegodmb ish
(Inner Shareable Bariera) vs. dmb sy
(System) barier pamięci w tym kompilatorze.
Stwierdzam, że żadna implementacja C ++ dla żadnego innego ISA nie działa std::thread
na rdzeniach z niespójnymi pamięciami podręcznymi. Nie mam dowodu, że taka implementacja nie istnieje, ale wydaje się to wysoce nieprawdopodobne. Jeśli nie celujesz w konkretny egzotyczny element sprzętu, który działa w ten sposób, twoje myślenie o wydajności powinno zakładać spójność pamięci podręcznej między wszystkimi wątkami podobną do MESI. (Najlepiej jednak używać atomic<T>
w sposób gwarantujący poprawność!)
Spójne pamięci podręczne sprawiają, że jest to proste
Ale w systemie wielordzeniowym ze spójnymi pamięciami podręcznymi zaimplementowanie magazynu wydań oznacza po prostu zlecenie zatwierdzenia do pamięci podręcznej dla sklepów tego wątku, bez wykonywania żadnego jawnego opróżniania. ( https://preshing.com/20120913/acquire-and-release-semantics/ i https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (A pobieranie oznacza zamawianie dostępu do pamięci podręcznej w drugim rdzeniu).
Instrukcja bariery pamięci po prostu blokuje ładowanie i / lub przechowywanie bieżącego wątku do momentu opróżnienia bufora magazynu; to zawsze dzieje się samoistnie tak szybko, jak to możliwe. ( Czy bariera pamięci zapewnia, że spójność pamięci podręcznej została zakończona? Rozwiązuje to błędne przekonanie). Więc jeśli nie potrzebujesz zamawiać, po prostu szybka widoczność w innych wątkach, mo_relaxed
jest w porządku. (I tak jest volatile
, ale nie rób tego.)
Zobacz także mapowania C / C ++ 11 do procesorów
Ciekawostka: na x86 każdy magazyn asm jest magazynem wydania, ponieważ model pamięci x86 to w zasadzie seq-cst plus bufor magazynu (z przekazywaniem magazynu).
Częściowo powiązane: bufor sklepu, globalna widoczność i spójność: C ++ 11 gwarantuje bardzo niewiele. Większość prawdziwych ISA (z wyjątkiem PowerPC) gwarantuje, że wszystkie wątki mogą uzgodnić kolejność pojawiania się dwóch sklepów przez dwa inne wątki. (W formalnej terminologii związanej z modelami pamięci architektury komputerowej są one „atomami wielu kopii”).
Innym błędnym przekonaniem jest to, że instrukcje asm ogrodzenia pamięci są potrzebne do opróżnienia bufora magazynu, aby inne rdzenie mogły w ogóle zobaczyć nasze sklepy . W rzeczywistości bufor magazynu zawsze próbuje opróżnić się (zatwierdzić do pamięci podręcznej L1d) tak szybko, jak to możliwe, w przeciwnym razie zapełniłby się i wstrzymał wykonanie. To, co robi pełna bariera / ogrodzenie, zatrzymuje bieżący wątek do opróżnienia bufora sklepu , więc nasze późniejsze obciążenia pojawiają się w porządku globalnym po naszych wcześniejszych sklepach.
(Silnie uporządkowany model pamięci asm volatile
x86 oznacza, że na x86 może skończyć się dając ci bliżej mo_acq_rel
, z wyjątkiem tego, że zmiana kolejności w czasie kompilacji ze zmiennymi nieatomowymi może nadal mieć miejsce. Ale większość modeli innych niż x86 ma słabo uporządkowane modele pamięci, więc volatile
i relaxed
jest mniej więcej tak samo słaby, jak na to mo_relaxed
pozwala.)