Czy słowo kluczowe volatile w C ++ wprowadza ogrodzenie pamięci?

85

Rozumiem, że volatileinformuje kompilator, że wartość może ulec zmianie, ale czy aby wykonać tę funkcję, kompilator musi wprowadzić ogrodzenie pamięci, aby działało?

Z mojego zrozumienia, sekwencji operacji na obiektach ulotnych nie można zmienić i należy ją zachować. Wydaje się to sugerować, że niektóre ogrodzenia pamięci są konieczne i nie ma sposobu na obejście tego. Czy mam rację, mówiąc to?


Na to pokrewne pytanie toczy się interesująca dyskusja

Jonathan Wakely pisze :

... Kompilator nie może zmienić kolejności dostępu do różnych zmiennych lotnych, o ile występują w oddzielnych pełnych wyrażeniach ... prawda, że ​​zmienne są bezużyteczne ze względu na bezpieczeństwo wątków, ale nie z powodów, które podaje. Nie dzieje się tak dlatego, że kompilator może zmienić kolejność dostępu do obiektów ulotnych, ale dlatego, że procesor może zmienić ich kolejność. Operacje atomowe i bariery pamięci uniemożliwiają kompilatorowi i procesorowi zmianę kolejności

Na co David Schwartz odpowiada w komentarzach :

... Z punktu widzenia standardu C ++ nie ma różnicy między kompilatorem, który coś robi, a kompilatorem emitującym instrukcje, które powodują, że sprzęt coś robi. Jeśli procesor może zmienić kolejność dostępu do składników lotnych, to standard nie wymaga zachowania ich kolejności. ...

... Standard C ++ nie ma żadnego rozróżnienia na temat tego, co powoduje zmianę kolejności. I nie można argumentować, że procesor może zmienić ich kolejność bez zauważalnego efektu, więc to jest w porządku - standard C ++ definiuje ich kolejność jako obserwowalną. Kompilator jest zgodny ze standardem C ++ na platformie, jeśli generuje kod, który sprawia, że ​​platforma robi to, czego wymaga standard. Jeśli standard wymaga, aby dostęp do składników lotnych nie był zmieniany, platforma, na której są one ponownie zamawiane, nie jest zgodna. ...

Chodzi mi o to, że jeśli standard C ++ zabrania kompilatorowi zmiany kolejności dostępów do różnych składników lotnych, na podstawie teorii, że kolejność takich dostępów jest częścią obserwowalnego zachowania programu, to wymaga również, aby kompilator emitował kod, który zabrania procesorowi więc. Standard nie rozróżnia między tym, co robi kompilator, a tym, co generuje kod kompilatora, powoduje, że robi to procesor.

Co rodzi dwa pytania: czy którekolwiek z nich jest „słuszne”? Co tak naprawdę robią rzeczywiste wdrożenia?

Nathan Doromal
źródło
9
Oznacza to głównie, że kompilator nie powinien przechowywać tej zmiennej w rejestrze. Każde przypisanie i odczyt w kodzie źródłowym powinno odpowiadać dostępom do pamięci w kodzie binarnym.
Basile Starynkevitch
1
Podejrzewam, że chodzi o to, że jakiekolwiek ogrodzenie pamięci byłoby nieskuteczne, gdyby wartość była przechowywana w rejestrze wewnętrznym. Myślę, że w równoległej sytuacji nadal musisz podjąć inne środki ochronne.
Galik
O ile wiem, zmienna zmienna jest używana dla zmiennych, które mogą być zmieniane sprzętowo (często używane z mikrokontrolerami). Oznacza to po prostu, że nie można odczytać zmiennej w innej kolejności i nie można jej zoptymalizować. To jest C, ale powinno być takie samo w ++.
Maszt
1
@Mast Nie widziałem jeszcze kompilatora, który zapobiega volatileoptymalizacji odczytów zmiennych przez pamięci podręczne procesora. Albo wszystkie te kompilatory są niezgodne, albo standard nie oznacza tego, co myślisz, że oznacza. (Standard nie rozróżnia między tym, co robi kompilator, a tym, co kompilator wymusza na procesorze. Zadaniem kompilatora jest emitowanie kodu, który po uruchomieniu jest zgodny ze standardem.)
David Schwartz

Odpowiedzi:

58

Zamiast wyjaśniać, co to volatilerobi, pozwól mi wyjaśnić, kiedy powinieneś użyć volatile.

  • Wewnątrz uchwytu sygnału. Ponieważ zapis do volatilezmiennej jest właściwie jedyną rzeczą, na którą pozwala standard, z poziomu programu obsługi sygnału. Od C ++ 11 można go używać std::atomicdo tego celu, ale tylko wtedy, gdy atomic nie zawiera blokad.
  • W kontaktach z firmą setjmp Intel .
  • Gdy masz do czynienia bezpośrednio ze sprzętem i chcesz mieć pewność, że kompilator nie zoptymalizuje odczytów ani zapisów.

Na przykład:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Bez volatilespecyfikatora kompilator może całkowicie zoptymalizować pętlę. Specyfikator volatilemówi kompilatorowi, że może nie zakładać, że 2 kolejne odczyty zwracają tę samą wartość.

Zauważ, że volatilenie ma to nic wspólnego z wątkami. Powyższy przykład nie działa, jeśli zapisano do innego wątku, *fooponieważ nie ma operacji pobierania.

We wszystkich innych przypadkach użycie programu volatilepowinno być uważane za nieprzenośne i nie należy już przechodzić przeglądu kodu, z wyjątkiem sytuacji, gdy mamy do czynienia z kompilatorami sprzed wersji C ++ 11 i rozszerzeniami kompilatora (takimi jak /volatile:msprzełącznik msvc , który jest domyślnie włączony w X86 / I64).

Stefan
źródło
5
Jest bardziej rygorystyczny niż „nie może zakładać, że 2 kolejne odczyty zwracają tę samą wartość”. Nawet jeśli przeczytasz tylko raz i / lub wyrzucisz wartość (y), odczyt musi zostać wykonany.
philipxy,
1
Użycie w programach obsługi sygnału i setjmpsą dwiema gwarancjami, które są standardowymi markami. Z drugiej strony zamiarem , przynajmniej na początku, było wspieranie operacji we / wy mapowanych w pamięci. Które na niektórych procesorach mogą wymagać ogrodzenia lub membara.
James Kanze
@philipxy Tyle tylko, że nikt nie wie, co oznacza „odczyt”. Na przykład nikt nie wierzy, że faktyczny odczyt z pamięci musi zostać wykonany - żaden znany mi kompilator nie próbuje omijać pamięci podręcznej procesora podczas volatiledostępu.
David Schwartz
@JamesKanze: Nie tak. Re obsługi sygnału standard mówi, że podczas obsługi sygnału tylko ulotne obiekty atomowe std :: sig_atomic_t i wolne od blokady mają zdefiniowane wartości. Ale mówi również, że dostęp do obiektów lotnych jest obserwowalnymi efektami ubocznymi.
philipxy
1
@DavidSchwartz Niektóre pary kompilator-architektura odwzorowują określoną przez standard sekwencję dostępu do rzeczywistych efektów, a działające programy uzyskują dostęp do składników lotnych, aby uzyskać te efekty. Fakt, że niektóre takie pary nie mają mapowania lub trywialne, niepomocne mapowanie jest istotne dla jakości implementacji, ale nie dla tego zagadnienia.
philipxy
25

