Czy num ++ może być atomowe dla „int num”?


153

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 ):

Tutaj wprowadź opis obrazu

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 lockprefiksu. 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 ++).


65
Kto ci powiedział, że addjest atomowa?
Slava

6
biorąc pod uwagę, że jedną z cech atomiki jest zapobieganie określonym rodzajom zmiany kolejności podczas optymalizacji, nie, niezależnie od atomowości rzeczywistej operacji
jaggedSpire

19
Chciałbym również zwrócić uwagę, że jeśli jest to atomic na twojej platformie, nie ma gwarancji, że będzie na innym pltaformie. Bądź niezależny od platformy i wyrażaj swoje zamiary za pomocą pliku std::atomic<int>.
NathanOliver

8
Podczas wykonywania tej addinstrukcji inny rdzeń mógłby ukraść adres pamięci z pamięci podręcznej tego rdzenia i go zmodyfikować. W przypadku procesora x86 addinstrukcja wymaga lockprefiksu, jeśli adres ma być zablokowany w pamięci podręcznej na czas trwania operacji.
David Schwartz,

21
Możliwe jest, że każda operacja będzie „atomowa”. Wszystko, co musisz zrobić, to mieć szczęście i nigdy nie zdarzyć się, że wykonasz coś, co ujawniłoby, że nie jest atomowe. Atomic jest wartościowy tylko jako gwarancja . Biorąc pod uwagę, że patrzysz na kod asemblera, pytanie brzmi, czy ta konkretna architektura zapewnia gwarancję i czy kompilator zapewnia gwarancję, że jest to implementacja na poziomie zestawu, którą wybrał.
Cort Ammon,

Odpowiedzi:


197

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::atomicuzyskać wiarygodne wyniki, ale możesz go użyć, memory_order_relaxedjeśli nie zależy Ci na zmianie kolejności. Poniżej znajduje się przykładowy kod i dane wyjściowe ASM przy użyciu fetch_add.


Ale najpierw część pytania w języku asemblerowym:

Ponieważ num ++ jest jedną instrukcją ( add dword [num], 1), czy możemy stwierdzić, że num ++ jest w tym przypadku niepodzielna?

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], 1w 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.

lockPrefix 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 lockprefiksu 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 bez lockotrzymywania 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 lockinstrukcja 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 lockprefiks 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 lockprzedrostka. 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ły dec 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:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

Jest to bardzo prawdopodobne, jeśli użyjesz wartości numpóźniej: kompilator zachowa ją w rejestrze po zwiększeniu. Więc nawet jeśli sprawdzisz, jak num++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=i586faktycznie 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, a 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 stanienum++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:

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

