Kiedy używać lotnych przy wielowątkowości?

130

Jeśli istnieją dwa wątki uzyskujące dostęp do zmiennej globalnej, wiele samouczków mówi, że zmienna jest ulotna, aby zapobiec buforowaniu zmiennej przez kompilator w rejestrze, a tym samym niepoprawnej aktualizacji. Jednak dwa wątki, które uzyskują dostęp do wspólnej zmiennej, są czymś, co wymaga ochrony przez mutex, prawda? Ale w takim przypadku między zablokowaniem wątku a zwolnieniem muteksu kod znajduje się w krytycznej sekcji, w której tylko jeden wątek może uzyskać dostęp do zmiennej, w którym to przypadku zmienna nie musi być niestabilna?

Zatem jakie jest zastosowanie / cel zmienności w programie wielowątkowym?

David Preston
źródło
3
W niektórych przypadkach nie chcesz / potrzebujesz ochrony przez muteks.
Stefan Mai
4
Czasami dobrze jest mieć stan rasy, a czasami nie. Jak używasz tej zmiennej?
David Heffernan
3
@David: Przykład tego, kiedy „dobrze” jest mieć wyścig, proszę?
John Dibling
6
@John Tutaj idzie. Wyobraź sobie, że masz wątek roboczy, który przetwarza wiele zadań. Wątek roboczy zwiększa licznik za każdym razem, gdy kończy zadanie. Wątek główny okresowo odczytuje ten licznik i aktualizuje użytkownika wiadomościami o postępie. Dopóki licznik jest odpowiednio ustawiony, aby uniknąć rozerwania, nie ma potrzeby synchronizacji dostępu. Chociaż jest rasa, jest łagodna.
David Heffernan
5
@John Sprzęt, na którym działa ten kod, gwarantuje, że wyrównane zmienne nie zostaną rozerwane. Jeśli pracownik aktualizuje n do n + 1 w trakcie odczytywania przez czytelnika, czytelnik nie dba o to, czy otrzyma n, czy n + 1. Nie zostaną podjęte żadne ważne decyzje, ponieważ jest on używany tylko do raportowania postępów.
David Heffernan

Odpowiedzi:

167

Krótka i szybka odpowiedź : volatilejest (prawie) bezużyteczna w programowaniu aplikacji wielowątkowych niezależnych od platformy. Nie zapewnia synchronizacji, nie tworzy barier pamięciowych ani nie zapewnia kolejności wykonywania operacji. Nie czyni operacji atomowymi. To nie sprawia, że ​​twój kod jest magicznie bezpieczny dla wątków. volatilemoże być najbardziej niezrozumianą funkcją w całym C ++. Zobacz to , to i to, aby uzyskać więcej informacji na tematvolatile

Z drugiej strony volatilema pewne zastosowanie, które może nie być tak oczywiste. Może być używany w taki sam sposób, w jaki można by constpomóc kompilatorowi pokazać ci, gdzie możesz popełnić błąd, uzyskując dostęp do udostępnionego zasobu w niezabezpieczony sposób. To zastosowanie zostało omówione przez Alexandrescu w tym artykule . Jest to jednak zasadniczo używanie systemu typów C ++ w sposób, który jest często postrzegany jako wymysł i może wywołać niezdefiniowane zachowanie.

volatilebył specjalnie przeznaczony do użycia podczas łączenia się ze sprzętem mapowanym w pamięci, programami obsługi sygnałów i instrukcją kodu maszynowego setjmp. Ma to volatilebezpośrednie zastosowanie do programowania na poziomie systemu, a nie do normalnego programowania na poziomie aplikacji.

Standard C ++ 2003 nie mówi, że volatilestosuje się do zmiennych jakikolwiek rodzaj semantyki Acquire lub Release. W rzeczywistości norma całkowicie milczy we wszystkich kwestiach wielowątkowości. Jednak określone platformy stosują semantykę Acquire and Release na volatilezmiennych.

[Aktualizacja dla C ++ 11]

Standard C ++ 11 obsługuje teraz wielowątkowość bezpośrednio w modelu pamięci i języku, a także zapewnia bibliotekę umożliwiającą radzenie sobie z nią w sposób niezależny od platformy. Jednak semantyka volatilenadal nie uległa zmianie. volatilenadal nie jest mechanizmem synchronizacji. Bjarne Stroustrup mówi to samo w TCPPPL4E:

Nie używaj volatilez wyjątkiem kodu niskiego poziomu, który dotyczy bezpośrednio sprzętu.

Nie zakładaj, że volatilema specjalne znaczenie w modelu pamięci. To nie. Nie jest to - jak w niektórych późniejszych językach - mechanizm synchronizacji. Aby uzyskać synchronizację, użyj atomic, a mutexlub a condition_variable.

[/ Zakończ aktualizację]

Dotyczy to przede wszystkim samego języka C ++, zgodnie z definicją zawartą w standardzie 2003 (a obecnie w standardzie 2011). Jednak niektóre konkretne platformy dodają dodatkowe funkcje lub ograniczenia volatile. Na przykład w MSVC 2010 (przynajmniej) semantyka Acquire and Release ma zastosowanie do niektórych operacji na volatilezmiennych. Z MSDN :

Podczas optymalizacji kompilator musi zachować kolejność między odwołaniami do obiektów ulotnych, jak również odwołaniami do innych obiektów globalnych. W szczególności,

Zapis do obiektu ulotnego (zapis ulotny) ma semantykę wydania; odniesienie do obiektu globalnego lub statycznego, które występuje przed zapisem do obiektu ulotnego w sekwencji instrukcji, nastąpi przed tym zapisem ulotnym w skompilowanym pliku binarnym.

Odczyt obiektu ulotnego (odczyt ulotny) ma semantykę Acquire; odniesienie do globalnego lub statycznego obiektu, który występuje po odczycie ulotnej pamięci w sekwencji instrukcji, nastąpi po tym ulotnym odczycie w skompilowanym pliku binarnym.

Możesz jednak zwrócić uwagę na fakt, że jeśli skorzystasz z powyższego łącza, w komentarzach pojawi się pewna dyskusja, czy semantyka nabywania / zwalniania faktycznie ma zastosowanie w tym przypadku.

John Dibling
źródło
19
Część mnie chce to zlekceważyć z powodu protekcjonalnego tonu odpowiedzi i pierwszego komentarza. „Nietrwałość jest bezużyteczna” jest podobna do „ręcznego przydzielania pamięci jest bezużyteczne”. Jeśli możesz napisać program wielowątkowy bez volatileniego, to dlatego, że stanąłeś na barkach ludzi, którzy volatileimplementowali biblioteki wątków.
Ben Jackson
19
@Ben tylko dlatego, że coś podważa twoje przekonania, nie czyni tego protekcjonalnym
David Heffernan
38
@Ben: nie, przeczytaj, co volatiletak naprawdę robi w C ++. To, co powiedział @John, jest poprawne , koniec historii. Nie ma to nic wspólnego z kodem aplikacji i kodem biblioteki, ani z „zwykłymi” kontra „boskimi wszechwiedzącymi programistami”. volatilejest niepotrzebne i bezużyteczne do synchronizacji między wątkami. Bibliotek wątkowych nie można zaimplementować w kategoriach volatile; i tak musi polegać na szczegółach specyficznych dla platformy, a kiedy na nich polegasz, nie potrzebujesz już volatile.
jalf
6
@jalf: „nietrwałość jest niepotrzebna i bezużyteczna do synchronizacji między wątkami” (co właśnie powiedziałeś) to nie to samo, co „niestabilność jest bezużyteczna w programowaniu wielowątkowym” (tak powiedział John w odpowiedzi). Masz 100% racji, ale ja nie zgadzam się z Johnem (częściowo) - niestabilny może być nadal używany do programowania wielowątkowego (do bardzo ograniczonego zestawu zadań)
4
@GMan: Wszystko, co jest przydatne, jest przydatne tylko w przypadku określonego zestawu wymagań lub warunków. Zmienna jest przydatna w programowaniu wielowątkowym pod ściśle określonym zestawem warunków (aw niektórych przypadkach może być nawet lepsza (dla niektórych definicji lepszych) niż alternatywy). Mówisz „ignorując to tamto i…”, ale przypadek, w którym zmienna zmienna jest przydatna w przypadku wielowątkowości, niczego nie ignoruje. Wymyśliłeś coś, czego nigdy nie twierdziłem. Tak, użyteczność zmiennej volatile jest ograniczona, ale istnieje - ale wszyscy możemy się zgodzić, że NIE jest ona przydatna do synchronizacji.
31