Czy słowo kluczowe volatile w C ++ wprowadza ogrodzenie pamięci?

Kompilator C ++ zgodny ze specyfikacją nie musi wprowadzać ogrodzenia pamięci. Twój konkretny kompilator może; skieruj swoje pytanie do autorów twojego kompilatora.

Funkcja „volatile” w C ++ nie ma nic wspólnego z wątkami. Pamiętaj, że celem „volatile” jest wyłączenie optymalizacji kompilatora, aby odczyt z rejestru, który zmienia się z powodu warunków egzogenicznych, nie został zoptymalizowany. Czy adres pamięci, który jest zapisywany przez inny wątek na innym procesorze, jest rejestrem, który zmienia się z powodu warunków egzogenicznych? Nie. Ponownie, jeśli niektórzy autorzy kompilatorów zdecydowali się traktować adresy pamięci zapisywane przez różne wątki na różnych procesorach tak, jakby były to rejestry zmieniające się z powodu warunków egzogenicznych, to ich sprawa; nie są do tego zobowiązani. Nie są też wymagane - nawet jeśli wprowadza barierę pamięci - na przykład, aby upewnić się, że każdy wątek jest spójny porządkowanie nietrwałych odczytów i zapisów.

W rzeczywistości volatile jest praktycznie bezużyteczne do tworzenia wątków w C / C ++. Najlepszą praktyką jest unikanie tego.

Ponadto: ogrodzenia pamięci są szczegółem implementacji poszczególnych architektur procesorów. W C #, gdzie volatile jest jawnie przeznaczone do wielowątkowości, specyfikacja nie mówi, że zostaną wprowadzone półogrodzenia, ponieważ program może działać na architekturze, która w ogóle nie ma ogrodzeń. Wręcz przeciwnie, specyfikacja zapewnia pewne (wyjątkowo słabe) gwarancje dotyczące tego, jakie optymalizacje zostaną pominięte przez kompilator, środowisko wykonawcze i procesor, aby nałożyć pewne (bardzo słabe) ograniczenia na sposób uporządkowania niektórych efektów ubocznych. W praktyce te optymalizacje są eliminowane przez zastosowanie półotworów, ale jest to szczegół implementacji, który może ulec zmianie w przyszłości.

Fakt, że zależy ci na semantyce zmiennych w dowolnym języku, ponieważ odnoszą się one do wielowątkowości, wskazuje, że myślisz o udostępnianiu pamięci między wątkami. Rozważ po prostu, żeby tego nie robić. To sprawia, że ​​twój program jest dużo trudniejszy do zrozumienia i dużo bardziej prawdopodobne, że zawiera subtelne, niemożliwe do odtworzenia błędy.

Eric Lippert
źródło
19
„niestabilność jest praktycznie bezużyteczna w C / C ++”. Ani trochę! Masz bardzo skoncentrowany na komputerach tryb użytkownika widok świata ... ale większość kodu C i C ++ działa w systemach wbudowanych, w których ulotność jest bardzo potrzebna dla operacji we / wy mapowanych w pamięci.
Ben Voigt,
12
Przyczyną zachowania nietrwałego dostępu nie jest po prostu fakt, że warunki egzogeniczne mogą zmieniać lokalizacje pamięci. Sam dostęp może wywołać dalsze działania. Na przykład bardzo często odczyt przyspiesza FIFO lub usuwa flagę przerwania.
Ben Voigt,
3
@BenVoigt: Bezużyteczne do skutecznego radzenia sobie z problemami związanymi z wątkami było moim zamierzonym celem.
Eric Lippert
4
@DavidSchwartz Standard oczywiście nie może zagwarantować, jak działa IO mapowane na pamięć. Ale IO mapowane na pamięć jest powodem, dla którego volatilezostało wprowadzone do standardu C. Mimo to, ponieważ standard nie może określić rzeczy, takich jak to, co faktycznie dzieje się przy „dostępie”, mówi, że „To, co stanowi dostęp do obiektu, który ma zmienny typ kwalifikowany, jest zdefiniowane przez implementację”. Zbyt wiele dzisiejszych implementacji nie dostarcza użytecznej definicji dostępu, co IMHO narusza ducha standardu, nawet jeśli jest zgodny z literą.
James Kanze
8
Ta zmiana jest zdecydowaną poprawą, ale twoje wyjaśnienie jest nadal zbyt skoncentrowane na tym, że „pamięć może zostać zmieniona egzogenicznie”. volatilesemantyka jest silniejsza niż to, kompilator musi wygenerować każdy żądany dostęp (1,9 / 8, 1,9 / 12), a nie tylko zagwarantować, że egzogeniczne zmiany zostaną ostatecznie wykryte (1.10 / 27). W świecie operacji we / wy mapowanych w pamięci, odczyt pamięci może mieć dowolną skojarzoną logikę, na przykład pobierający właściwość. Nie zoptymalizowałbyś wywołań funkcji pobierających właściwości zgodnie z regułami, dla których określiłeś volatile, ani standard na to nie pozwala.
Ben Voigt
13

David przeoczył fakt, że standard C ++ określa zachowanie kilku wątków wchodzących w interakcje tylko w określonych sytuacjach, a wszystko inne skutkuje niezdefiniowanym zachowaniem. Warunek wyścigu obejmujący co najmniej jeden zapis jest niezdefiniowany, jeśli nie używasz zmiennych atomowych.

W konsekwencji kompilator ma pełne prawo zrezygnować z wszelkich instrukcji synchronizacji, ponieważ procesor zauważy tylko różnicę w programie, który wykazuje niezdefiniowane zachowanie z powodu braku synchronizacji.