Ale tak się nie stanie. Kompilator może swobodnie przesuwać flag++wywołanie funkcji (jeśli wstawia funkcję lub wie, że nie patrzy flag). Wtedy może całkowicie zoptymalizować modyfikację, ponieważ flagnie jest równa volatile. (I nie, C ++ volatilenie 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 do volatile, ale jest o wiele więcej niż tylko to. Ponadto, volatile std::atomic<int> foojest nie to samo co std::atomic<int> fooomó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 x86lock jest pełną barierą pamięci, więc użycie num.fetch_add(1, std::memory_order_relaxed);generuje ten sam kod na x86 co num++(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::atomiczmiennej 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.

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.

# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

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łe load()(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 a shared_ptr_unsynchronized<T>dla gcc).


Wracając do num++; num-=2;kompilacji tak, jakby to było num--: kompilatorom wolno to robić, chyba że numjest volatile 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]zamiast lock 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 zamiast lock add dword [num], 0(tj num += 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_ptrjest tworzona i niszczona, jeśli kompilator może udowodnić, że inny shared_ptrobiekt 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 lockoperacje ed nawet memory_order_relaxedw przypadku najbardziej oczywistej optymalizacji. ( Eksplorator kompilatora Godbolt, dzięki czemu możesz sprawdzić, czy najnowsze wersje są różne).

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret

1
"[używając oddzielnych instrukcji] było bardziej wydajne ... ale nowoczesne procesory x86 ponownie obsługują operacje RMW co najmniej tak samo wydajnie" - nadal jest bardziej wydajne w przypadku, gdy zaktualizowana wartość zostanie wykorzystana później w tej samej funkcji i jest dostępny wolny rejestr, w którym kompilator może go przechowywać (a zmienna nie jest oczywiście oznaczona jako volatile). Oznacza to, że jest wysoce prawdopodobne, że to, czy kompilator wygeneruje jedną instrukcję, czy wiele operacji, zależy od reszty kodu w funkcji, a nie tylko od pojedynczej linii, o której mowa.
Periata Breatta

@PeriataBreatta: tak, słuszna uwaga. W asm możesz użyć mov eax, 1 xadd [num], eax(bez przedrostka blokady) do zaimplementowania post-inkrementacji num++, ale to nie jest to, co robią kompilatory.
Peter Cordes,

3
@ DavidC.Rankin: Jeśli masz jakieś zmiany, które chciałbyś wprowadzić, nie krępuj się. Nie chcę jednak robić tego CW. To nadal moja praca (i mój bałagan: P). Posprzątam trochę po mojej grze Ultimate [frisbee] :)
Peter Cordes

1
Jeśli nie wiki społeczności, to może link na odpowiednim wiki tagu. (zarówno tagi x86, jak i atomowe?). Jest to warte dodatkowego powiązania zamiast pełnego nadziei powrotu przez ogólne wyszukiwanie na SO (Gdybym wiedział lepiej, gdzie to powinno pasować w tym względzie, zrobiłbym to. Będę musiał zagłębić się w to, co należy robić i czego nie robić w tagu wiki linkage)
David C. Rankin

1
Jak zawsze - świetna odpowiedź! Dobre rozróżnienie między spójnością a atomowością (gdzie inni się mylili)
Leeor,

39

... a teraz włączmy optymalizacje:

f():
        rep ret

OK, dajmy temu szansę:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

wynik:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

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:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

gdzie wynik to:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

Teraz każda modyfikacja to: -

  1. widoczne w innym wątku i
  2. szanując podobne modyfikacje zachodzące w innych wątkach.

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::atomics.

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ć:

void incdec(int& num) {
    ++num;
    --num;
}

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 .:

void incdec(int&) {
    // nada
}

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 numbyło volatile(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ę:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

numjest 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:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

przykładowe wyjście:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
To nie wyjaśnia, że nieadd 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
Peter Cordes,

3
Tak naprawdę w tym miejscu nikt nie zaimplementował tej optymalizacji w gcc, ponieważ byłaby ona prawie bezużyteczna i prawdopodobnie bardziej niebezpieczna niż pomocna. (Zasada najmniejszego zaskoczenia. Może ktoś się spodziewa się stan tymczasowy być widoczne czasami, i są ok z probabilty statystycznych. Albo oni za pomocą sprzętowych Watch-punkty przerwania na zmiany). Potrzeby kod blokady wolna być starannie wykonane, więc nie będzie nic do optymalizacji. Warto go poszukać i wydrukować ostrzeżenie, aby ostrzec programistę, że jego kod może nie oznaczać tego, co myślą!
Peter Cordes,

2
Być może jest to powód, dla którego kompilatory tego nie implementują (zasada najmniejszego zaskoczenia i tak dalej). Obserwując, że byłoby to możliwe w praktyce na prawdziwym sprzęcie. Jednak reguły porządkowania pamięci w C ++ nie mówią nic o żadnej gwarancji, że ładowanie jednego wątku jest „równo” mieszane z operacjami innych wątków na abstrakcyjnej maszynie C ++. Nadal uważam, że byłoby to legalne, ale wrogie dla programistów.
Peter Cordes,

2
Eksperyment myślowy: rozważ implementację C ++ we współpracującym systemie wielozadaniowym. Implementuje std :: thread, wstawiając punkty plastyczności tam, gdzie jest to potrzebne, aby uniknąć zakleszczeń, ale nie między każdą instrukcją. Myślę, że można argumentować, że coś w standardzie C ++ wymaga granicy plastyczności między num++a num--. 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.
Peter Cordes,

5
Ze względu na ostateczność zapytałem na liście dyskusyjnej standardowej. To pytanie pojawiło się w 2 artykułach, które wydają się zgadzać z Peterem i dotyczą moich obaw dotyczących takich optymalizacji: wg21.link/p0062 i wg21.link/n4455 Moje podziękowania dla Andy'ego, który zwrócił mi na to uwagę.
Richard Hodges

38

Bez wielu komplikacji instrukcja add DWORD PTR [rbp-4], 1jest 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ść.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

Wartość X jest zwiększana tylko raz.


7
@LeoHeinsaar Aby tak się stało, każdy układ pamięci musiałby posiadać własną jednostkę arytmetyczno-logiczną (ALU). W efekcie wymagałoby to, aby każdy układ pamięci był procesorem.
Richard Hodges,

6
@LeoHeinsaar: instrukcje pamięci-miejsca docelowego są operacjami odczytu, modyfikacji i zapisu. Ż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.
Peter Cordes

@PeterCordes Twój komentarz jest dokładnie odpowiedzią, której szukałem. Odpowiedź Margaret sprawiła, że ​​podejrzewałem, że coś takiego musi się dziać w środku.
Leo Heinsaar

Zamienił ten komentarz w pełną odpowiedź, w tym odnosząc się do C ++ części pytania.
Peter Cordes,

1
@PeterCordes Dzięki, bardzo szczegółowe i we wszystkich punktach. Był to oczywiście wyścig danych, a zatem niezdefiniowane zachowanie w standardzie C ++, byłem po prostu ciekawy, czy w przypadkach, w których wygenerowany kod był tym, co opublikowałem, można założyć, że może to być atomowe itp. Itd. Właśnie sprawdziłem też, że przynajmniej programista Intela podręczniki bardzo jasno definiują atomowość w odniesieniu do operacji pamięciowych, a nie niepodzielności instrukcji, jak założyłem: „Operacje zamknięte są atomowe w odniesieniu do wszystkich innych operacji pamięciowych i wszystkich zewnętrznie widocznych zdarzeń”.
Leo Heinsaar,

11

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


3
lock xaddimplementuje C ++ std :: atomic fetch_add, zwracając starą wartość. Jeśli tego nie potrzebujesz, kompilator użyje normalnych instrukcji miejsca docelowego pamięci z lockprefiksem. lock addlub lock inc.
Peter Cordes,

1
add [mem], 1nadal nie byłby atomowy na maszynie SMP bez pamięci podręcznej, zobacz moje komentarze na temat innych odpowiedzi.
Peter Cordes,

Zobacz moją odpowiedź, aby uzyskać więcej informacji na temat tego, dlaczego nie jest atomowy. Również koniec mojej odpowiedzi na to powiązane pytanie .
Peter Cordes,

10

Ponieważ wiersz 5, który odpowiada num ++, jest jedną instrukcją, czy możemy wywnioskować, że num ++ jest w tym przypadku niepodzielna?

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 addbędzie to atomowe w systemach wieloprocesorowych, nawet w architekturze x86.


9

Nawet jeśli twój kompilator zawsze emitował to jako operację atomową, jednoczesne uzyskiwanie dostępu numz 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, ++numjeśli numnie jest atomowa, np

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Nawet jeśli optymistycznie założymy, że ++readyjest 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, inicjowanie vectordo punktu po operacji inkrementacji, powodując chaos w nowym wątku. W praktyce nie zdziwiłbym się wcale, gdyby optymalizujący kompilator readycał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) LOCKprefiks 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:

  1. Twoim obowiązkiem jest napisanie kodu, który ma dobrze zdefiniowane zachowanie zgodnie z regułami językowymi (aw szczególności standardowym modelem pamięci języka).
  2. Obowiązkiem kompilatora jest wygenerowanie kodu maszynowego, który ma takie samo dobrze zdefiniowane (obserwowalne) zachowanie w modelu pamięci docelowej architektury.
  3. Obowiązkiem Twojego procesora jest wykonanie tego kodu, aby obserwowane zachowanie było zgodne z modelem pamięci jego własnej architektury.

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:

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

Jest to bezpieczne, ponieważ:

  1. Sprawdzeń readynie można zoptymalizować zgodnie z regułami językowymi.
  2. Występuje ++ready przed sprawdzeniem, które nie widzi readyzera i nie można zmienić kolejności innych operacji wokół tych operacji. Dzieje się tak, ponieważ ++readyi 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ć zapisu vecpo inkrementacji ready. 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.

1
Gdyby kompilator nie mógł zobaczyć wszystkich zastosowań programu ready, prawdopodobnie skompilowałby się while (!ready);w coś bardziej podobnego if(!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.
Peter Cordes,

9

Na jednordzeniowej maszynie x86 addinstrukcja 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ą lockprefiksu. Mogli wybrać, aby załadować numdo rejestru i przechowywać przyrost wyniku za pomocą oddzielnej instrukcji i prawdopodobnie zrobi to, jeśli użyjesz wyniku.)


Przypis 1: lockPrefiks istniał nawet w oryginalnym 8086, ponieważ urządzenia we / wy działają jednocześnie z procesorem; sterowniki w systemie jednordzeniowym muszą lock addatomowo 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.


Nie jest nawet generalnie atomowy: inny wątek może aktualizować tę samą zmienną w tym samym czasie i tylko jedna aktualizacja jest przejmowana.
fuz

1
Rozważ system wielordzeniowy. Oczywiście w jednym rdzeniu instrukcja jest atomowa, ale nie jest atomowa w odniesieniu do całego systemu.
fuz

1
@FUZxxl: Jakie były czwarte i piąte słowo mojej odpowiedzi?
supercat

1
@supercat Twoja odpowiedź jest bardzo myląca, ponieważ uwzględnia tylko rzadki obecnie przypadek pojedynczego rdzenia i daje OP fałszywe poczucie bezpieczeństwa. Dlatego skomentowałem, aby rozważyć również przypadek wielordzeniowy.
fuz

1
@FUZxxl: Zrobiłem edycję, aby wyjaśnić potencjalne zamieszanie dla czytelników, którzy nie zauważyli, że nie chodzi o normalne nowoczesne procesory wielordzeniowe. (A także sprecyzuj niektóre rzeczy, których supercat nie był pewien). A tak przy okazji, wszystko w tej odpowiedzi jest już w mojej, z wyjątkiem ostatniego zdania o tym, jak platformy, w których odczyt-modyfikacja-zapis jest atomowe „za darmo”, są rzadkie.
Peter Cordes,

7

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.


1
Przerwania jeszcze zrobić dwojone operacji RMW, więc oni mają jeszcze zsynchronizować jeden wątek z obsługi sygnałów, że prowadzony w tym samym wątku. Oczywiście działa to tylko wtedy, gdy asm używa pojedynczej instrukcji, a nie osobnego ładowania / modyfikowania / przechowywania. C ++ 11 może ujawnić tę funkcjonalność sprzętową, ale tak nie jest (prawdopodobnie dlatego, że było to naprawdę przydatne tylko w jądrach Uniprocessor do synchronizacji z programami obsługi przerwań, a nie w przestrzeni użytkownika z obsługą sygnałów). Również architektury nie mają instrukcji odczytu, modyfikacji i zapisu pamięci docelowej. Mimo to może po prostu skompilować się jak zrelaksowany atomowy RMW na innym niż x86
Peter Cordes

Chociaż, o ile pamiętam, używanie przedrostka Lock nie było absurdalnie drogie, dopóki nie pojawiły się superskalery. Nie było więc powodu, by zauważyć, że spowalnia to ważny kod w 486, mimo że nie było to potrzebne temu programowi.
JDługosz

Tak, przepraszam! Właściwie nie czytałem uważnie. Widziałem początek akapitu z czerwonym śledziem o dekodowaniu do ups i nie skończyłem czytać, żeby zobaczyć, co właściwie powiedziałeś. re: 486: Myślę, że czytałem, że najwcześniejszym SMP był jakiś Compaq 386, ale jego semantyka porządkowania pamięci nie była taka sama, jak obecnie mówi ISA x86. Obecne podręczniki x86 mogą nawet wspominać o SMP 486. Z pewnością nie były one powszechne nawet w HPC (klastry Beowulfa) aż do dni PPro / Athlon XP.
Peter Cordes,

1
@PeterCordes Ok. Jasne, zakładając również, że nie ma obserwatorów DMA / urządzenia - nie pasowało do obszaru komentarzy, aby uwzględnić również ten. Dzięki JDługosz za doskonały dodatek (odpowiedź i komentarze). Naprawdę zakończyłem dyskusję.
Leo Heinsaar

3
@Leo: Jeden kluczowy punkt, o którym nie wspomniano: niesprawne procesory zmieniają kolejność rzeczy wewnętrznie, ale złota zasada jest taka, że dla pojedynczego rdzenia zachowują iluzję instrukcji uruchamianych pojedynczo, w kolejności. (Obejmuje to przerwania, które wyzwalają przełączanie kontekstu). Wartości mogą być elektrycznie przechowywane w pamięci w nieprawidłowej kolejności, ale pojedynczy rdzeń, na którym wszystko działa, śledzi wszystkie zmiany kolejności, które robi sam, aby zachować iluzję. Dlatego nie potrzebujesz bariery pamięci dla odpowiednika asm, a = 1; b = a;aby poprawnie załadować 1, który właśnie zapisałeś.
Peter Cordes

4

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:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

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 czyta numwielokrotnie, to możliwe prawidłowe przeplatanie polega na tym, że wątek B nigdy nie czyta między num++i num--. 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:

while (working())
    progress++;  // atomic, global

(tj. wyobraź sobie, że inny wątek aktualizuje interfejs paska postępu na podstawie progress)

Czy kompilator może zamienić to na:

int local = 0;
while (working())
    local++;

progress += local;

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 progressbył niestabilny, nadal byłby ważny:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

: - /


Ta odpowiedź wydaje się odpowiadać tylko na poboczne pytanie, które rozważaliśmy z Richardem. My ostatecznie rozwiązany go: okazuje się, że tak, standard C ++ nie zezwala na łączenie operacji spoza volatileobiektó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.
Peter Cordes

Tak, moje „nie” jest tak naprawdę odpowiedzią na całą linię rozumowania. Jeśli pytanie brzmi po prostu „czy num ++ może być atomowe w jakimś kompilatorze / implementacji”, odpowiedź jest pewna. Na przykład kompilator może zdecydować o dodaniu lockdo 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 ...)
tony