(Uwaga redaktora: w C ++ 11 volatilenie jest odpowiednim narzędziem do tego zadania i nadal ma Data-race UB. Użyj std::atomic<bool>z std::memory_order_relaxedładowaniami / magazynami, aby to zrobić bez UB. W prawdziwych implementacjach będzie kompilować się do tego samego asm co volatile. Dodałem odpowiedź z bardziej szczegółowo, a także rozwiązywania nieporozumień w komentarzach, że słabo uporządkowane pamięć może być problemem dla tego zastosowania literami: wszystkie procesory świata rzeczywistego mieć spójną pamięć współdzieloną tak volatilezadziała za to na prawdziwym C ++ implementacje Ale nadal don. nie rób tego.

Niektóre dyskusja w komentarzach wydaje się mówić o innych przypadkach użytkowych, gdzie będzie trzeba coś mocniejszego niż zrelaksowany atomistyki. Ta odpowiedź już wskazuje, że volatilenie dajesz zamówienia.)


Nietrwałe jest czasami przydatne z następującego powodu: ten kod:

/* global */ bool flag = false;

while (!flag) {}

jest zoptymalizowany przez gcc do:

if (!flag) { while (true) {} }

Co jest oczywiście niepoprawne, jeśli flaga jest zapisywana przez inny wątek. Zauważ, że bez tej optymalizacji mechanizm synchronizacji prawdopodobnie działa (w zależności od innego kodu mogą być potrzebne pewne bariery pamięci) - nie ma potrzeby stosowania muteksu w scenariuszu 1 producent - 1 konsument.

W przeciwnym razie słowo kluczowe volatile jest zbyt dziwne, aby było możliwe do użycia - nie zapewnia żadnego porządkowania pamięci, gwarantującego zarówno dostęp ulotny, jak i nieulotny, i nie zapewnia żadnych niepodzielnych operacji - tj. Nie otrzymujesz pomocy od kompilatora ze słowem kluczowym volatile z wyjątkiem wyłączonego buforowania rejestrów .