Voo
źródło
5
Ładnie wyjaśnione, dziękuję. Standard definiuje tylko sekwencję dostępu do składników lotnych jako obserwowalną, o ile program nie ma nieokreślonego zachowania .
Jonathan Wakely
4
Jeśli program ma wyścig danych, wówczas norma nie nakłada żadnych wymagań dotyczących obserwowalnego zachowania programu. Nie oczekuje się, że kompilator doda bariery do nietrwałych dostępów, aby zapobiec wyścigom danych występującym w programie, to jest zadaniem programisty, albo przy użyciu jawnych barier, albo operacji atomowych.
Jonathan Wakely
Jak myślisz, dlaczego to przeoczam? Jak myślisz, która część mojego argumentu unieważnia? W 100% zgadzam się, że kompilator ma pełne prawo do rezygnacji z jakiejkolwiek synchronizacji.
David Schwartz
2
Jest to po prostu błędne, a przynajmniej ignoruje to, co istotne. volatilenie ma nic wspólnego z wątkami; jego pierwotnym celem była obsługa operacji we / wy mapowanych w pamięci. Przynajmniej na niektórych procesorach obsługa operacji we / wy mapowanych w pamięci wymagałaby ogrodzeń. (Kompilatory tego nie robią, ale to inny problem.)
James Kanze
@JamesKanze volatilema wiele wspólnego z wątkami: volatilezajmuje się pamięcią, do której można uzyskać dostęp bez wiedzy kompilatora, że ​​można uzyskać do niej dostęp, i obejmuje wiele rzeczywistych zastosowań współdzielonych danych między wątkami na określonym procesorze.
curiousguy
12

Po pierwsze, standardy C ++ nie gwarantują barier pamięciowych potrzebnych do prawidłowego uporządkowania odczytów / zapisów, które nie są atomowe. zmienne nietrwałe są zalecane do używania z MMIO, obsługą sygnałów itp. W większości implementacji zmienne zmienne nie są przydatne w przypadku wielowątkowości i generalnie nie są zalecane.

Jeśli chodzi o implementację dostępów ulotnych, jest to wybór kompilatora.

W tym artykule , opisując zachowanie gcc, pokazano, że nie można użyć obiektu ulotnego jako bariery pamięci, aby zamówić sekwencję zapisów do pamięci ulotnej.

Jeśli chodzi o zachowanie ICC , znalazłem to źródło, które mówi również, że volatile nie gwarantuje uporządkowania dostępu do pamięci.

Kompilator Microsoft VS2013 ma inne zachowanie. W tej dokumentacji wyjaśniono, w jaki sposób nietrwałość wymusza semantykę Release / Acquire i umożliwia używanie obiektów ulotnych w blokadach / wydaniach aplikacji wielowątkowych.

Innym aspektem, który należy wziąć pod uwagę, jest to, że ten sam kompilator może mieć inne zachowanie wrt. niestabilny w zależności od docelowej architektury sprzętowej . Ten post dotyczący kompilatora MSVS 2013 jasno określa specyfikę kompilacji z wersją volatile dla platform ARM.

Więc moja odpowiedź na:

Czy słowo kluczowe volatile w C ++ wprowadza ogrodzenie pamięci?

byłoby: Brak gwarancji, prawdopodobnie nie, ale niektóre kompilatory mogą to zrobić. Nie powinieneś polegać na tym, że tak.

VAndrei
źródło
2
Nie zapobiega optymalizacji, po prostu zapobiega zmienianiu przez kompilator obciążeń i przechowywaniu poza określonymi ograniczeniami.
Dietrich Epp
Nie jest jasne, co mówisz. Czy mówisz, że zdarza się to w przypadku niektórych nieokreślonych kompilatorów, które volatileuniemożliwiają kompilatorowi zmianę kolejności ładowań / sklepów? A może mówisz, że standard C ++ wymaga tego? A jeśli to drugie, czy możesz odpowiedzieć na mój argument przeciwny, przytoczony w pierwotnym pytaniu?
David Schwartz
@DavidSchwartz Standard zapobiega zmianie kolejności (z dowolnego źródła) dostępów przez volatilelwartość. Ponieważ jednak pozostawia definicję „dostępu” w gestii implementacji, nie kupuje to nam wiele, jeśli implementacja nie obchodzi.
James Kanze
Myślę, że w niektórych wersjach kompilatorów MSC zaimplementowano semantykę ogrodzenia volatile, ale w kodzie wygenerowanym przez kompilator w programie Visual Studios 2012 nie ma
przeszkód
@JamesKanze Co w zasadzie oznacza, że ​​jedynym przenośnym zachowaniem volatilejest to, które jest wyszczególnione w standardzie. ( setjmp, sygnały i tak dalej.)
David Schwartz
7

Kompilator wstawia jedynie ogrodzenie pamięci w architekturze Itanium, o ile wiem.

Słowo volatilekluczowe jest naprawdę najlepiej używane do zmian asynchronicznych, np. Programów obsługi sygnałów i rejestrów mapowanych w pamięci; jest to zwykle niewłaściwe narzędzie do programowania wielowątkowego.

Dietrich Epp
źródło
1
Raczej. „kompilator” (msvc) wstawia ogrodzenie pamięci, gdy celem jest architektura inna niż ARM i używany jest przełącznik / volatile: ms (ustawienie domyślne). Zobacz msdn.microsoft.com/en-us/library/12a04hfd.aspx . Według mojej wiedzy inne kompilatory nie wstawiają granic dla zmiennych lotnych. Należy unikać używania volatile, chyba że mamy do czynienia bezpośrednio ze sprzętem, programami obsługi sygnałów lub kompilatorami niezgodnymi z C ++ 11.
Stefan
@Stefan No. volatilejest niezwykle przydatny w wielu zastosowaniach, które nigdy nie dotyczą sprzętu. Jeśli chcesz, aby implementacja generowała kod procesora zgodny z kodem C / C ++, użyj volatile.
curiousguy
7

Zależy to od tego, jakim kompilatorem jest „kompilator”. Visual C ++ to robi od 2005 roku. Ale Standard tego nie wymaga, więc niektóre inne kompilatory tego nie wymagają.

Ben Voigt
źródło
VC ++ 2012 nie wydaje się, aby wstawić ogrodzenie: int volatile i; int main() { return i; }generuje główny z dokładnie dwóch instrukcji: mov eax, i; ret 0;.
James Kanze
@JamesKanze: Która wersja dokładnie? Czy używasz jakichkolwiek innych niż domyślne opcji kompilacji? Opieram się na dokumentacji (pierwsza wersja, której dotyczy problem) i (najnowsza wersja) , które zdecydowanie wspominają o semantyce pobierania i wydawania.
Ben Voigt
cl /helpmówi wersja 18.00.21005.1. Katalog, w którym się znajduje C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC. Nagłówek w oknie poleceń mówi VS 2013. A więc jeśli chodzi o wersję ... Jedyne opcje, których użyłem, to /c /O2 /Fa. (Bez /O2tego ustawia również lokalną ramkę stosu. Ale nadal nie ma instrukcji ogrodzenia.)
James Kanze,
@JamesKanze: Bardziej interesowała mnie architektura, np. „Microsoft (R) C / C ++ Optimizing Compiler Version 18.00.30723 for x64” Być może nie ma ogrodzenia, ponieważ x86 i x64 mają dość silne gwarancje spójności pamięci podręcznej w modelu pamięci na początek ?
Ben Voigt
Może. Nie wiem. Fakt, że zrobiłem to main, aby kompilator mógł zobaczyć cały program i wiedział, że nie było innych wątków lub przynajmniej żadnych innych dostępów do zmiennej przed moim (więc nie mogło być problemów z pamięcią podręczną) może mieć na to wpływ również, ale jakoś w to wątpię.
James Kanze,
5