1
Zauważ, że And just remove the incr/decr entirely.to nie jest całkiem w porządku. Nadal trwa operacja nabycia i zwolnienia num. Na x86 num++;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żą.
Peter Cordes

@PeterCordes ah tak, zgodził się.
tony

2

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ą.


2
Ładunek i magazyn są atomowe oddzielnie, ale cała operacja odczytu, modyfikacji i zapisu jako całość zdecydowanie nie jest atomowa. Pamięci podręczne są spójne, więc nigdy nie mogą zawierać sprzecznych kopii tej samej linii ( en.wikipedia.org/wiki/MESI_protocol ). Inny rdzeń nie może nawet mieć kopii tylko do odczytu, gdy ten rdzeń ma ją w stanie zmodyfikowanym. To, co sprawia, że ​​nie jest atomowy, to fakt, że rdzeń wykonujący RMW może stracić własność linii pamięci podręcznej między ładunkiem a magazynem.
Peter Cordes,

2
Nie, całe linie pamięci podręcznej nie zawsze są przenoszone atomowo. Zobacz tę odpowiedź , gdzie eksperymentalnie wykazano, że wielogniazdowy Opteron sprawia, że ​​16B SSE przechowuje nieatomowe, przesyłając linie pamięci podręcznej w fragmentach 8B z hipertransportem, nawet jeśli są one atomowe dla jednogniazdowych procesorów tego samego typu (ponieważ obciążenie / sprzęt sklepu ma ścieżkę 16B do pamięci podręcznej L1). x86 gwarantuje niepodzielność tylko dla oddzielnych obciążeń lub magazynów do 8B.
Peter Cordes,