zeuxcg
źródło
4
Jeśli dobrze pamiętam, C ++ 0x atomic ma robić właściwie to, co wielu ludzi uważa (niepoprawnie) za sprawą niestabilności.
David Heffernan
13
volatilenie zapobiega zmianie kolejności dostępu do pamięci. volatiledostępy nie będą zmieniane w odniesieniu do siebie nawzajem, ale nie zapewniają żadnej gwarancji zmiany kolejności w odniesieniu do volatileobiektów niebędących obiektami, a zatem są w zasadzie bezużyteczne również jako flagi.
jalf
13
@Ben: Myślę, że masz to do góry nogami. Tłum „niestabilny jest bezużyteczny” opiera się na prostym fakcie, że zmienny nie chroni przed zmianą kolejności , co oznacza, że ​​jest całkowicie bezużyteczny do synchronizacji. Inne podejścia mogą być równie bezużyteczne (jak wspomniałeś, optymalizacja kodu w czasie łączenia może pozwolić kompilatorowi zajrzeć do kodu, który, jak założył, potraktowałby jako czarną skrzynkę), ale to nie naprawia wad volatile.
jalf
15
@jalf: Zobacz artykuł Arch Robinsona (link w innym miejscu na tej stronie), dziesiąty komentarz (autor: „Spud”). Zasadniczo zmiana kolejności nie zmienia logiki kodu. Wysłany kod używa flagi do anulowania zadania (zamiast sygnalizowania, że ​​zadanie zostało wykonane), więc nie ma znaczenia, czy zadanie zostanie anulowane przed czy po kodzie (np .: while (work_left) { do_piece_of_work(); if (cancel) break;}jeśli anulowanie jest zmieniane w pętli, logika jest nadal aktualna. Miałem fragment kodu, który działał podobnie: jeśli główny wątek chce się zakończyć, ustawia flagę dla innych wątków, ale nie ...
15
... ma znaczenie, jeśli inne wątki wykonają kilka dodatkowych iteracji swoich pętli roboczych przed zakończeniem, o ile dzieje się to rozsądnie wkrótce po ustawieniu flagi. Oczywiście jest to JEDYNE zastosowanie, które przychodzi mi do głowy i jest raczej niszowe (i może nie działać na platformach, na których zapis do zmiennej lotnej nie powoduje, że zmiana jest widoczna dla innych wątków, chociaż przynajmniej na x86 i x86-64 to Pracuje). Z pewnością nie radziłbym nikomu tego robić bez bardzo dobrego powodu, po prostu mówię, że ogólne stwierdzenie, takie jak „zmienne nigdy nie jest przydatne w kodzie wielowątkowym” nie jest w 100% poprawne.
15

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_relaxedna sprzęcie ze spójnymi pamięciami podręcznymi (tj. Ze wszystkim); wystarczy zatrzymać kompilatory przechowujące zmienne w rejestrach. atomicnie 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_relaxednigdy 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, volatilebył 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::atomicz mo_relaxedprzestarzałymi volatiledo 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::atomicgłównych kompilatorów (takich jak gcc i clang) nie jest wykorzystywana tylko volatilewewnę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, volatilejest użyteczne w praktyce do takich rzeczy, jak exit_nowflaga 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 volatilepowinno 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::atomicz mo_relaxed przestarzałymi wątkami volatile.

(Norma ISO C ++ jest dość niejasna, mówiąc tylko, że volatiledostę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 volatileodczyty 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_nowflaga 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_stuffponieważ 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_nowbył 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, volatilewię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 volatilenie daje gwarancji braku łzawienia. Zignorowałem to rozróżnienie, boolponieważ nie jest to problem w przypadku normalnych implementacji. Ale to również część tego, dlaczego volatilenadal podlega UB wyścigu danych, zamiast być równoważnym zrelaksowanym atomem.

Zauważ, że „zgodnie z przeznaczeniem” nie oznacza, że ​​wątek exit_nowoczekuje na wyjście innego wątku. Lub nawet to, że czeka, aż exit_now=truemagazyn ulotny będzie widoczny globalnie, zanim przejdzie do późniejszych operacji w tym wątku. ( atomic<bool>z domyślnym ustawieniem mo_seq_cstbę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> flagzmo_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, atomicdaje 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 volatileprzypadku 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_cstzamó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 atomicw 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 volatilezapewnia 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 = flaglub 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, releasektó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::threadprzekracza 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::threadwą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 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::threadna 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_relaxedjest 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 volatilex86 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 volatilei relaxedjest mniej więcej tak samo słaby, jak na to mo_relaxedpozwala.)

Peter Cordes
źródło
Komentarze nie służą do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Samuel Liew
2
Świetny opis. To jest dokładnie to, czego szukałem (podając wszystkie fakty) zamiast ogólnego stwierdzenia, które po prostu mówi „użyj atomowej zamiast lotnej dla pojedynczej globalnej współdzielonej flagi boolowskiej”.
bernie
2
@bernie: Napisałem to po frustracji powtarzającymi się twierdzeniami, że nieużywanie atomicmoże prowadzić do różnych wątków mających różne wartości dla tej samej zmiennej w pamięci podręcznej . / facepalm. W pamięci podręcznej nie, w rejestrach procesora tak (ze zmiennymi nieatomowymi); Procesory używają spójnej pamięci podręcznej. Chciałbym, żeby inne pytania dotyczące SO nie były pełne wyjaśnień atomicrozpowszechniających się nieporozumień na temat działania procesorów. (Ponieważ jest to przydatna rzecz do zrozumienia ze względu na wydajność, a także pomaga wyjaśnić, dlaczego reguły atomowe ISO C ++ są napisane takimi, jakie są).
Peter Cordes
-1
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;

bool checkValue = false;

int main()
{
    std::thread writer([&](){
            sleep(2);
            checkValue = true;
            std::cout << "Value of checkValue set to " << checkValue << std::endl;
        });

    std::thread reader([&](){
            while(!checkValue);
        });

    writer.join();
    reader.join();
}

Pewnego razu ankieter, który również uważał, że zmienność jest bezużyteczna, spierał się ze mną, że optymalizacja nie spowoduje żadnych problemów i odnosił się do różnych rdzeni mających oddzielne linie pamięci podręcznej i tak dalej (nie bardzo rozumiał, do czego dokładnie odnosi się). Ale ten fragment kodu po skompilowaniu z -O3 na g ++ (g ++ -O3 thread.cpp -lpthread) wykazuje niezdefiniowane zachowanie. Zasadniczo, jeśli wartość zostanie ustawiona przed while check, działa dobrze, a jeśli nie, przechodzi w pętlę bez zawracania sobie głowy pobieraniem wartości (która została faktycznie zmieniona przez inny wątek). Zasadniczo uważam, że wartość checkValue jest pobierana tylko raz do rejestru i nigdy nie jest ponownie sprawdzana w ramach najwyższego poziomu optymalizacji. Jeśli przed pobraniem ustawiono wartość true, działa dobrze, a jeśli nie, przechodzi w pętlę. Proszę mnie poprawić, jeśli się mylę.

Anu Siril
źródło
4
Co to ma wspólnego volatile? Tak, ten kod to UB - ale to też UB volatile.
David Schwartz,
-2

Potrzebujesz niestabilności i prawdopodobnie blokowania.

volatile mówi optymalizatorowi, że wartość może zmieniać się asynchronicznie

volatile bool flag = false;

while (!flag) {
    /*do something*/
}

odczyta flagę za każdym razem w pętli.

Jeśli wyłączysz optymalizację lub sprawisz, że każda zmienna będzie ulotna, program będzie zachowywał się tak samo, ale wolniej. niestabilny oznacza po prostu „Wiem, że może właśnie to przeczytałeś i wiesz, co jest w nim napisane, ale jeśli powiem, przeczytaj to, a potem przeczytaj.

Blokowanie jest częścią programu. Nawiasem mówiąc, jeśli implementujesz semafory, to między innymi muszą one być niestabilne. (Nie próbuj tego, jest to trudne, prawdopodobnie będzie potrzebować małego asemblera lub nowego atomowego materiału, i to już zostało zrobione).

ctrl-alt-delor
źródło
1
Ale czy to nie jest i ten sam przykład w innej odpowiedzi, zajęty czekaniem, a zatem coś, czego należy unikać? Jeśli jest to wymyślony przykład, czy są jakieś przykłady z życia, które nie są wymyślone?
David Preston,
7
@Chris: Zajęte oczekiwanie jest czasami dobrym rozwiązaniem. W szczególności, jeśli spodziewasz się czekać tylko na kilka cykli zegara, niesie to znacznie mniej obciążenia niż znacznie cięższe podejście polegające na zawieszeniu wątku. Oczywiście, jak wspomniałem w innych komentarzach, przykłady takie jak ten są błędne, ponieważ zakładają, że odczyty / zapisy do flagi nie zostaną ponownie uporządkowane w odniesieniu do kodu, który chroni, i nie ma takiej gwarancji, więc , volatilenie jest naprawdę przydatne nawet w tym przypadku. Ale zajęte czekanie jest czasami przydatną techniką.
jalf
3
@richard Tak i nie. Pierwsza połowa jest poprawna. Ale to tylko oznacza, że ​​procesor i kompilator nie mogą zmieniać kolejności zmiennych ulotnych względem siebie. Jeśli odczytam zmienną ulotną A, a następnie odczytam zmienną ulotną B, wówczas kompilator musi wyemitować kod, który jest gwarantowany (nawet przy zmianie kolejności procesora), aby odczytać A przed B. Ale nie daje gwarancji co do wszystkich nieulotnych dostępów do zmiennych . Można je ponownie uporządkować wokół ulotnego odczytu / zapisu. Więc jeśli nie
sprawisz
2
@ ctrl-alt-delor: To nie jest to, co volatileoznacza „brak zmiany kolejności”. Masz nadzieję, że oznacza to, że sklepy staną się globalnie widoczne (dla innych wątków) w kolejności programu. To właśnie atomic<T>z memory_order_releaselub seq_cstdaje. Ale daje volatile tylko gwarancję braku zmiany kolejności w czasie kompilacji : każdy dostęp pojawi się w asm w kolejności programu. Przydatne w przypadku sterownika urządzenia. I przydatne do interakcji z obsługą przerwań, debugerem lub obsługą sygnału w bieżącym rdzeniu / wątku, ale nie do interakcji z innymi rdzeniami.
Peter Cordes
1
volatilew praktyce wystarcza do sprawdzenia keep_runningflagi, tak jak tutaj: Prawdziwe procesory zawsze mają spójne pamięci podręczne, które nie wymagają ręcznego opróżniania. Ale nie ma powodu, aby polecić volatilesię atomic<T>z mo_relaxed; dostaniesz to samo co m.
Peter Cordes