Jest to w dużej mierze z pamięci i oparte na wersjach wcześniejszych niż C ++ 11, bez wątków. Ale biorąc udział w dyskusjach na temat wątków w komisji, mogę powiedzieć, że komisja nigdy nie miała zamiaru, który volatilemożna by wykorzystać do synchronizacji między wątkami. Microsoft to zaproponował, ale propozycja nie została zrealizowana.

Kluczową specyfikacją volatilejest to, że dostęp do zmiennej lotnej reprezentuje „obserwowalne zachowanie”, podobnie jak IO. W ten sam sposób kompilator nie może zmienić kolejności ani usunąć określonych operacji we / wy, nie może zmienić kolejności ani usunąć dostępu do obiektu nietrwałego (lub, dokładniej, dostęp za pośrednictwem wyrażenia lvalue z nietrwałym typem kwalifikowanym). Pierwotnym zamiarem projektu volatile było w rzeczywistości wspieranie operacji we / wy mapowanych w pamięci. „Problem” z tym polega jednak na tym, że to implementacja definiuje, co stanowi „nietrwały dostęp”. I wiele kompilatorów implementuje to tak, jakby definicja brzmiała „instrukcja, która odczytuje lub zapisuje do pamięci, została wykonana”. Co jest legalną, choć bezużyteczną definicją, jeśli implementacja to określa. (Nie znalazłem jeszcze rzeczywistej specyfikacji żadnego kompilatora.

Prawdopodobnie (i to jest argument, który akceptuję), jest to sprzeczne z intencją standardu, ponieważ jeśli sprzęt nie rozpozna adresów jako mapowanych w pamięci IO i nie zablokuje jakiejkolwiek zmiany kolejności itp., Nie możesz nawet użyć volatile dla mapowanych w pamięci IO, przynajmniej na architekturach Sparc lub Intel. Niemniej jednak żaden z kompilatorów, na które patrzyłem (Sun CC, g ++ i MSC), nie wyświetla żadnych instrukcji dotyczących ogrodzenia lub membara. (Mniej więcej w czasie, gdy Microsoft zaproponował rozszerzenie reguł dla volatile, myślę, że niektórzy z ich kompilatorów zaimplementowali swoją propozycję i wydali instrukcje ogrodzenia dla niestabilnych dostępów. Nie sprawdziłem, co robią najnowsze kompilatory, ale nie zdziwiłbym się, gdyby to zależało na niektórych opcjach kompilatora. Wersja, którą sprawdziłem - myślę, że to VS6.0 - nie emitowała jednak barier).

James Kanze
źródło
Dlaczego po prostu mówisz, że kompilator nie może zmienić kolejności lub usunąć dostępu do obiektów ulotnych? Z pewnością, jeśli dostępy są obserwowalnym zachowaniem, to z pewnością równie ważne jest zapobieganie zmianie kolejności przez procesor, zapisywanie buforów wysyłania, kontrolera pamięci i wszystkiego innego.
David Schwartz
@DavidSchwartz Ponieważ tak mówi norma. Oczywiście z praktycznego punktu widzenia to, co robią kompilatory, które zweryfikowałem, jest całkowicie bezużyteczne, ale standardowe słowa łasicy to wystarczające, aby nadal mogli twierdzić, że są zgodne (lub mogliby, jeśli faktycznie to udokumentowali).
James Kanze
1
@DavidSchwartz: Dla wyłącznych (lub zmutowanych) mapowanych pamięciowo I / O do urządzeń peryferyjnych, volatilesemantyka jest całkowicie odpowiednia. Zazwyczaj takie urządzenia peryferyjne zgłaszają swoje obszary pamięci jako niebuforowalne, co pomaga w zmianie kolejności na poziomie sprzętowym.
Ben Voigt
@BenVoigt Jakoś się nad tym zastanawiałem: pomysł, że procesor w jakiś sposób „wie”, że adres, z którym ma do czynienia, to IO mapowane w pamięci. O ile wiem, Sparcs nie ma żadnego wsparcia dla tego, więc nadal powodowałoby to, że Sun CC i g ++ na serwerze Sparc nie nadawał się do użytku dla operacji we / wy mapowanych w pamięci. (Kiedy się temu przyjrzałem, interesował mnie głównie Sparc.)
James Kanze,
@JamesKanze: Z moich drobnych poszukiwań wynika, że ​​Sparc ma dedykowane zakresy adresów dla „alternatywnych widoków” pamięci, których nie można buforować. Tak długo, jak twoje niestabilne punkty dostępu do ASI_REAL_IOczęści przestrzeni adresowej, myślę, że powinno być dobrze. (Altera NIOS używa podobnej techniki, z wysokimi bitami obejścia MMU kontrolującego adres; jestem pewien, że są też inne)
Ben Voigt,
5

Nie musi. Zmienna nie jest prymitywem synchronizacji. Po prostu wyłącza optymalizacje, tj. Otrzymujesz przewidywalną sekwencję odczytów i zapisów w wątku w tej samej kolejności, jaką określa maszyna abstrakcyjna. Ale czyta i pisze w różnych wątkach przede wszystkim nie ma porządku, nie ma sensu mówić o zachowaniu lub nie zachowaniu ich porządku. Porządek między teadami można ustalić za pomocą prymitywów synchronizacji, bez nich otrzymujesz UB.

Trochę wyjaśnienia odnośnie barier pamięci. Typowy procesor ma kilka poziomów dostępu do pamięci. Jest potok pamięci, kilka poziomów pamięci podręcznej, a następnie pamięć RAM itp.

Instrukcje Membar przepłukują rurociąg. Nie zmieniają kolejności wykonywania odczytów i zapisów, tylko wymusza wykonanie w danym momencie wybitnych. Jest to przydatne w programach wielowątkowych, ale poza tym niewiele.

Pamięci podręczne są zwykle automatycznie spójne między procesorami. Jeśli ktoś chce się upewnić, że pamięć podręczna jest zsynchronizowana z pamięcią RAM, konieczne jest opróżnienie pamięci podręcznej. Bardzo różni się od membara.

n.m.
źródło
1
Więc mówisz, że standard C ++ mówi, że volatilepo prostu wyłącza optymalizację kompilatora? To nie ma żadnego sensu. Każda optymalizacja, którą może wykonać kompilator, może, przynajmniej w zasadzie, równie dobrze zostać wykonana przez procesor. Więc jeśli standard mówi, że po prostu wyłącza optymalizacje kompilatora, oznaczałoby to, że nie zapewnia żadnego zachowania, na którym można by polegać w kodzie przenośnym. Ale to oczywiście nieprawda, ponieważ kod przenośny może polegać na swoim zachowaniu w odniesieniu do setjmpsygnałów i.
David Schwartz
1
@DavidSchwartz Nie, norma nic takiego nie mówi. Wyłączanie optymalizacji jest właśnie tym, co jest zwykle wykonywane w celu wdrożenia standardu. Standard wymaga, aby obserwowalne zachowanie zachodziło w tej samej kolejności, jakiej wymaga abstrakcyjna maszyna. Gdy maszyna abstrakcyjna nie wymaga żadnego zamówienia, implementacja może korzystać z dowolnego zamówienia lub w ogóle go nie mieć. Dostęp do zmiennych ulotnych w różnych wątkach nie jest uporządkowany, chyba że zostanie zastosowana dodatkowa synchronizacja.
n. zaimki m.
1
@DavidSchwartz Przepraszam za nieprecyzyjne sformułowanie. Standard nie wymaga wyłączania optymalizacji. W ogóle nie ma pojęcia optymalizacji. Raczej określa zachowanie, które w praktyce wymaga od kompilatorów wyłączenia pewnych optymalizacji w taki sposób, aby obserwowalna sekwencja odczytów i zapisów była zgodna ze standardem.
n. zaimki m.
1
Tyle, że nie wymaga tego, ponieważ standard pozwala implementacjom definiować „obserwowalną sekwencję odczytów i zapisów” w dowolny sposób. Jeśli implementacje zdecydują się zdefiniować obserwowalne sekwencje tak, że optymalizacje muszą być wyłączone, to robią. Jeśli nie, to nie. Otrzymujesz przewidywalną sekwencję odczytów i zapisów wtedy i tylko wtedy, gdy implementacja zdecydowała się ją udostępnić.
David Schwartz
1
Nie, implementacja musi zdefiniować, co stanowi pojedynczy dostęp. Kolejność takich dostępów jest określana przez maszynę abstrakcyjną. Wdrożenie musi zachować porządek. Norma wyraźnie mówi, że „zmienność jest wskazówką do implementacji, aby uniknąć agresywnej optymalizacji obejmującej obiekt”, choć w części nienormatywnej, ale intencja jest jasna.
n. zaimki m.
4

Kompilator musi wprowadzić barierę pamięci wokół volatiledostępów wtedy i tylko wtedy, gdy jest to konieczne do wykonania zastosowań volatileokreślonych w standardowej pracy ( setjmpprogramy obsługi sygnałów itp.) Na tej konkretnej platformie.

Zauważ, że niektóre kompilatory wykraczają daleko poza to, co jest wymagane przez standard C ++, aby uczynić volatilebardziej wydajnymi lub użytecznymi na tych platformach. Przenośny kod nie powinien polegać na volatilerobieniu niczego poza tym, co określono w standardzie C ++.

David Schwartz
źródło
2

Zawsze używam volatile w procedurach obsługi przerwań, np. ISR (często kod asemblera) modyfikuje jakąś lokalizację w pamięci, a kod wyższego poziomu, który działa poza kontekstem przerwania, uzyskuje dostęp do lokalizacji pamięci poprzez wskaźnik do ulotności.

Robię to dla pamięci RAM, a także dla operacji we / wy mapowanych w pamięci.

Na podstawie dyskusji tutaj wydaje się, że jest to nadal ważne użycie volatile, ale nie ma to nic wspólnego z wieloma wątkami lub procesorami. Jeśli kompilator mikrokontrolera „wie”, że nie może być innych dostępów (np. Wszystko jest na chipie, nie ma pamięci podręcznej i jest tylko jeden rdzeń), pomyślałbym, że w ogóle nie zakłada się ograniczenia pamięci, kompilator po prostu musi zapobiec pewnym optymalizacjom.

Kiedy wrzucamy więcej rzeczy do "systemu", który wykonuje kod wynikowy, prawie wszystkie zakłady są wyłączone, przynajmniej tak czytam tę dyskusję. Jak kompilator mógł kiedykolwiek objąć wszystkie bazy?

Andrew Queisser
źródło
0

Myślę, że zamieszanie wokół zmienności i zmiany kolejności instrukcji wynika z dwóch pojęć dotyczących zmiany kolejności procesorów:

  1. Wykonanie poza kolejnością.
  2. Sekwencja odczytu / zapisu pamięci widziana przez inne procesory (zmiana kolejności w tym sensie, że każdy procesor może zobaczyć inną sekwencję).

Volatile wpływa na sposób, w jaki kompilator generuje kod, zakładając wykonanie jednowątkowe (w tym przerwania). Nie oznacza to nic o instrukcjach bariery pamięci, ale raczej uniemożliwia kompilatorowi wykonywanie pewnych rodzajów optymalizacji związanych z dostępem do pamięci.
Typowym przykładem jest ponowne pobieranie wartości z pamięci, zamiast używania jednej z pamięci podręcznej w rejestrze.

Wykonanie poza kolejnością

Procesory mogą wykonywać instrukcje poza kolejnością / spekulatywnie, pod warunkiem, że wynik końcowy mógł mieć miejsce w oryginalnym kodzie. Procesory mogą wykonywać transformacje, które są niedozwolone w kompilatorach, ponieważ kompilatory mogą wykonywać tylko takie transformacje, które są poprawne we wszystkich okolicznościach. W przeciwieństwie do tego, procesory mogą sprawdzić poprawność tych optymalizacji i wycofać się z nich, jeśli okażą się niepoprawne.

Sekwencja odczytów / zapisów pamięci widziana przez inne procesory

Końcowy wynik sekwencji instrukcji, efektywna kolejność, musi zgadzać się z semantyką kodu generowanego przez kompilator. Jednak rzeczywista kolejność wykonywania wybrana przez procesor może być inna. Efektywna kolejność widoczna w innych procesorach (każdy procesor może mieć inny widok) może być ograniczona przez bariery pamięci.
Nie jestem pewien, jak bardzo efektywna i rzeczywista kolejność może się różnić, ponieważ nie wiem, w jakim stopniu bariery pamięci mogą uniemożliwić procesorom wykonywanie wykonywania poza kolejnością.

Źródła:

Paweł Batko
źródło
0

Podczas gdy pracowałem nad samouczkiem wideo do pobrania w trybie online, dotyczącym tworzenia grafiki 3D i silnika gier, pracowałem z nowoczesnym OpenGL. Użyliśmy volatilew ramach jednej z naszych zajęć. Witrynę z samouczkami można znaleźć tutaj, a film wideo działający ze volatilesłowem kluczowym znajduje się w Shader Engineserii wideo 98. Te prace nie są moje własne, ale są akredytowane Marek A. Krzeminski, MASci jest to fragment ze strony pobierania wideo.

„Ponieważ możemy teraz uruchamiać nasze gry w wielu wątkach, ważne jest, aby prawidłowo synchronizować dane między wątkami. W tym filmie pokazuję, jak utworzyć klasę blokowania nietrwałego, aby zapewnić prawidłową synchronizację zmiennych nietrwałych ...”

A jeśli jesteś zapisany do swojej stronie internetowej i mieć dostęp do jego filmu w tym filmie on odwołuje się ten artykuł dotyczący wykorzystania Volatilez multithreadingprogramowaniem.

Oto artykuł z linku powyżej: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

niestabilny: najlepszy przyjaciel programisty wielowątkowego

Andrei Alexandrescu, 01 lutego 2001

Słowo kluczowe volatile zostało opracowane, aby zapobiec optymalizacjom kompilatora, które mogą powodować nieprawidłowe wyświetlanie kodu w obecności pewnych zdarzeń asynchronicznych.

Nie chcę zepsuć Ci nastroju, ale ta kolumna porusza przerażający temat programowania wielowątkowego. Jeśli - jak mówi poprzednia część Generic - programowanie bezpieczne w wyjątkowych sytuacjach jest trudne, to dziecinnie proste w porównaniu z programowaniem wielowątkowym.

Programy używające wielu wątków są bardzo trudne do napisania, udowodnienia poprawności, debugowania, utrzymania i ogólnie oswajania. Nieprawidłowe programy wielowątkowe mogą działać przez lata bez zakłóceń, tylko po to, aby nieoczekiwanie uruchomić amok, ponieważ został spełniony krytyczny warunek czasowy.

Nie trzeba dodawać, że programista piszący kod wielowątkowy potrzebuje wszelkiej możliwej pomocy. Ta kolumna koncentruje się na warunkach wyścigu - częstym źródle problemów w programach wielowątkowych - i dostarcza wglądu i narzędzi, jak ich uniknąć oraz, co zadziwiające, aby kompilator ciężko pracował, aby ci w tym pomóc.

Tylko małe słowo kluczowe

Chociaż standardy C i C ++ są wyraźnie ciche, jeśli chodzi o wątki, robią niewielkie ustępstwa na temat wielowątkowości w postaci słowa kluczowego volatile.

Podobnie jak jego lepiej znany odpowiednik const, zmienny jest modyfikatorem typu. Jest przeznaczony do użytku w połączeniu ze zmiennymi, które są dostępne i modyfikowane w różnych wątkach. Zasadniczo bez ulotności pisanie programów wielowątkowych staje się niemożliwe lub kompilator marnuje ogromne możliwości optymalizacji. Wyjaśnienie jest w porządku.

Rozważ następujący kod:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

Celem Gadget :: Wait powyżej jest sprawdzanie zmiennej składowej flag_ co sekundę i zwracanie jej, gdy zmienna ta zostanie ustawiona na wartość true przez inny wątek. Przynajmniej tak zamierzał jego programista, ale niestety Wait jest niepoprawne.

Załóżmy, że kompilator odkryje, że Sleep (1000) jest wywołaniem zewnętrznej biblioteki, która nie może zmodyfikować zmiennej składowej flag_. Następnie kompilator stwierdza, że ​​może buforować flag_ w rejestrze i używać tego rejestru zamiast uzyskiwać dostęp do wolniejszej pamięci wbudowanej. Jest to doskonała optymalizacja dla kodu jednowątkowego, ale w tym przypadku szkodzi poprawności: po wywołaniu Wait for some Gadget object, mimo że inny wątek wywołuje Wakeup, Wait zapętli się na zawsze. Dzieje się tak, ponieważ zmiana flag_ nie zostanie odzwierciedlona w rejestrze przechowującym flag_. Optymalizacja jest zbyt ... optymistyczna.

Buforowanie zmiennych w rejestrach jest bardzo cenną optymalizacją, która ma zastosowanie przez większość czasu, więc szkoda byłoby ją marnować. C i C ++ dają Ci szansę na jawne wyłączenie takiego buforowania. Jeśli użyjesz modyfikatora volatile na zmiennej, kompilator nie będzie buforował tej zmiennej w rejestrach - każdy dostęp dotrze do rzeczywistej lokalizacji pamięci tej zmiennej. Wszystko, co musisz zrobić, aby kombinacja oczekiwania / wybudzania gadżetu działała, to odpowiednio zakwalifikować flag_:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

Większość wyjaśnień uzasadnienia i użycia stopu nietrwałego w tym miejscu i radzi, aby kwalifikować zmienne typy pierwotne, których używasz w wielu wątkach. Jednak z volatile można zrobić znacznie więcej, ponieważ jest to część wspaniałego systemu typów w C ++.

Używanie ulotnych z typami zdefiniowanymi przez użytkownika

Można kwalifikować nie tylko typy pierwotne, ale także typy zdefiniowane przez użytkownika. W takim przypadku volatile modyfikuje typ w sposób podobny do const. (Możesz również jednocześnie zastosować const i volatile do tego samego typu).

W przeciwieństwie do const, volatile rozróżnia typy pierwotne i typy zdefiniowane przez użytkownika. Mianowicie, w przeciwieństwie do klas, typy pierwotne nadal obsługują wszystkie swoje operacje (dodawanie, mnożenie, przypisanie itp.), Gdy są kwalifikowane zmiennie. Na przykład można przypisać nieulotną wartość int do nieulotnej wartości int, ale nie można przypisać nieulotnego obiektu do nieulotnego obiektu.

Zilustrujmy, jak nietrwałość działa na typach zdefiniowanych przez użytkownika na przykładzie.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Jeśli uważasz, że lotność nie jest zbyt użyteczna w przypadku obiektów, przygotuj się na niespodziankę.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

Konwersja z typu niekwalifikowanego do jego lotnego odpowiednika jest trywialna. Jednak, podobnie jak w przypadku const, nie można cofnąć podróży od niestabilnej do niekwalifikowanej. Musisz użyć obsady:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Klasa z kwalifikacją lotną zapewnia dostęp tylko do podzbioru swojego interfejsu, podzbioru, który jest pod kontrolą implementatora klasy. Użytkownicy mogą uzyskać pełny dostęp do interfejsu tego typu tylko przy użyciu const_cast. Ponadto, podobnie jak constness, zmienność przenosi się z klasy do jej elementów członkowskich (na przykład zmienne volatileGadget.name_ i volatileGadget.state_ są zmiennymi nietrwałymi).

niestabilne, krytyczne sekcje i warunki wyścigu

Najprostszym i najczęściej używanym urządzeniem synchronizującym w programach wielowątkowych jest mutex. Muteks ujawnia prymitywy Acquire i Release. Po wywołaniu Acquire w jakimś wątku, każdy inny wątek wywołujący Acquire zostanie zablokowany. Później, gdy ten wątek wywoła Release, zostanie zwolniony dokładnie jeden wątek zablokowany w wywołaniu Acquire. Innymi słowy, dla danego muteksu tylko jeden wątek może uzyskać czas procesora między wywołaniem Acquire a wywołaniem Release. Wykonywany kod między wywołaniem Acquire a wywołaniem Release nazywany jest sekcją krytyczną. (Terminologia systemu Windows jest nieco zagmatwana, ponieważ sam muteks nazywa się sekcją krytyczną, podczas gdy „mutex” jest w rzeczywistości muteksem między procesami. Byłoby miło, gdyby nazywano je muteksem wątku i muteksem procesu).

Muteksy służą do ochrony danych przed warunkami wyścigu. Z definicji sytuacja wyścigu występuje, gdy wpływ większej liczby wątków na dane zależy od sposobu planowania wątków. Warunki wyścigu pojawiają się, gdy co najmniej dwa wątki rywalizują o wykorzystanie tych samych danych. Ponieważ wątki mogą się wzajemnie przerywać w dowolnym momencie, dane mogą zostać uszkodzone lub źle zinterpretowane. W związku z tym zmiany, a czasami dostęp do danych, muszą być starannie chronione za pomocą krytycznych sekcji. W programowaniu obiektowym zwykle oznacza to, że przechowujesz muteks w klasie jako zmienną składową i używasz go za każdym razem, gdy uzyskujesz dostęp do stanu tej klasy.

Doświadczeni wielowątkowi programiści mogli ziewnąć, czytając dwa powyższe akapity, ale ich celem jest zapewnienie intelektualnego treningu, ponieważ teraz połączymy się z niestabilnym połączeniem. Robimy to, rysując paralelę między światem typów C ++ a światem semantyki wątków.

  • Poza sekcją krytyczną każdy wątek może przerwać dowolny inny w dowolnym momencie; nie ma kontroli, więc w konsekwencji zmienne dostępne z wielu wątków są niestabilne. Jest to zgodne z pierwotną intencją volatile - zapobieganiem nieświadomemu buforowaniu przez kompilator wartości używanych przez wiele wątków jednocześnie.
  • Wewnątrz krytycznej sekcji zdefiniowanej przez mutex, tylko jeden wątek ma dostęp. W związku z tym wewnątrz sekcji krytycznej wykonywany kod ma semantykę jednowątkową. Zmienna kontrolowana nie jest już ulotna - możesz usunąć kwalifikator volatile.

Krótko mówiąc, dane współdzielone między wątkami są koncepcyjnie nietrwałe poza sekcją krytyczną i nieulotne w sekcji krytycznej.

Wchodzisz do krytycznej sekcji, blokując muteks. Kwalifikator volatile z typu jest usuwany przez zastosowanie const_cast. Jeśli uda nam się połączyć te dwie operacje, tworzymy połączenie między systemem typów C ++ a semantyką wątków aplikacji. Możemy sprawić, że kompilator sprawdzi za nas warunki wyścigu.

LockingPtr

Potrzebujemy narzędzia, które zbiera pozyskanie muteksów i const_cast. Opracujmy szablon klasy LockingPtr, który jest inicjowany za pomocą obiektu volatile obj i obiektu mutex mtx. Podczas swojego życia LockingPtr utrzymuje pozyskanie MTX. Ponadto LockingPtr oferuje dostęp do volatile-stripped obj. Dostęp jest oferowany w formie inteligentnego wskaźnika, poprzez operator-> i operator *. Const_cast jest wykonywany wewnątrz LockingPtr. Rzutowanie jest poprawne semantycznie, ponieważ LockingPtr przechowuje muteks nabyty przez cały okres jego istnienia.

Najpierw zdefiniujmy szkielet klasy Mutex, z którą będzie działać LockingPtr:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Aby użyć LockingPtr, należy zaimplementować Mutex przy użyciu natywnych struktur danych systemu operacyjnego i podstawowych funkcji.

LockingPtr jest szablonem z typem kontrolowanej zmiennej. Na przykład, jeśli chcesz sterować Widżetem, używasz LockingPtr, który inicjujesz za pomocą zmiennej typu volatile Widget.

Definicja LockingPtr jest bardzo prosta. LockingPtr implementuje nieskomplikowany inteligentny wskaźnik. Skupia się wyłącznie na zbieraniu const_cast i sekcji krytycznej.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

Pomimo swojej prostoty LockingPtr jest bardzo przydatną pomocą w pisaniu poprawnego kodu wielowątkowego. Powinieneś zdefiniować obiekty, które są współdzielone między wątkami jako nietrwałe i nigdy nie używaj z nimi const_cast - zawsze używaj automatycznych obiektów LockingPtr. Zilustrujmy to przykładem.

Załóżmy, że masz dwa wątki, które współużytkują obiekt wektorowy:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Wewnątrz funkcji wątku po prostu używasz LockingPtr, aby uzyskać kontrolowany dostęp do zmiennej składowej buffer_:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

Kod jest bardzo łatwy do napisania i zrozumienia - ilekroć potrzebujesz użyć buffer_, musisz utworzyć LockingPtr wskazujący na to. Gdy to zrobisz, uzyskasz dostęp do całego interfejsu wektora.

Fajne jest to, że jeśli popełnisz błąd, kompilator wskaże to:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Nie możesz uzyskać dostępu do żadnej funkcji buffer_, dopóki nie zastosujesz const_cast lub nie użyjesz LockingPtr. Różnica polega na tym, że LockingPtr oferuje uporządkowany sposób stosowania const_cast do zmiennych nietrwałych.

LockingPtr jest niezwykle ekspresyjny. Jeśli potrzebujesz tylko wywołać jedną funkcję, możesz utworzyć nienazwany tymczasowy obiekt LockingPtr i używać go bezpośrednio:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Powrót do typów pierwotnych

Widzieliśmy, jak ładnie niestabilny chroni obiekty przed niekontrolowanym dostępem i jak LockingPtr zapewnia prosty i skuteczny sposób pisania kodu bezpiecznego dla wątków. Wróćmy teraz do typów pierwotnych, które są traktowane inaczej przez zmienne.

Rozważmy przykład, w którym wiele wątków współdzieli zmienną typu int.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

Jeśli Increment i Decrement mają być wywoływane z różnych wątków, powyższy fragment jest błędny. Po pierwsze, ctr_ musi być niestabilne. Po drugie, nawet pozornie atomowa operacja, taka jak ++ ctr_, jest w rzeczywistości operacją trzystopniową. Sama pamięć nie ma zdolności arytmetycznych. Przy zwiększaniu zmiennej procesor:

  • Odczytuje tę zmienną w rejestrze
  • Zwiększa wartość w rejestrze
  • Zapisuje wynik z powrotem w pamięci

Ta trzyetapowa operacja nosi nazwę RMW (odczyt-modyfikacja-zapis). Podczas części Modify operacji RMW większość procesorów zwalnia magistralę pamięci, aby umożliwić innym procesorom dostęp do pamięci.

Jeśli w tym czasie inny procesor wykonuje operację RMW na tej samej zmiennej, mamy sytuację wyścigu: drugi zapis nadpisuje efekt pierwszego.

Aby tego uniknąć, możesz ponownie polegać na LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Teraz kod jest poprawny, ale jego jakość jest gorsza w porównaniu z kodem SyncBuf. Czemu? Ponieważ z Counter, kompilator nie ostrzeże Cię, jeśli przez pomyłkę uzyskasz bezpośredni dostęp do ctr_ (bez blokowania go). Kompilator kompiluje ++ ctr_, jeśli ctr_ jest ulotny, chociaż wygenerowany kod jest po prostu nieprawidłowy. Kompilator nie jest już twoim sprzymierzeńcem i tylko twoja uwaga może pomóc ci uniknąć warunków wyścigu.

Co wtedy powinieneś zrobić? Po prostu hermetyzuj pierwotne dane, których używasz w strukturach wyższego poziomu i używaj ulotnych z tymi strukturami. Paradoksalnie, gorzej jest używać volatile bezpośrednio z wbudowanymi funkcjami, mimo że początkowo taki był cel użycia volatile!

niestabilne funkcje składowe

Do tej pory mieliśmy klasy, które agregują zmienne składowe danych; pomyślmy teraz o projektowaniu klas, które z kolei będą częścią większych obiektów i będą współdzielone między wątkami. Tutaj bardzo pomocne mogą być zmienne funkcje składowe.

Projektując klasę, kwalifikujesz nietrwałe tylko te funkcje składowe, które są bezpieczne wątkowo. Musisz założyć, że kod z zewnątrz będzie wywoływał funkcje ulotne z dowolnego kodu w dowolnym momencie. Nie zapomnij: nietrwałość oznacza wolny kod wielowątkowy i brak krytycznej sekcji; nieulotna oznacza scenariusz jednowątkowy lub w sekcji krytycznej.

Na przykład definiujesz klasę Widget, która implementuje operację w dwóch wariantach - bezpiecznym wątkowo i szybkim, niezabezpieczonym.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

Zwróć uwagę na użycie przeciążenia. Teraz użytkownik Widget może wywołać Operację używając jednolitej składni albo dla obiektów ulotnych i uzyskać bezpieczeństwo wątków, albo dla zwykłych obiektów i uzyskać szybkość. Użytkownik musi zachować ostrożność podczas definiowania współdzielonych obiektów Widgetu jako nietrwałych.

Podczas implementowania niestabilnej funkcji składowej pierwszą operacją jest zwykle zablokowanie jej za pomocą LockingPtr. Następnie praca jest wykonywana przy użyciu nieulotnego rodzeństwa:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Podsumowanie

Pisząc programy wielowątkowe, możesz wykorzystać zmienność na swoją korzyść. Musisz przestrzegać następujących zasad:

  • Zdefiniuj wszystkie obiekty udostępnione jako nietrwałe.
  • Nie używaj lotnych bezpośrednio z typami pierwotnymi.
  • Definiując klasy współdzielone, użyj zmiennych funkcji składowych, aby wyrazić bezpieczeństwo wątków.

Jeśli to zrobisz i jeśli użyjesz prostego komponentu ogólnego LockingPtr, możesz napisać kod bezpieczny dla wątków i znacznie mniej martwić się warunkami wyścigu, ponieważ kompilator będzie się o ciebie martwił i pilnie wskaże miejsca, w których się mylisz.

Kilka projektów, z którymi byłem zaangażowany, używa volatile i LockingPtr z doskonałym skutkiem. Kod jest czysty i zrozumiały. Pamiętam kilka zakleszczeń, ale wolę zakleszczenia od warunków wyścigu, ponieważ są one o wiele łatwiejsze do debugowania. Praktycznie nie było problemów związanych z warunkami wyścigu. Ale wtedy nigdy nie wiadomo.

Podziękowanie

Podziękowania dla Jamesa Kanze i Sorina Jianu, którzy pomogli we wnikliwych pomysłach.


Andrei Alexandrescu jest kierownikiem ds. Rozwoju w RealNetworks Inc. (www.realnetworks.com) z siedzibą w Seattle w stanie Waszyngton i autorem uznanej książki Modern C ++ Design. Można się z nim skontaktować pod adresem www.moderncppdesign.com. Andrei jest również jednym z polecanych instruktorów The C ++ Seminar (www.gotw.ca/cpp_seminar).

Ten artykuł może być trochę przestarzały, ale daje dobry wgląd w doskonałe wykorzystanie zmiennego modyfikatora w programowaniu wielowątkowym, aby pomóc zachować asynchroniczność wydarzeń, podczas gdy kompilator sprawdza dla nas warunki wyścigu. Może to nie odpowiadać bezpośrednio na pierwotne pytanie OP dotyczące tworzenia ogrodzenia pamięci, ale zdecydowałem się opublikować to jako odpowiedź dla innych jako doskonałe odniesienie do dobrego wykorzystania ulotności podczas pracy z aplikacjami wielowątkowymi.

Francis Cugler
źródło
0

Słowo kluczowe volatilezasadniczo oznacza, że ​​odczyt i zapis obiektu powinien być wykonywany dokładnie tak, jak został napisany przez program, i nie powinien być w żaden sposób optymalizowany . Kod binarny powinien następować po kodzie C lub C ++: ładowanie, w którym jest czytany, sklep, w którym jest zapis.

Oznacza to również, że żaden odczyt nie powinien dać przewidywalnej wartości: kompilator nie powinien zakładać niczego o odczycie, nawet bezpośrednio po zapisie do tego samego ulotnego obiektu:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatilemoże być najważniejszym narzędziem w zestawie narzędzi "C to język asemblera wysokiego poziomu" .

To, czy zadeklarowanie obiektu jako ulotnego jest wystarczające do zapewnienia zachowania kodu, który obsługuje zmiany asynchroniczne, zależy od platformy: różne procesory zapewniają różne poziomy gwarantowanej synchronizacji dla normalnych odczytów i zapisów w pamięci. Prawdopodobnie nie powinieneś próbować pisać kodu wielowątkowego niskiego poziomu, chyba że jesteś ekspertem w tej dziedzinie.

Atomowe prymitywy zapewniają ładny, wyższy poziom widoku obiektów do wielowątkowości, co ułatwia wnioskowanie o kodzie. Prawie wszyscy programiści powinni używać albo atomowych prymitywów, albo prymitywów, które zapewniają wzajemne wykluczenia, takie jak muteksy, blokady odczytu i zapisu, semafory lub inne prymitywy blokujące.

ciekawy facet
źródło