C ++ 11 wprowadził ustandaryzowany model pamięci, ale co to dokładnie znaczy? Jak to wpłynie na programowanie w C ++?
Ten artykuł (autorstwa Gavina Clarke'a, który cytuje Herb Sutter ) mówi, że:
Model pamięci oznacza, że kod C ++ ma teraz znormalizowaną bibliotekę do wywołania, niezależnie od tego, kto stworzył kompilator i na jakiej platformie jest uruchomiony. Istnieje standardowy sposób kontrolowania, w jaki sposób różne wątki komunikują się z pamięcią procesora.
„Kiedy mówisz o dzieleniu [kodu] na różne rdzenie, które są w standardzie, mówimy o modelu pamięci. Zamierzamy go zoptymalizować bez złamania następujących założeń, które ludzie przyjmą w kodzie” - powiedział Sutter .
Cóż, mogę zapamiętać ten i podobne akapity dostępne w Internecie (ponieważ mam swój własny model pamięci od urodzenia: P), a nawet mogę pisać jako odpowiedź na pytania zadane przez innych, ale szczerze mówiąc, nie do końca rozumiem to.
Programiści C ++ już wcześniej opracowywali aplikacje wielowątkowe, więc jakie to ma znaczenie, jeśli są to wątki POSIX, Windows lub C ++ 11? Jakie są korzyści? Chcę zrozumieć szczegóły niskiego poziomu.
Mam również wrażenie, że model pamięci C ++ 11 jest w jakiś sposób powiązany z obsługą wielowątkowości C ++ 11, ponieważ często widzę te dwa razem. Jeśli tak, to jak dokładnie? Dlaczego powinny być powiązane?
Ponieważ nie wiem, jak działają elementy wewnętrzne wielowątkowości i co ogólnie oznacza model pamięci, pomóż mi zrozumieć te pojęcia. :-)
Odpowiedzi:
Najpierw musisz nauczyć się myśleć jak prawnik językowy.
Specyfikacja C ++ nie zawiera odniesienia do żadnego konkretnego kompilatora, systemu operacyjnego lub procesora. Odwołuje się do abstrakcyjnej maszyny, która jest uogólnieniem rzeczywistych systemów. W świecie Language Lawyer zadaniem programisty jest pisanie kodu dla abstrakcyjnej maszyny; zadaniem kompilatora jest aktualizacja tego kodu na konkretnej maszynie. Kodując sztywno zgodnie ze specyfikacją, możesz mieć pewność, że Twój kod będzie się kompilował i działał bez modyfikacji w dowolnym systemie z kompatybilnym kompilatorem C ++, zarówno dzisiaj, jak i za 50 lat.
Maszyna abstrakcyjna w specyfikacji C ++ 98 / C ++ 03 jest zasadniczo jednowątkowa. Dlatego nie jest możliwe napisanie wielowątkowego kodu C ++, który jest „w pełni przenośny” w odniesieniu do specyfikacji. Specyfikacja nawet nie mówi nic o atomowości ładowań i magazynów pamięci ani o kolejności, w której mogą się zdarzać ładunki i sklepy, nie wspominając o takich rzeczach jak muteksy.
Oczywiście możesz pisać kod wielowątkowy w praktyce dla konkretnych konkretnych systemów - takich jak pthreads lub Windows. Ale nie ma standardowego sposobu pisania kodu wielowątkowego dla C ++ 98 / C ++ 03.
Maszyna abstrakcyjna w C ++ 11 jest wielowątkowa z założenia. Ma również dobrze zdefiniowany model pamięci ; oznacza to, co kompilator może, a czego nie może zrobić, jeśli chodzi o dostęp do pamięci.
Rozważ następujący przykład, w którym para zmiennych globalnych jest dostępna jednocześnie przez dwa wątki:
Co może wygenerować wątek 2?
W C ++ 98 / C ++ 03 nie jest to nawet niezdefiniowane zachowanie; samo pytanie nie ma znaczenia, ponieważ standard nie uwzględnia niczego zwanego „wątkiem”.
W C ++ 11 wynikiem jest zachowanie niezdefiniowane, ponieważ ładunki i zapasy nie muszą być ogólnie atomowe. Co może nie wydawać się dużą poprawą ... I samo w sobie nie jest.
Ale w C ++ 11 możesz napisać to:
Teraz sprawy stają się znacznie bardziej interesujące. Po pierwsze, zachowanie tutaj jest zdefiniowane . Wątek 2 może teraz zostać wydrukowany
0 0
(jeśli działa przed wątkiem 1),37 17
(jeśli działa po wątku 1) lub0 17
(jeśli działa po tym, jak wątek 1 przypisuje x, ale przed przypisaniem do y).Nie może drukować
37 0
, ponieważ domyślnym trybem dla ładunków / magazynów atomowych w C ++ 11 jest wymuszanie spójności sekwencyjnej . Oznacza to po prostu, że wszystkie ładunki i magazyny muszą być „tak, jakby” miały miejsce w kolejności, w jakiej zostały napisane w każdym wątku, podczas gdy operacje między wątkami mogą być przeplatane w dowolny sposób. Tak więc domyślne zachowanie atomiki zapewnia zarówno atomowość, jak i porządkowanie ładunków i zapasów.Teraz na nowoczesnym procesorze zapewnienie sekwencyjnej spójności może być kosztowne. W szczególności kompilator najprawdopodobniej będzie emitował pełne bariery pamięciowe między każdym dostępem tutaj. Ale jeśli twój algorytm może tolerować ładunki i zamówienia poza kolejnością; tzn. jeśli wymaga atomowości, ale nie porządkowania; tzn. jeśli może tolerować
37 0
dane wyjściowe z tego programu, możesz napisać to:Im bardziej nowoczesny procesor, tym większe prawdopodobieństwo, że będzie to szybsze niż w poprzednim przykładzie.
Na koniec, jeśli chcesz zachować porządek w poszczególnych ładunkach i sklepach, możesz napisać:
To zabiera nas z powrotem do zamówionych ładunków i sklepów - więc
37 0
nie jest to już możliwe wyjście - ale robi to przy minimalnym obciążeniu. (W tym trywialnym przykładzie wynik jest taki sam, jak pełna zgodność sekwencyjna; w większym programie tak nie byłoby).Oczywiście, jeśli jedynymi wyjściami, które chcesz zobaczyć, są
0 0
lub37 17
, możesz po prostu owinąć muteks wokół oryginalnego kodu. Ale jeśli przeczytałeś do tej pory, założę się, że wiesz już, jak to działa, a ta odpowiedź jest już dłuższa niż zamierzałem :-).Więc dolna linia. Muteksy są świetne, a C ++ 11 je standaryzuje. Ale czasami ze względów wydajnościowych potrzebujesz prymitywów niższego poziomu (np. Klasyczny wzorzec blokowania z podwójną kontrolą ). Nowy standard zapewnia gadżety wysokiego poziomu, takie jak muteksy i zmienne warunkowe, a także zapewnia gadżety niskiego poziomu, takie jak typy atomowe i różne bariery pamięci. Teraz możesz pisać skomplikowane, wysokowydajne współbieżne procedury całkowicie w języku określonym przez standard, i możesz mieć pewność, że Twój kod będzie się kompilował i działał bez zmian zarówno w dzisiejszych systemach, jak i w przyszłości.
Chociaż szczerze mówiąc, chyba że jesteś ekspertem i pracujesz nad poważnym kodem niskiego poziomu, prawdopodobnie powinieneś trzymać się muteksów i zmiennych warunkowych. To właśnie zamierzam zrobić.
Aby uzyskać więcej informacji na ten temat, zobacz ten post na blogu .
źródło
i = i++
. Stara koncepcja punktów sekwencji została odrzucona; nowy standard określa to samo, wykorzystując relację sekwencyjną przed, która jest tylko szczególnym przypadkiem bardziej ogólnej koncepcji między wątkami, która wydarzyła się przed .Podam tylko analogię, z którą rozumiem modele spójności pamięci (lub modele pamięci, w skrócie). Inspiracją jest przełomowy artykuł Leslie Lamporta „Czas, zegary i kolejność zdarzeń w systemie rozproszonym” . Analogia jest trafna i ma fundamentalne znaczenie, ale dla wielu osób może być przesadą. Mam jednak nadzieję, że zapewnia obraz mentalny (przedstawienie obrazowe), który ułatwia rozumowanie modeli spójności pamięci.
Zobaczmy historie wszystkich lokalizacji pamięci na schemacie czasoprzestrzennym, w którym oś pozioma reprezentuje przestrzeń adresową (tj. Każda lokalizacja pamięci jest reprezentowana przez punkt na tej osi), a oś pionowa reprezentuje czas (zobaczymy, ogólnie nie ma uniwersalnego pojęcia czasu). Historia wartości przechowywanych w każdej lokalizacji pamięci jest zatem reprezentowana przez pionową kolumnę pod tym adresem pamięci. Każda zmiana wartości wynika z tego, że jeden z wątków zapisuje nową wartość w tej lokalizacji. Przez obraz pamięci rozumiemy agregację / kombinację wartości wszystkich lokalizacji pamięci, które można zaobserwować w określonym czasie przez określony wątek .
Cytując z „Podstawy spójności pamięci i spójności pamięci podręcznej”
Ta globalna kolejność pamięci może się różnić w zależności od uruchomienia programu i może nie być wcześniej znana. Cechą charakterystyczną SC jest zestaw poziomych przekrojów na schemacie adres-czasoprzestrzeń reprezentujących płaszczyzny jednoczesności (tj. Obrazy pamięci). Na danej płaszczyźnie wszystkie jej zdarzenia (lub wartości pamięci) są równoczesne. Istnieje pojęcie czasu bezwzględnego , w którym wszystkie wątki zgadzają się, które wartości pamięci są równoczesne. W SC w każdej chwili istnieje tylko jeden obraz pamięci współużytkowany przez wszystkie wątki. Oznacza to, że w każdej chwili wszystkie procesory uzgadniają obraz pamięci (tj. Zagregowaną zawartość pamięci). Oznacza to nie tylko, że wszystkie wątki wyświetlają tę samą sekwencję wartości dla wszystkich lokalizacji w pamięci, ale także że wszystkie procesory obserwują to samokombinacje wartości wszystkich zmiennych. Jest to to samo, co stwierdzenie, że wszystkie operacje pamięci (we wszystkich lokalizacjach pamięci) są obserwowane w tej samej całkowitej kolejności przez wszystkie wątki.
W zrelaksowanych modelach pamięci, każdy wątek tnie adres-czasoprzestrzeń na swój sposób, jedynym ograniczeniem jest to, że wycinki każdego wątku nie powinny się przecinać, ponieważ wszystkie wątki muszą zgadzać się z historią każdego pojedynczego miejsca pamięci (oczywiście , plastry różnych nici mogą i będą się krzyżować). Nie ma uniwersalnego sposobu na podzielenie go na części (brak uprzywilejowanego foliowania czasu i przestrzeni adresowej). Plastry nie muszą być płaskie (ani liniowe). Mogą być zakrzywione, a to może sprawić, że wątek odczyta wartości zapisane przez inny wątek w kolejności, w jakiej zostały zapisane. Historie różnych lokalizacji pamięci mogą przesuwać się (lub rozciągać) dowolnie względem siebie, gdy są oglądane przez dowolny konkretny wątek. Każdy wątek będzie miał inne wyczucie, które zdarzenia (lub równoważnie wartości pamięci) są jednoczesne. Zestaw zdarzeń (lub wartości pamięci), które są jednoczesne dla jednego wątku, nie są jednoczesne dla drugiego. Tak więc w zrelaksowanym modelu pamięci wszystkie wątki nadal obserwują tę samą historię (tj. Sekwencję wartości) dla każdej lokalizacji pamięci. Mogą jednak obserwować różne obrazy pamięci (tj. Kombinacje wartości wszystkich lokalizacji pamięci). Nawet jeśli dwie różne lokalizacje pamięci są zapisywane przez ten sam wątek w sekwencji, dwie nowo zapisane wartości mogą być obserwowane w innej kolejności przez inne wątki.
[Zdjęcie z Wikipedii]
Czytelnicy zaznajomieni ze Specjalną Teorią Względności Einsteina zauważą, o czym mówię. Tłumaczenie słów Minkowskiego na dziedzinę modeli pamięci: przestrzeń adresowa i czas to cienie adresu-czasoprzestrzeni. W takim przypadku każdy obserwator (tj. Wątek) będzie rzutował cienie zdarzeń (tj. Zapisy / obciążenia pamięci) na własną linię świata (tj. Jego oś czasu) i własną płaszczyznę jednoczesności (jego oś adres-przestrzeń) . Wątki w modelu pamięci C ++ 11 odpowiadają obserwatorom poruszającym się względem siebie w szczególnej teorii względności. Spójność sekwencyjna odpowiada czasoprzestrzeni Galilejskiej (tzn. Wszyscy obserwatorzy zgadzają się co do jednej absolutnej kolejności zdarzeń i globalnego poczucia jednoczesności).
Podobieństwo między modelami pamięci a szczególną teorią względności wynika z faktu, że oba definiują częściowo uporządkowany zestaw zdarzeń, często nazywany zbiorem przyczynowym. Niektóre zdarzenia (tj. Magazyny pamięci) mogą wpływać na inne zdarzenia (ale nie mieć na nie wpływu). Wątek C ++ 11 (lub obserwator w fizyce) jest niczym więcej niż łańcuchem (tj. Całkowicie uporządkowanym zbiorem) zdarzeń (np. Ładuje pamięć i przechowuje pod możliwie różnymi adresami).
W teorii względności pewien porządek przywracany jest pozornie chaotycznemu obrazowi częściowo uporządkowanych zdarzeń, ponieważ jedynym porządkiem czasowym, na który wszyscy obserwatorzy się zgadzają, jest porządkowanie między zdarzeniami „podobnymi do czasu” (tj. Zdarzeniami, które w zasadzie można połączyć dowolną cząsteczką wolniej działającą niż prędkość światła w próżni). Niezmiennie porządkowane są tylko zdarzenia związane z czasem. Czas w fizyce, Craig Callender .
W modelu pamięci C ++ 11 podobny mechanizm (model spójności nabywania i uwalniania) jest używany do ustalenia tych lokalnych związków przyczynowości .
Aby podać definicję spójności pamięci i motywację do porzucenia SC, zacytuję z „Podstawy spójności pamięci i spójności pamięci podręcznej”
Ponieważ spójność pamięci podręcznej i spójność pamięci są czasami mylone, pouczające jest również mieć ten cytat:
Kontynuując nasz obraz mentalny, niezmiennik SWMR odpowiada fizycznemu wymogowi, że w jednym miejscu może znajdować się co najwyżej jedna cząstka, ale może być nieograniczona liczba obserwatorów w dowolnym miejscu.
źródło
Jest to obecnie wieloletnie pytanie, ale będąc bardzo popularnym, warto wspomnieć o fantastycznym zasobie do nauki o modelu pamięci C ++ 11. Nie widzę sensu w podsumowywaniu swojego przemówienia, aby uzyskać kolejną pełną odpowiedź, ale biorąc pod uwagę, że jest to facet, który napisał standard, uważam, że warto go obejrzeć.
Herb Sutter ma trzygodzinną rozmowę o modelu pamięci C ++ 11 zatytułowanym „Broń atomowa <>”, dostępnym na stronie Channel9 - część 1 i część 2 . Dyskusja jest dość techniczna i obejmuje następujące tematy:
Dyskusja nie dotyczy API, ale rozumowania, tła, pod maską i za kulisami (czy wiesz, że łagodna semantyka została dodana do standardu tylko dlatego, że POWER i ARM nie obsługują wydajnie synchronizowanego obciążenia?).
źródło
Oznacza to, że standard definiuje teraz wielowątkowość i określa, co dzieje się w kontekście wielu wątków. Oczywiście ludzie używali różnych implementacji, ale to tak, jakby pytać, dlaczego powinniśmy mieć,
std::string
kiedy wszyscy moglibyśmy korzystać zstring
klasy domowej .Kiedy mówisz o wątkach POSIX lub Windows, jest to trochę złudzeniem, ponieważ tak naprawdę mówisz o wątkach x86, ponieważ jest to funkcja sprzętowa do jednoczesnego działania. Model pamięci C ++ 0x daje gwarancje, niezależnie od tego, czy korzystasz z x86, ARM, MIPS , czy cokolwiek innego, co możesz wymyślić.
źródło
W przypadku języków, które nie określają modelu pamięci, piszesz kod języka i modelu pamięci określonego przez architekturę procesora. Procesor może zmienić kolejność dostępów do pamięci w celu zwiększenia wydajności. Tak więc, jeśli twój program ma wyścigi danych (wyścig danych jest możliwy, gdy wiele rdzeni / hiperwątków ma dostęp do tej samej pamięci jednocześnie), to twój program nie jest wieloplatformowy z powodu zależności od modelu pamięci procesora. Możesz zapoznać się z instrukcją oprogramowania Intel lub AMD, aby dowiedzieć się, w jaki sposób procesory mogą ponownie zamówić dostęp do pamięci.
Bardzo ważne jest to, że zamki (i semantyka współbieżności z blokowaniem) są zazwyczaj implementowane w sposób wieloplatformowy ... Więc jeśli używasz standardowych zamków w programie wielowątkowym bez wyścigów danych, nie musisz się martwić o modele pamięci między platformami .
Co ciekawe, kompilatory Microsoft dla C ++ posiadają semantykę / release dla volatile, która jest rozszerzeniem C ++ do radzenia sobie z brakiem modelu pamięci w C ++ http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80) .aspx . Biorąc jednak pod uwagę, że Windows działa tylko na x86 / x64, nie znaczy to wiele (modele pamięci Intel i AMD ułatwiają i wydajnie wdrażać semantykę pozyskiwania / wydawania w języku).
źródło
Jeśli używasz muteksów do ochrony wszystkich swoich danych, naprawdę nie powinieneś się martwić. Mutexy zawsze zapewniały wystarczające gwarancje porządku i widoczności.
Teraz, jeśli używałeś atomów lub algorytmów bez blokady, musisz pomyśleć o modelu pamięci. Model pamięci opisuje dokładnie, kiedy atomics zapewnia porządek i gwarancje widoczności, a także zapewnia przenośne ogrodzenia dla ręcznie kodowanych gwarancji.
Wcześniej atomika była wykonywana przy użyciu wewnętrznych funkcji kompilatora lub biblioteki wyższego poziomu. Ogrodzenia zostałyby wykonane przy użyciu instrukcji specyficznych dla procesora (bariery pamięci).
źródło
Powyższe odpowiedzi dotyczą najbardziej podstawowych aspektów modelu pamięci C ++. W praktyce większość zastosowań
std::atomic<>
„po prostu działa”, przynajmniej do momentu nadmiernej optymalizacji programisty (np. Poprzez próbę rozluźnienia zbyt wielu rzeczy).Jest jedno miejsce, w którym błędy są nadal powszechne: sekwencje blokują się . Na stronie https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf znajduje się doskonała i łatwa do odczytania dyskusja . Blokady sekwencji są atrakcyjne, ponieważ czytelnik unika pisania słowa blokującego. Poniższy kod oparty jest na rysunku 1 powyższego raportu technicznego i przedstawia wyzwania związane z implementacją blokad sekwencji w C ++:
Na początku tak nieintuicyjna, jak się wydaje,
data1
idata2
musi byćatomic<>
. Jeśli nie są atomowe, można je odczytać (wreader()
) dokładnie w tym samym czasie, w którym są zapisane (wwriter()
). Według modelu pamięci C ++ jest to wyścig, nawet jeślireader()
tak naprawdę nigdy nie korzysta z danych . Ponadto, jeśli nie są atomowe, kompilator może buforować pierwszy odczyt każdej wartości w rejestrze. Oczywiście nie chcesz tego ... chcesz ponownie przeczytać w każdej iteracjiwhile
pętlireader()
.Nie wystarczy też ich utworzyć
atomic<>
i uzyskać do nich dostępmemory_order_relaxed
. Powodem tego jest to, że tylko odczyty seq (inreader()
) są pobierane semantyki. Mówiąc prosto, jeśli X i Y są dostępami do pamięci, X poprzedza Y, X nie jest nabyciem ani zwolnieniem, a Y jest nabyciem, to kompilator może zmienić kolejność Y przed X. Jeśli Y był drugim odczytem seq, a X był odczyt danych, takie zmiany kolejności przerwałyby implementację blokady.Artykuł podaje kilka rozwiązań. Ten, który dzisiaj ma najlepszą wydajność, prawdopodobnie używa tego
atomic_thread_fence
sięmemory_order_relaxed
przed drugim czytamy o seqlock. W artykule jest to rysunek 6. Nie odtwarzam tutaj kodu, ponieważ każdy, kto przeczytał do tej pory, naprawdę powinien przeczytać ten artykuł. Jest bardziej precyzyjny i kompletny niż ten post.Ostatnią kwestią jest to, że wykonanie tego może być nienaturalne
data
zmienne atomowe . Jeśli nie możesz tego zrobić w kodzie, musisz być bardzo ostrożny, ponieważ rzutowanie z nieatomowego na atomowy jest legalne tylko dla typów pierwotnych. C ++ 20 powinien dodaćatomic_ref<>
, co ułatwi rozwiązanie tego problemu.Podsumowując: nawet jeśli uważasz, że rozumiesz model pamięci C ++, powinieneś być bardzo ostrożny przed uruchomieniem własnych blokad sekwencji.
źródło
C i C ++ były definiowane za pomocą śladu wykonawczego dobrze utworzonego programu.
Teraz są w połowie zdefiniowane przez ślad wykonania programu, a w połowie a posteriori przez wiele porządków na obiektach synchronizujących.
Oznacza to, że te definicje języka nie mają żadnego sensu, ponieważ nie ma logicznej metody łączenia tych dwóch podejść. W szczególności zniszczenie muteksu lub zmiennej atomowej nie jest dobrze zdefiniowane.
źródło