Pozostawienie wyrównania kompilatorowi nie oznacza, że ​​pamięć zostanie wyrównana na granicy 4-bajtowej. Kompilatory mogą mieć opcje lub pragmy, aby zmienić granicę wyrównania. Jest to przydatne na przykład do pracy na ściśle upakowanych danych w strumieniach sieciowych.
Dmitry Rubanovich

2
Sophistries, nic więcej. Liczba całkowita z automatycznym przechowywaniem, która nie jest częścią struktury, jak pokazano w przykładzie, zostanie całkowicie wyrównana. Twierdzenie, że jest coś innego, jest po prostu głupie. Linie pamięci podręcznej, jak również wszystkie POD-y, mają rozmiar PoT (potęga dwóch) i są wyrównane - na każdej nie iluzorycznej architekturze na świecie. Matematyka mówi, że każdy poprawnie ustawiony punkt PoT pasuje do dokładnie jednego (nigdy więcej) dowolnego innego punktu PoT tego samego lub większego rozmiaru. Moje stwierdzenie jest zatem prawidłowe.
Damon

1
@Damon, przykład podany w pytaniu nie wspomina o strukturze, ale nie ogranicza pytania tylko do sytuacji, w których liczby całkowite nie są częściami struktur. POD z całą pewnością mogą mieć rozmiar PoT i nie mogą być wyrównane PoT. Spójrz na tę odpowiedź na przykłady składni: stackoverflow.com/a/11772340/1219722 . Nie jest to więc żadna „sofistyka”, ponieważ zadeklarowane w ten sposób POD są używane w kodzie sieciowym dość często w prawdziwym kodzie.
Dmitry Rubanovich

