Ogólnie rzecz biorąc, for int num
, num++
(lub ++num
), jako operacja odczytu-modyfikacji-zapisu, nie jest atomowa . Ale często widzę kompilatory, na przykład GCC , generują dla niego następujący kod ( spróbuj tutaj ):
Ponieważ wiersz 5, który odpowiada, num++
jest jedną instrukcją, czy możemy wywnioskować, że w tym przypadku num++
jest atomowa ?
A jeśli tak, to czy oznacza to, że tak wygenerowane num++
można wykorzystać w scenariuszach współbieżnych (wielowątkowych) bez niebezpieczeństwa wyścigów danych (tj. Nie musimy tego robić na przykład std::atomic<int>
i nakładać związanych z tym kosztów, ponieważ jest to atomic w każdym razie)?
AKTUALIZACJA
Zauważ, że to pytanie nie dotyczy tego, czy przyrost jest atomowy (nie jest i to było i jest pierwszym wierszem pytania). Chodzi o to, czy może to być w określonych scenariuszach, tj. Czy charakter jednej instrukcji można w niektórych przypadkach wykorzystać, aby uniknąć narzutu lock
prefiksu. I, jak wspomina przyjęta odpowiedź w sekcji o maszynach jednoprocesorowych, a także tę odpowiedź , rozmowę w jej komentarzach i innych wyjaśniają, można (chociaż nie w C lub C ++).
add
jest atomowa?std::atomic<int>
.add
instrukcji inny rdzeń mógłby ukraść adres pamięci z pamięci podręcznej tego rdzenia i go zmodyfikować. W przypadku procesora x86add
instrukcja wymagalock
prefiksu, jeśli adres ma być zablokowany w pamięci podręcznej na czas trwania operacji.Odpowiedzi:
To jest absolutnie to, co C ++ definiuje jako wyścig danych, który powoduje niezdefiniowane zachowanie, nawet jeśli zdarzyło się, że jeden kompilator wyprodukował kod, który zrobił to, czego oczekiwałeś na jakiejś maszynie docelowej. Musisz użyć, aby
std::atomic
uzyskać wiarygodne wyniki, ale możesz go użyć,memory_order_relaxed
jeśli nie zależy Ci na zmianie kolejności. Poniżej znajduje się przykładowy kod i dane wyjściowe ASM przy użyciufetch_add
.Ale najpierw część pytania w języku asemblerowym:
Instrukcje miejsca docelowego pamięci (inne niż czyste magazyny) to operacje odczytu, modyfikacji i zapisu, które występują w wielu krokach wewnętrznych . Żaden rejestr architektoniczny nie jest modyfikowany, ale procesor musi przechowywać dane wewnętrznie, gdy wysyła je przez swoją jednostkę ALU . Rzeczywisty plik rejestru to tylko niewielka część pamięci danych wewnątrz nawet najprostszego procesora, z zatrzaskami utrzymującymi wyjścia jednego stopnia jako wejścia dla innego stopnia itp., Itd.
Operacje pamięci z innych procesorów mogą stać się globalnie widoczne między ładowaniem a przechowywaniem. Oznacza to, że dwa wątki działające
add dword [num], 1
w pętli nadepnęłyby na wzajemne sklepy. (Zobacz odpowiedź @ Margaret na ładny diagram). Po 40 tys. Przyrostów z każdego z dwóch wątków licznik mógł wzrosnąć tylko o ~ 60 tys. (Nie 80 tys.) Na prawdziwym wielordzeniowym sprzęcie x86.„Atomowy”, od greckiego słowa oznaczającego niepodzielność, oznacza, że żaden obserwator nie może postrzegać operacji jako oddzielnych kroków. Fizyczne / elektryczne natychmiastowe działanie dla wszystkich bitów jednocześnie jest tylko jednym ze sposobów osiągnięcia tego dla obciążenia lub magazynu, ale nie jest to nawet możliwe w przypadku operacji ALU. O wiele bardziej szczegółowo omówiłem czyste obciążenia i czyste sklepy w mojej odpowiedzi na temat Atomicity na x86 , podczas gdy ta odpowiedź skupia się na odczycie, modyfikacji i zapisie.
lock
Prefix może być stosowany do wielu read-modify-write (docelowym pamięci) instrukcje, aby cała operacja atomowa w odniesieniu do wszystkich możliwych obserwatorów w systemie (innych rdzeni i urządzeń DMA, nie oscyloskop podłączone do pinów procesora). Dlatego istnieje. (Zobacz także te pytania i odpowiedzi ).Tak
lock add dword [num], 1
jest atomowa . Rdzeń procesora uruchamiający tę instrukcję utrzymywałby linię pamięci podręcznej przypiętą w stanie zmodyfikowanym w swojej prywatnej pamięci podręcznej L1 od momentu, gdy ładowanie odczytuje dane z pamięci podręcznej do momentu, gdy sklep zatwierdzi wynik z powrotem do pamięci podręcznej. Zapobiega to posiadaniu przez jakąkolwiek inną pamięć podręczną w systemie kopii linii pamięci podręcznej w dowolnym momencie od załadowania do przechowywania, zgodnie z zasadami protokołu spójności pamięci podręcznej MESI (lub jego wersjami MOESI / MESIF używanymi przez wielordzeniowe AMD / Odpowiednio procesory Intel). W ten sposób wydaje się, że operacje wykonywane przez inne rdzenie mają miejsce przed lub po, a nie w trakcie.Bez
lock
prefiksu inny rdzeń mógłby przejąć na własność linię pamięci podręcznej i zmodyfikować ją po naszym załadowaniu, ale przed naszym sklepem, tak aby inny sklep stał się globalnie widoczny między naszym ładowaniem a sklepem. Kilka innych odpowiedzi jest błędnych i twierdzi, że bezlock
otrzymywania sprzecznych kopii tej samej linii pamięci podręcznej. To nigdy nie może się zdarzyć w systemie ze spójnymi pamięciami podręcznymi.(Jeśli
lock
instrukcja ed działa w pamięci, która obejmuje dwie linie pamięci podręcznej, dużo więcej pracy wymaga upewnienie się, że zmiany w obu częściach obiektu pozostaną atomowe w miarę ich propagowania do wszystkich obserwatorów, aby żaden obserwator nie mógł zobaczyć zerwania. Procesor może trzeba zablokować całą magistralę pamięci, aż dane trafią do pamięci. Nie wyrównaj zmiennych atomowych!)Zauważ, że
lock
prefiks również zamienia instrukcję w pełną barierę pamięci (jak MFENCE ), zatrzymując wszelkie zmiany kolejności w czasie wykonywania, a tym samym zapewniając sekwencyjną spójność. (Zobacz doskonały post na blogu Jeffa Preshinga . Jego pozostałe posty też są doskonałe i jasno wyjaśniają wiele dobrych rzeczy na temat programowania bez blokad , od x86 i innych szczegółów dotyczących sprzętu po reguły C ++.)Na maszynie jednoprocesorowej lub w procesie jednowątkowym pojedyncza instrukcja RMW jest w rzeczywistości niepodzielna bez
lock
przedrostka. Jedynym sposobem na uzyskanie dostępu do wspólnej zmiennej przez inny kod jest wykonanie przez procesor przełączania kontekstu, co nie może się zdarzyć w środku instrukcji. Tak więc zwykłydec dword [num]
może synchronizować się między programem jednowątkowym a jego programami obsługi sygnału lub w programie wielowątkowym działającym na maszynie jednordzeniowej. Zobacz drugą połowę mojej odpowiedzi na inne pytanie i komentarze pod nim, gdzie wyjaśniam to bardziej szczegółowo.Powrót do C ++:
Jest to całkowicie fałszywe w użyciu
num++
bez mówienia kompilatorowi, że potrzebujesz go do kompilacji do pojedynczej implementacji odczytu, modyfikacji i zapisu:Jest to bardzo prawdopodobne, jeśli użyjesz wartości
num
później: kompilator zachowa ją w rejestrze po zwiększeniu. Więc nawet jeśli sprawdzisz, jaknum++
kompiluje się samodzielnie, zmiana otaczającego kodu może na to wpłynąć.(Jeśli wartość nie jest potrzebna później,
inc dword [num]
jest preferowana; nowoczesne procesory x86 będą uruchamiać instrukcję RMW przeznaczoną dla pamięci co najmniej tak wydajnie, jak przy użyciu trzech oddzielnych instrukcji. Ciekawostka:gcc -O3 -m32 -mtune=i586
faktycznie wyemituje to , ponieważ superskalarny potok (Pentium) P5 nie zadziałał „t dekodowania skomplikowane instrukcje do wielu prostych mikro działań, których sposób P6 i później microarchitectures zrobić. Patrz instrukcja tabele / przewodnik mikroarchitektury Agner mgła za uzyskać więcej informacji, ax86 tag wiki dla wielu przydatnych linków (w tym podręczniki Intel x86 ISA, które są dostępne bezpłatnie w formacie PDF).Nie należy mylić docelowego modelu pamięci (x86) z modelem pamięci C ++
Dozwolona jest zmiana kolejności w czasie kompilacji . Inną częścią tego, co otrzymujesz dzięki std :: atomic, jest kontrola nad zmianą kolejności w czasie kompilacji, aby upewnić się, że stanie
num++
się globalnie widoczne dopiero po wykonaniu innej operacji.Klasyczny przykład: przechowywanie niektórych danych w buforze, aby inny wątek mógł je obejrzeć, a następnie ustawienie flagi. Mimo że x86 pobiera magazyny ładunków / wydań za darmo, nadal musisz powiedzieć kompilatorowi, aby nie zmieniał kolejności za pomocą
flag.store(1, std::memory_order_release);
.Można się spodziewać, że ten kod zostanie zsynchronizowany z innymi wątkami:
Ale tak się nie stanie. Kompilator może swobodnie przesuwać
flag++
wywołanie funkcji (jeśli wstawia funkcję lub wie, że nie patrzyflag
). Wtedy może całkowicie zoptymalizować modyfikację, ponieważflag
nie jest równavolatile
. (I nie, C ++volatile
nie jest użytecznym substytutem std :: atomowej. Std :: atomowy robi kompilator założyć, że wartości w pamięci mogą być modyfikowane w sposób asynchroniczny podobny dovolatile
, ale jest o wiele więcej niż tylko to. Ponadto,volatile std::atomic<int> foo
jest nie to samo costd::atomic<int> foo
omówiono z @Richardem Hodgesem.)Zdefiniowanie wyścigów danych na zmiennych nieatomowych jako niezdefiniowane zachowanie pozwala kompilatorowi nadal podnosić ładunki i składować ujścia poza pętle, a także wiele innych optymalizacji pamięci, do których może mieć odniesienie wiele wątków. (Zobacz ten blog LLVM, aby uzyskać więcej informacji o tym, jak UB umożliwia optymalizacje kompilatora).
Jak wspomniałem, prefiks x86
lock
jest pełną barierą pamięci, więc użycienum.fetch_add(1, std::memory_order_relaxed);
generuje ten sam kod na x86 conum++
(domyślnie jest to spójność sekwencyjna), ale może być znacznie bardziej wydajne na innych architekturach (takich jak ARM). Nawet na x86, relaxed pozwala na dłuższą zmianę kolejności w czasie kompilacji.To właśnie robi GCC na x86, dla kilku funkcji, które działają na
std::atomic
zmiennej globalnej.Zobacz kod źródłowy + język asemblera ładnie sformatowany w eksploratorze kompilatora Godbolt . Możesz wybrać inne architektury docelowe, w tym ARM, MIPS i PowerPC, aby zobaczyć, jaki rodzaj kodu języka asemblerowego otrzymasz od atomics dla tych celów.
Zwróć uwagę, jak MFENCE (pełna bariera) jest potrzebna po magazynach o sekwencyjnej spójności. x86 jest ogólnie mocno uporządkowany, ale zmiana kolejności StoreLoad jest dozwolona. Posiadanie bufora magazynu jest niezbędne dla dobrej wydajności na niedziałającym potokowo procesorze. Jeff Preshing's Memory Reordering Caught in the Act pokazuje konsekwencje nieużywania MFENCE, z prawdziwym kodem pokazującym zmianę kolejności zachodzącą na prawdziwym sprzęcie.
Re: dyskusja w komentarzach do odpowiedzi @Richarda Hodgesa na temat kompilatorów łączących
num++; num-=2;
operacje std :: atomic w jednąnum--;
instrukcję :Oddzielne pytania i odpowiedzi na ten sam temat: Dlaczego kompilatory nie łączą redundantnych zapisów std :: atomic? , gdzie moja odpowiedź przedstawia wiele z tego, co napisałem poniżej.
Obecne kompilatory tak naprawdę tego nie robią (jeszcze), ale nie dlatego, że nie mają na to pozwolenia. C ++ WG21 / P0062R1: Kiedy kompilatory powinny optymalizować atomiki? omawia oczekiwanie, które wielu programistów ma, że kompilatory nie będą dokonywać „zaskakujących” optymalizacji, oraz co może zrobić standard, aby dać programistom kontrolę. N4455 omawia wiele przykładów rzeczy, które można zoptymalizować, w tym ten. Wskazuje, że inlining i stała propagacja może wprowadzić rzeczy,
fetch_or(0)
które mogą być w stanie przekształcić się w zwykłeload()
(ale nadal ma semantykę pozyskiwania i uwalniania), nawet jeśli oryginalne źródło nie miało żadnych oczywiście zbędnych atomowych operacji.Prawdziwe powody, dla których kompilatory tego nie robią (jeszcze) to: (1) nikt nie napisał skomplikowanego kodu, który pozwoliłby kompilatorowi zrobić to bezpiecznie (bez pomyłki) oraz (2) potencjalnie narusza zasadę najmniejszego niespodzianka . Przede wszystkim kod bez blokady jest wystarczająco trudny do prawidłowego napisania. Dlatego nie bądź swobodny w używaniu broni atomowej: nie są one tanie i nie optymalizują zbyt wiele.
std::shared_ptr<T>
Jednak nie zawsze łatwo jest uniknąć zbędnych operacji atomowych , ponieważ nie ma ich nieatomowej wersji (chociaż jedna z odpowiedzi tutaj daje łatwy sposób zdefiniowania ashared_ptr_unsynchronized<T>
dla gcc).Wracając do
num++; num-=2;
kompilacji tak, jakby to byłonum--
: kompilatorom wolno to robić, chyba żenum
jestvolatile std::atomic<int>
. Jeśli zmiana kolejności jest możliwa, reguła as-if pozwala kompilatorowi zdecydować w czasie kompilacji, że zawsze dzieje się to w ten sposób. Nic nie gwarantuje, że obserwator będzie mógł zobaczyć wartości pośrednie (num++
wynik).To znaczy, jeśli kolejność, w której nic nie staje się globalnie widoczne między tymi operacjami, jest zgodna z wymaganiami porządkowania źródła (zgodnie z regułami C ++ dla maszyny abstrakcyjnej, a nie architektury docelowej), kompilator może emitować pojedynczy
lock dec dword [num]
zamiastlock inc dword [num]
/lock sub dword [num], 2
.num++; num--
nie może zniknąć, ponieważ nadal ma relację Synchronizuje z innymi wątkami, na które patrząnum
, i jest to zarówno pobieranie, jak i magazyn wersji, co uniemożliwia zmianę kolejności innych operacji w tym wątku. W przypadku x86 może to być możliwe do skompilowania do MFENCE zamiastlock add dword [num], 0
(tjnum += 0
.).Jak omówiono w PR0062 , bardziej agresywne łączenie niesąsiadujących atomowych operacji w czasie kompilacji może być złe (np. Licznik postępu jest aktualizowany tylko raz na końcu zamiast każdej iteracji), ale może również pomóc w wydajności bez wad (np. Pomijanie atomic inc / dec of ref liczy się, gdy kopia a
shared_ptr
jest tworzona i niszczona, jeśli kompilator może udowodnić, że innyshared_ptr
obiekt istnieje przez cały czas życia tymczasowego.)Nawet
num++; num--
scalanie może zaszkodzić uczciwości implementacji blokady, gdy jeden wątek zostanie natychmiast odblokowany i ponownie zablokowany. Jeśli w rzeczywistości nigdy nie zostanie wydany w asm, nawet mechanizmy arbitrażu sprzętowego nie dadzą innemu wątkowi szansy na złapanie blokady w tym momencie.Z obecnymi gcc6.2 i clang3.9, nadal otrzymujesz oddzielne
lock
operacje ed nawetmemory_order_relaxed
w przypadku najbardziej oczywistej optymalizacji. ( Eksplorator kompilatora Godbolt, dzięki czemu możesz sprawdzić, czy najnowsze wersje są różne).źródło
mov eax, 1
xadd [num], eax
(bez przedrostka blokady) do zaimplementowania post-inkrementacjinum++
, ale to nie jest to, co robią kompilatory.... a teraz włączmy optymalizacje:
OK, dajmy temu szansę:
wynik:
inny wątek obserwujący (nawet ignorujący opóźnienia synchronizacji pamięci podręcznej) nie ma możliwości obserwowania poszczególnych zmian.
porównać do:
gdzie wynik to:
Teraz każda modyfikacja to: -
Atomowość dotyczy nie tylko poziomu instrukcji, ale obejmuje cały potok, od procesora, przez pamięci podręczne, do pamięci iz powrotem.
Dalsze informacje
Jeśli chodzi o efekt optymalizacji aktualizacji
std::atomic
s.Standard c ++ ma regułę `` jak gdyby '', zgodnie z którą kompilator może zmienić kolejność kodu, a nawet przepisać kod, pod warunkiem, że wynik ma dokładnie takie same obserwowalne efekty (w tym skutki uboczne), jak gdyby po prostu wykonał twój kod.
Reguła as-if jest konserwatywna, szczególnie dotyczy atomów.
rozważać:
Ponieważ nie ma blokad mutexów, atomów ani żadnych innych konstrukcji, które wpływają na sekwencjonowanie między wątkami, argumentowałbym, że kompilator może przepisać tę funkcję jako NOP, np .:
Dzieje się tak, ponieważ w modelu pamięci c ++ nie ma możliwości obserwowania wyniku inkrementacji przez inny wątek. Byłoby oczywiście inaczej, gdyby tak
num
byłovolatile
(mogłoby to wpłynąć na zachowanie sprzętu). Ale w tym przypadku ta funkcja będzie jedyną funkcją modyfikującą tę pamięć (w przeciwnym razie program będzie źle sformułowany).To jednak inna gra w piłkę:
num
jest atomem. Zmiany w nim muszą być widoczne dla innych obserwowanych wątków. Zmiany dokonane przez te wątki (takie jak ustawienie wartości na 100 między przyrostem a zmniejszeniem) będą miały bardzo daleko idący wpływ na ostateczną wartość num.Oto demo:
przykładowe wyjście:
źródło
add dword [rdi], 1
jest atomowa (bez przedrostka). Obciążenie jest atomowe, a sklep jest atomowy, ale nic nie powstrzymuje innego wątku przed modyfikacją danych między ładunkiem a magazynem. Sklep może więc przejść na modyfikację dokonaną przez inny wątek. Zobacz jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Poza tym artykuły Jeffa Preshinga bez blokad są bardzo dobre i wspomina o podstawowym problemie RMW w tym artykule wprowadzającym.lock
num++
anum--
. Jeśli znajdziesz sekcję w standardzie, która tego wymaga, załatwi to. Jestem prawie pewien, że wymaga to tylko, aby żaden obserwator nigdy nie zauważył niewłaściwego zmiany kolejności, co nie wymaga tam wydajności. Myślę więc, że to tylko kwestia jakości wykonania.Bez wielu komplikacji instrukcja
add DWORD PTR [rbp-4], 1
jest bardzo podobna do CISC.Wykonuje trzy operacje: ładuje operand z pamięci, inkrementuje go, zapisuje operand z powrotem do pamięci.
Podczas tych operacji procesor dwukrotnie pobiera i zwalnia magistralę, a każdy inny agent może ją zdobyć, co narusza atomowość.
Wartość X jest zwiększana tylko raz.
źródło
Instrukcja add nie jest atomowa. Odwołuje się do pamięci, a dwa rdzenie procesora mogą mieć inną lokalną pamięć podręczną tej pamięci.
IIRC atomowy wariant instrukcji add nazywa się lock xadd
źródło
lock xadd
implementuje C ++ std :: atomicfetch_add
, zwracając starą wartość. Jeśli tego nie potrzebujesz, kompilator użyje normalnych instrukcji miejsca docelowego pamięci zlock
prefiksem.lock add
lublock inc
.add [mem], 1
nadal nie byłby atomowy na maszynie SMP bez pamięci podręcznej, zobacz moje komentarze na temat innych odpowiedzi.Wyciąganie wniosków na podstawie montażu generowanego w ramach „inżynierii odwrotnej” jest niebezpieczne. Na przykład wydaje się, że skompilowałeś swój kod z wyłączoną optymalizacją, w przeciwnym razie kompilator wyrzuciłby tę zmienną lub załadował 1 bezpośrednio do niej bez wywoływania
operator++
. Ponieważ wygenerowany zespół może się znacznie zmienić w oparciu o flagi optymalizacji, docelowy procesor itp., Twój wniosek opiera się na piasku.Również twój pomysł, że jedna instrukcja asemblera oznacza, że operacja jest atomowa, również jest błędny. Nie
add
będzie to atomowe w systemach wieloprocesorowych, nawet w architekturze x86.źródło
Nawet jeśli twój kompilator zawsze emitował to jako operację atomową, jednoczesne uzyskiwanie dostępu
num
z dowolnego innego wątku stanowiłoby wyścig danych zgodnie ze standardami C ++ 11 i C ++ 14, a program miałby niezdefiniowane zachowanie.Ale to jest gorsze. Po pierwsze, jak już wspomniano, instrukcja generowana przez kompilator podczas zwiększania wartości zmiennej może zależeć od poziomu optymalizacji. Po drugie, kompilator może zmienić kolejność innych dostępów do pamięci,
++num
jeślinum
nie jest atomowa, npNawet jeśli optymistycznie założymy, że
++ready
jest to „atomowe” i że kompilator generuje pętlę kontrolną w razie potrzeby (jak powiedziałem, jest to UB i dlatego kompilator może go usunąć, zastąpić nieskończoną pętlą itp.), kompilator może nadal przesuwać przypisanie wskaźnika lub, co gorsza, inicjowanievector
do punktu po operacji inkrementacji, powodując chaos w nowym wątku. W praktyce nie zdziwiłbym się wcale, gdyby optymalizujący kompilatorready
całkowicie usunął zmienną i pętlę kontrolną, ponieważ nie wpływa to na obserwowalne zachowanie zgodnie z regułami języka (w przeciwieństwie do twoich prywatnych nadziei).W rzeczywistości na zeszłorocznej konferencji Meeting C ++ usłyszałem od dwóch programistów kompilatorów, że bardzo chętnie wdrażają optymalizacje, które powodują, że naiwnie napisane programy wielowątkowe źle zachowują się, o ile pozwalają na to reguły językowe, jeśli zauważono nawet niewielką poprawę wydajności w poprawnie napisanych programach.
Wreszcie, nawet jeśli nie dbałeś o przenośność, a twój kompilator był magicznie fajny, procesor, którego używasz, jest najprawdopodobniej superskalarnym typem CISC i rozbije instrukcje na mikrooperacje, zmieni kolejność i / lub spekulacyjnie je wykona, w stopniu ograniczonym jedynie przez synchronizację elementów podstawowych, takich jak (na platformie Intel)
LOCK
prefiks lub ograniczenia pamięci, w celu maksymalizacji operacji na sekundę.Krótko mówiąc, naturalne obowiązki programowania bezpiecznego dla wątków to:
Jeśli chcesz to zrobić na swój sposób, może to po prostu zadziałać w niektórych przypadkach, ale pamiętaj, że gwarancja jest nieważna i będziesz ponosić wyłączną odpowiedzialność za wszelkie niepożądane skutki. :-)
PS: Poprawnie napisany przykład:
Jest to bezpieczne, ponieważ:
ready
nie można zoptymalizować zgodnie z regułami językowymi.++ready
przed sprawdzeniem, które nie widziready
zera i nie można zmienić kolejności innych operacji wokół tych operacji. Dzieje się tak, ponieważ++ready
i sprawdzenie są sekwencyjnie spójne , co jest innym terminem opisanym w modelu pamięci C ++ i zabrania tej konkretnej zmiany kolejności. Dlatego kompilator nie może zmieniać kolejności instrukcji, a także musi powiedzieć procesorowi, że nie może np. Odkładać zapisuvec
po inkrementacjiready
. Sekwencyjna spójność jest najsilniejszą gwarancją dotyczącą atomiki w standardzie językowym. Mniejsze (i teoretycznie tańsze) gwarancje są dostępne np. Innymi metodamistd::atomic<T>
, ale są one zdecydowanie przeznaczone tylko dla ekspertów i mogą nie być zbytnio optymalizowane przez programistów kompilatorów, ponieważ są rzadko używane.źródło
ready
, prawdopodobnie skompilowałby sięwhile (!ready);
w coś bardziej podobnegoif(!ready) { while(true); }
. Upvoted: kluczową częścią std :: atomic jest zmiana semantyki, aby zakładać asynchroniczną modyfikację w dowolnym momencie. Zwykle posiadanie UB umożliwia kompilatorom podnoszenie ładunków i zrzucanie zapasów z pętli.Na jednordzeniowej maszynie x86
add
instrukcja będzie generalnie niepodzielna w stosunku do innego kodu na CPU 1 . Przerwanie nie może rozdzielić pojedynczej instrukcji w dół.Wykonywanie poza kolejnością jest wymagane, aby zachować iluzję instrukcji wykonywanych pojedynczo w ramach jednego rdzenia, więc każda instrukcja uruchomiona na tym samym procesorze będzie wykonywana całkowicie przed lub całkowicie po dodaniu.
Nowoczesne systemy x86 są wielordzeniowe, więc specjalny przypadek jednoprocesorowy nie ma zastosowania.
Jeśli ktoś ma na celu mały wbudowany komputer i nie planuje przenieść kodu na cokolwiek innego, można wykorzystać atomową naturę instrukcji „add”. Z drugiej strony platformy, na których operacje są z natury atomowe, stają się coraz rzadsze.
(To nie pomaga, jeśli piszesz w C ++, choć. Kompilatory nie ma opcji, aby wymagać
num++
skompilować do pamięci docelowego dodatku lub xadd bez pomocąlock
prefiksu. Mogli wybrać, aby załadowaćnum
do rejestru i przechowywać przyrost wyniku za pomocą oddzielnej instrukcji i prawdopodobnie zrobi to, jeśli użyjesz wyniku.)Przypis 1:
lock
Prefiks istniał nawet w oryginalnym 8086, ponieważ urządzenia we / wy działają jednocześnie z procesorem; sterowniki w systemie jednordzeniowym musząlock add
atomowo zwiększać wartość w pamięci urządzenia, jeśli urządzenie może ją również modyfikować, lub w odniesieniu do dostępu DMA.źródło
W czasach, gdy komputery x86 miały jeden procesor, użycie pojedynczej instrukcji zapewniało, że przerwania nie dzielą odczytu / modyfikacji / zapisu, a jeśli pamięć nie byłaby używana również jako bufor DMA, w rzeczywistości była atomowa (i C ++ nie wspomina o wątkach w standardzie, więc nie zostało to rozwiązane).
Kiedy rzadko zdarzało się mieć podwójny procesor (np. Dwugniazdowy Pentium Pro) na komputerze klienta, skutecznie użyłem tego, aby uniknąć przedrostka LOCK na komputerze jednordzeniowym i poprawić wydajność.
Dzisiaj pomogłoby to tylko w przypadku wielu wątków, które były ustawione na to samo koligacje procesora, więc wątki, o które się martwisz, wejdą w grę tylko po wygaśnięciu przedziału czasu i uruchomieniu drugiego wątku na tym samym procesorze (rdzeniu). To nie jest realistyczne.
W nowoczesnych procesorach x86 / x64 pojedyncza instrukcja jest dzielona na kilka mikrooperacji, a ponadto odczytywanie i zapisywanie w pamięci jest buforowane. Tak więc różne wątki działające na różnych procesorach nie tylko będą postrzegać to jako nieatomowe, ale mogą zobaczyć niespójne wyniki dotyczące tego, co odczytuje z pamięci i co zakłada, że inne wątki przeczytały do tego momentu: musisz dodać ogrodzenia pamięci, aby przywrócić rozsądek zachowanie.
źródło
a = 1; b = a;
aby poprawnie załadować 1, który właśnie zapisałeś.Nie. Https://www.youtube.com/watch?v=31g0YE61PLQ (to tylko link do sceny „Nie” z „Biura”)
Czy zgadzasz się, że byłby to możliwy wynik dla programu:
przykładowe wyjście:
Jeśli tak, to kompilator może uczynić to jedyne możliwe wyjście programu, w dowolny sposób kompilator. tj. main (), który właśnie wystawia 100.
To jest zasada „jak gdyby”.
I niezależnie od danych wyjściowych, możesz myśleć o synchronizacji wątków w ten sam sposób - jeśli wątek A tak robi,
num++; num--;
a wątek B czytanum
wielokrotnie, to możliwe prawidłowe przeplatanie polega na tym, że wątek B nigdy nie czyta międzynum++
inum--
. Ponieważ to przeplatanie jest ważne, kompilator może uczynić go jedynym możliwym przeplotem. I po prostu całkowicie usuń incr / decr.Istnieje kilka interesujących konsekwencji:
(tj. wyobraź sobie, że inny wątek aktualizuje interfejs paska postępu na podstawie
progress
)Czy kompilator może zamienić to na:
prawdopodobnie to jest ważne. Ale chyba nie to, na co liczył programista :-(
Komisja nadal pracuje nad tym. Obecnie "działa", ponieważ kompilatory nie optymalizują zbytnio atomów. Ale to się zmienia.
I nawet gdyby
progress
był niestabilny, nadal byłby ważny:: - /
źródło
volatile
obiektów atomowych, jeśli nie łamie żadnych innych reguł. Dwa dokumenty do dyskusji o standardach omawiają to dokładnie (linki w komentarzu Richarda ), z których jeden używa tego samego przykładu licznika postępu. Jest to więc problem z jakością implementacji, dopóki C ++ nie ustandaryzuje sposobów zapobiegania temu.lock
do każdej operacji. Lub jakaś kombinacja kompilator + jednoprocesorowa, w której zmiana kolejności (tj. „Stare dobre czasy”) nie jest możliwa, wszystko jest atomowe. Ale po co to wszystko? Nie możesz na tym polegać. Chyba że wiesz, że to system, dla którego piszesz. (Nawet wtedy byłoby lepiej, gdyby atomic <int> nie dodawał żadnych dodatkowych operacji do tego systemu. Więc nadal powinieneś pisać standardowy kod ...)And just remove the incr/decr entirely.
to nie jest całkiem w porządku. Nadal trwa operacja nabycia i zwolnienianum
. Na x86num++;num--
można skompilować tylko do MFENCE, ale na pewno nie do niczego. (Chyba że analiza całego programu kompilatora może udowodnić, że nic nie synchronizuje się z tą modyfikacją num i że nie ma znaczenia, czy niektóre sklepy sprzed tego czasu są opóźnione do późniejszego załadowania.) Np. Jeśli to było odblokowanie i ponowne -lock-right-away-case, nadal masz dwie oddzielne krytyczne sekcje (być może używając mo_relaxed), a nie jedną dużą.Tak ale...
Atomic nie jest tym, co chciałeś powiedzieć. Prawdopodobnie pytasz o coś złego.
Przyrost jest z pewnością atomowy . O ile pamięć nie jest źle wyrównana (a ponieważ pozostawiłeś wyrównanie do kompilatora, tak nie jest), jest koniecznie wyrównana w jednej linii pamięci podręcznej. Oprócz specjalnych instrukcji przesyłania strumieniowego, które nie są buforowane, każdy zapis przechodzi przez pamięć podręczną. Kompletne wiersze pamięci podręcznej są odczytywane i zapisywane atomowo, nigdy nic innego.
Dane mniejsze niż pamięć podręczna są oczywiście również zapisywane niepodzielnie (ponieważ otaczająca linia pamięci podręcznej jest).
Czy to jest bezpieczne dla wątków?
To jest inne pytanie i są co najmniej dwa dobre powody, aby odpowiedzieć jednoznacznym „Nie!” .
Po pierwsze, istnieje możliwość, że inny rdzeń może mieć kopię tej linii pamięci podręcznej w L1 (L2 i nowsze są zwykle współdzielone, ale L1 jest zwykle na rdzeń!) I jednocześnie modyfikuje tę wartość. Oczywiście dzieje się to również atomowo, ale teraz masz dwie „poprawne” (poprawnie, atomowo, zmodyfikowane) wartości - która z nich jest teraz prawdziwie poprawna?
Oczywiście procesor jakoś to rozwiąże. Ale wynik może nie być taki, jakiego oczekujesz.
Po drugie, istnieje porządkowanie pamięci lub inaczej sformułowane - zanim gwarancje. Najważniejszą rzeczą w instrukcjach atomowych nie jest to, że są one atomowe . Zamawia.
Masz możliwość egzekwowania gwarancji, że wszystko, co dzieje się pod względem pamięci, jest realizowane w jakiejś gwarantowanej, dobrze zdefiniowanej kolejności, w której masz gwarancję „wydarzyło się wcześniej”. Porządkowanie to może być tak „rozluźnione” (czytaj: brak w ogóle) lub tak surowe, jak potrzebujesz.
Na przykład, możesz ustawić wskaźnik na jakiś blok danych (powiedzmy, wyniki niektórych obliczeń), a następnie atomowo zwolnić flagę „dane są gotowe”. Teraz, ktokolwiek zdobędzie tę flagę, będzie sądził, że wskaźnik jest ważny. I rzeczywiście, zawsze będzie to ważny wskaźnik, nigdy nic innego. Dzieje się tak, ponieważ zapis do wskaźnika miał miejsce przed operacją atomową.
źródło
Że produkcja pojedynczego kompilator, na architekturze specyficzny procesora, z optymalizacje niepełnosprawnych (od gcc nawet nie skompilować
++
sięadd
przy optymalizacji w szybki i brudny przykład ), wydaje się sugerować, zwiększając w ten sposób jest atomowa nie oznacza to zgodny ze standardami ( spowodowałbyś niezdefiniowane zachowanie podczas próby dostępunum
w wątku), i tak czy inaczej jest błędny, ponieważ nieadd
jest atomowy w x86.Zauważ, że atomics (używając
lock
przedrostka instrukcji) są stosunkowo ciężkie na x86 ( zobacz tę odpowiednią odpowiedź ), ale nadal znacznie mniej niż mutex, co nie jest zbyt odpowiednie w tym przypadku użycia.Następujące wyniki pochodzą z clang ++ 3.8 podczas kompilacji z
-Os
.Zwiększanie liczby int przez odwołanie, „zwykły” sposób:
To kompiluje się w:
Zwiększanie liczby int przekazanej przez odniesienie, metodą atomową:
Ten przykład, który nie jest dużo bardziej skomplikowany niż zwykły sposób, po prostu otrzymuje
lock
przedrostek dodany doincl
instrukcji - ale ostrożnie, jak wcześniej stwierdzono, nie jest to tanie. Tylko dlatego, że montaż wygląda na krótki, nie oznacza, że jest szybki.źródło
Gdy Twój kompilator używa tylko jednej instrukcji do inkrementacji, a Twoja maszyna jest jednowątkowa, kod jest bezpieczny. ^^
źródło
Spróbuj skompilować ten sam kod na maszynie innej niż x86, a szybko zobaczysz bardzo różne wyniki asemblacji.
Przyczyna
num++
wydaje się być atomowa, ponieważ na maszynach x86 inkrementacja 32-bitowej liczby całkowitej jest w rzeczywistości atomowa (zakładając, że nie ma miejsca w pamięci). Ale nie jest to ani gwarantowane przez standard c ++, ani nie jest prawdopodobne w przypadku maszyny, która nie używa zestawu instrukcji x86. Tak więc ten kod nie jest bezpieczny dla wielu platform przed warunkami wyścigu.Nie masz również silnej gwarancji, że ten kod jest bezpieczny przed warunkami wyścigu, nawet w architekturze x86, ponieważ x86 nie konfiguruje ładowania i nie przechowuje w pamięci, chyba że zostanie to specjalnie poinstruowane. Jeśli więc wiele wątków próbowało jednocześnie zaktualizować tę zmienną, mogą one w rezultacie zwiększać wartości zapisane w pamięci podręcznej (nieaktualne)
Powód, dla którego mamy
std::atomic<int>
i tak dalej, jest taki, że kiedy pracujesz z architekturą, w której atomowość podstawowych obliczeń nie jest gwarantowana, masz mechanizm, który zmusi kompilator do wygenerowania kodu atomowego.źródło
add
rzeczywiście gwarantowane? Nie zdziwiłbym się, gdyby przyrosty rejestrów były atomowe, ale to mało przydatne; aby przyrost rejestru był widoczny dla innego wątku, musi on znajdować się w pamięci, co wymagałoby dodatkowych instrukcji, aby go załadować i zapisać, usuwając atomowość. Rozumiem, że właśnie dlategolock
przedrostek istnieje dla instrukcji; jedyny użyteczny atomicadd
dotyczy pamięci wyłuskanej i używalock
przedrostka, aby zapewnić zablokowanie linii pamięci podręcznej na czas trwania operacji .add
jest atomowy, ale wyjaśniłem, że nie oznacza to, że kod jest bezpieczny w warunkach wyścigu, ponieważ zmiany nie stają się od razu widoczne na całym świecie.