2

Że produkcja pojedynczego kompilator, na architekturze specyficzny procesora, z optymalizacje niepełnosprawnych (od gcc nawet nie skompilować ++się addprzy 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ępu numw wątku), i tak czy inaczej jest błędny, ponieważ nieadd jest atomowy w x86.

Zauważ, że atomics (używając lockprzedrostka 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:

void inc(int& x)
{
    ++x;
}

To kompiluje się w:

inc(int&):
    incl    (%rdi)
    retq

Zwiększanie liczby int przekazanej przez odniesienie, metodą atomową:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

Ten przykład, który nie jest dużo bardziej skomplikowany niż zwykły sposób, po prostu otrzymuje lockprzedrostek dodany do inclinstrukcji - 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.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

-2

Gdy Twój kompilator używa tylko jednej instrukcji do inkrementacji, a Twoja maszyna jest jednowątkowa, kod jest bezpieczny. ^^


-3

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.


„wynika z tego, że na maszynach x86 inkrementacja 32-bitowej liczby całkowitej jest w rzeczywistości atomowa”. czy możesz podać link do dokumentacji, która to potwierdza?
Slava

8
Nie jest też atomowy na x86. Jest bezpieczny dla pojedynczego rdzenia, ale jeśli jest wiele rdzeni (a jest), w ogóle nie jest atomowy.
harold

Czy x86 jest addrzeczywiś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 dlatego lockprzedrostek istnieje dla instrukcji; jedyny użyteczny atomic adddotyczy pamięci wyłuskanej i używa lockprzedrostka, aby zapewnić zablokowanie linii pamięci podręcznej na czas trwania operacji .
ShadowRanger,

@Slava @Harold @ShadowRanger Zaktualizowałem odpowiedź. addjest 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.
Xirema

3
@Xirema, która sprawia, że ​​z definicji nie jest atomowa
harold
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.