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

Ogólnie rzecz biorąc, dla int num, num++ (lub ++num), jako operacja odczytu-modyfikacji-zapisu, jest nie atomowa. Ale często widzę Kompilatory, na przykład GCC , generują dla niego następujący kod (Spróbuj tutaj):

Tutaj wpisz opis obrazka

Ponieważ linia 5, która odpowiada num++ jest jedną instrukcją, możemy wnioskować, że num++ czy atomic w tym przypadku?

A jeśli tak, to czy to znaczy, że tak wygenerowane num++ mogą być używane jednocześnie (wielowątkowe) scenariusze bez niebezpieczeństwa wyścigów danych (tzn. nie musimy tego robić np. std::atomic<int> i narzucać związanych z tym kosztów, bo i tak jest atomowe)?

UPDATE

Zauważ, że to pytanie jest a nie czy przyrost jest atomowy (nie jest i to było i jest linią początkową pytania). Chodzi o to, czy może być w konkretnych scenariuszach, tzn. czy charakter jednoprzyciskowy może być w pewnych przypadkach wykorzystany aby uniknąć napowietrzności przedrostka lock. I, jak przyjęta odpowiedź wspomina w sekcji o maszynach uniprocesorowych, a także ta odpowiedź, rozmowa w swoich komentarzach i innych wyjaśnia, może (choć nie z C lub c++).

Author: Leo, 2016-09-08

13 answers

To jest absolutnie to, co C++ definiuje jako wyścig danych, który powoduje nieokreślone zachowanie, nawet jeśli jeden kompilator zdarzył się wyprodukować kod, który zrobił to, czego oczekiwałeś na jakiejś docelowej maszynie. Musisz użyć std::atomic, aby uzyskać wiarygodne wyniki, ale możesz użyć go z memory_order_relaxed, Jeśli nie zależy ci na zmianie kolejności. Zobacz poniżej przykładowy kod i wyjście asm przy użyciu fetch_add.


Ale najpierw, część języka asemblacji pytania:

Ponieważ num++ jest jedną instrukcją (add dword [num], 1), można wnioskujemy, że num++ jest atomowe w tym przypadku?

Instrukcje pamięci-przeznaczenia (inne niż pure stores) są operacjami odczytu, modyfikacji i zapisu, które dzieją się w wielu wewnętrznych krokach. Żaden rejestr architektoniczny nie jest modyfikowany, ale procesor musi przechowywać dane wewnętrznie, podczas gdy wysyła je przez swój ALU . Rzeczywisty plik rejestru jest tylko niewielką częścią przechowywania danych wewnątrz nawet najprostszego PROCESORA, z zatrzaskami trzymającymi wyjścia jednego stopnia jako wejścia dla drugiego scena itp., itd.

Operacje pamięci z innych procesorów mogą być globalnie widoczne pomiędzy załadowaniem i przechowywaniem. Tzn. dwa wątki działające add dword [num], 1 W pętli stąpałyby po sobie nawzajem. (Zobacz @odpowiedź Małgorzaty dla ładnego diagramu). Po 40K przyrostów z każdego z dwóch wątków, licznik mógł wzrosnąć tylko o ~60k (nie 80k) na prawdziwym wielordzeniowym sprzęcie x86.


"atomowy", od greckiego słowa oznaczającego niepodzielny, oznacza, że żaden obserwator nie może patrz operacja jako oddzielne kroki. Dzieje się to fizycznie / elektrycznie natychmiastowo 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 dla operacji ALU.[71]} zająłem się dużo bardziej szczegółowo czystymi ładunkami i czystymi sklepami w mojej odpowiedzi na Atomicity na x86, podczas gdy ta odpowiedź koncentruje się na read-modify-write.

The lock prefiks może być stosowany do wielu odczytów-modyfikacji-zapisu (pamięć docelowa) instrukcje, aby cała operacja była atomowa w stosunku do wszystkich możliwych obserwatorów w systemie (inne rdzenie i urządzenia DMA, a nie oscyloskop podpięty do pinów procesora). Dlatego istnieje. (Zobacz także to Q & A ).

Więc lock add dword [num], 1 jest atomowe . Rdzeń PROCESORA z tą instrukcją utrzymywałby linię pamięci podręcznej przypiętą w zmodyfikowanym stanie w prywatnej pamięci podręcznej L1 od momentu, gdy obciążenie odczytuje dane z pamięci podręcznej, aż sklep zatwierdzi jej wynik z powrotem do pamięci podręcznej. Dzięki temu każda inna pamięć podręczna w systemie nie może mieć kopii linii pamięci podręcznej w dowolnym momencie od załadowania do przechowywania, zgodnie z regułami MESI Cache coherency protocol (lub jego wersje MOESI/MESIF używane przez wielordzeniowe procesory AMD/Intel, odpowiednio). Tak więc operacje wykonywane przez inne rdzenie wydają się mieć miejsce przed lub po, a nie w trakcie.

Bez prefiksu lock inny rdzeń mógłby przejąć własność linii cache i zmodyfikować ją po naszym załadować, ale przed naszym sklepem, aby inny sklep stał się globalnie widoczny pomiędzy naszym ładunkiem a sklepem. Kilka innych odpowiedzi źle to rozumie i twierdzi, że bez lock otrzymałbyś sprzeczne kopie tej samej linii pamięci podręcznej. To nigdy nie może się zdarzyć w systemie z koherentnymi buforami.

(Jeśli lockInstrukcja ed działa na pamięci obejmującej dwie linie pamięci podręcznej, potrzeba dużo więcej pracy, aby upewnić się, że zmiany w obu częściach obiektu pozostają atomowe, gdy propagują się do wszystkich obserwatorów, więc żaden obserwator nie widzi Rozdarcia. Procesor może być zmuszony do zablokowania całej magistrali pamięci, dopóki dane nie trafią do pamięci. Nie zmieniaj swoich zmiennych atomowych!)

Zauważ, że prefiks lock zamienia również instrukcję w pełną barierę pamięci (jak MFENCE), zatrzymując wszystkie zmiany kolejności w czasie wykonywania i nadając w ten sposób sekwencyjną spójność. (Zobacz Jeff Preshing ' s excellent blog post . Jego inne posty są również doskonałe i wyraźnie wyjaśniają wiele dobrego rzeczy o programowaniu bez blokad , od x86 i innych szczegółów sprzętowych do reguł C++.)


Na maszynie uniprocesorowej, lub w procesie jednowątkowym, pojedyncza RMW Instrukcja faktycznie jest atomowa bez prefiksu lock. Jedynym sposobem, aby inny kod mógł uzyskać dostęp do współdzielonej zmiennej, jest wykonanie przez procesor przełącznika kontekstowego, co nie może się zdarzyć w środku instrukcji. Tak więc zwykły {[17] } może synchronizować się między jednowątkowym program i jego programy obsługi sygnałów 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++:

Używanie num++ bez mówienia kompilatorowi, że jest potrzebny do kompilacji do jednej implementacji do odczytu-modyfikacji-zapisu:

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

Jest to bardzo prawdopodobne, jeśli użyjesz wartości num później: kompilator zachowa go w rejestrze po przyroście. 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 z przeznaczeniem pamięci co najmniej tak efektywnie, jak przy użyciu trzech oddzielnych instrukcji. Ciekawostka: gcc -O3 -m32 -mtune=i586 będzie emitować to , ponieważ (Pentium) P5 supersalar pipeline nie dekodował złożonych instrukcji do wiele prostych mikro-operacji, tak jak robią to mikroarchitektury P6 i późniejsze. Zobacz Agner Fog ' s instruction tables / microarchitecture guide, aby uzyskać więcej informacji, oraz x86 tag wiki, aby uzyskać wiele przydatnych linków (w tym podręczniki Intela x86 ISA, które są bezpłatnie dostępne w formacie PDF)).


Nie należy mylić modelu pamięci docelowej (x86) z Modelem pamięci C++ ]}

Zmiana kolejności w czasie kompilacji {[73] } jest dozwolona . Druga część tego, co dostajesz z std:: atomic kontroluje zmianę kolejności w czasie kompilacji, aby upewnić się, że twoja num++ będzie globalnie widoczna dopiero po jakiejś innej operacji.

Klasyczny przykład: zapisywanie niektórych danych do bufora, aby inny wątek mógł je obejrzeć, a następnie ustawianie flagi. Mimo że x86 pozyskuje ładunki / wydania magazynów za darmo, nadal musisz powiedzieć kompilatorowi, aby nie zmieniał kolejności przy użyciu flag.store(1, std::memory_order_release);.

Możesz się spodziewać, że ten kod będzie synchronizowany 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 to nie będzie. kompilator może swobodnie przenosić flag++ przez wywołanie funkcji (jeśli wpisuje funkcję lub wie, że nie patrzy na flag). Wtedy może całkowicie zoptymalizować modyfikację, ponieważ flag nie jest nawet volatile. (I nie, C++ volatile nie jest użytecznym substytutem STD:: atomic. std:: atomic sprawia, że kompilator zakłada, że wartości w pamięci mogą być modyfikowane asynchronicznie podobne do volatile, ale jest w tym o wiele więcej. Również volatile std::atomic<int> foo nie jest tym samym co std::atomic<int> foo, jak dyskusja z @ Richard Hodges.)

Definiowanie wyścigów danych na zmiennych nieatomowych jako niezdefiniowanego zachowania pozwala kompilatorowi nadal podnosić obciążenia i zatapiać zapasy z pętli, a także wiele innych optymalizacji pamięci, do których może odnosić się wiele wątków. (Zobacz ten blog LLVM aby dowiedzieć się więcej o tym, jak UB umożliwia optymalizację kompilatora.)


Jak już wspomniałem, prefiks x86 lock jest pełną barierą pamięci, więc użycie num.fetch_add(1, std::memory_order_relaxed); generuje tę samą kod na x86 jako num++ (domyślnie jest to spójność Sekwencyjna), ale może być znacznie bardziej wydajny na innych architekturach (takich jak ARM). Nawet na x86, relaxed pozwala na większą zmianę kolejności w czasie kompilacji.

To właśnie robi GCC na x86, dla kilku funkcji, które działają na zmiennej globalnej std::atomic.

Zobacz kod języka source + assembly sformatowany ładnie na kompilatorze Godbolt explorer . Możesz wybrać inne architektury docelowe, w tym ARM, MIPS, i PowerPC, aby zobaczyć, jaki kod języka asemblacji dostajesz od atomiki 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 przechowywaniu sekwencyjnej konsystencji. x86 jest ogólnie mocno uporządkowany, ale zmiana kolejności w magazynie jest dozwolona. Posiadanie bufora sklepu jest niezbędne dla dobrej wydajności na procesorze o napięciu wyjściowym. Jeff Preshing ' s Zmiana kolejności pamięci pokazuje konsekwencje Nie używanie MFENCE, z prawdziwym kodem do pokazania zmiany kolejności na prawdziwym sprzęcie.


Re: dyskusja w komentarzach do odpowiedzi na temat kompilatorów STD::atomic num++; num-=2;operations into onenum--; instruction :

Osobne pytanie na ten sam temat: dlaczego Kompilatory nie łączą redundantnych zapisów std:: atomic?, gdzie moja odpowiedź powtarza wiele z tego, co napisałem poniżej.

Current compilers don ' t actually do tego (jeszcze), ale nie dlatego, że nie wolno im C++ WG21/ P0062R1: kiedy Kompilatory powinny optymalizować atomikę? omawia oczekiwania wielu programistów, że Kompilatory nie dokonają" zaskakujących " optymalizacji, i co standard może zrobić, aby dać programistom kontrolę. N4455 omawia wiele przykładów rzeczy, które można zoptymalizować, w tym ten. Zwraca uwagę, że inlining i stała propagacja mogą wprowadzać rzeczy takie jak fetch_or(0), które mogą być w stanie zmienić się w load() (ale nadal ma semantykę pozyskania i Wydania), nawet jeśli oryginalne źródło nie miało żadnych oczywiście zbędnych atomic ops.

Prawdziwe powody, dla których Kompilatory tego nie robią (jeszcze) są następujące: (1) nikt nie napisał skomplikowanego kodu, który pozwoliłby kompilatorowi na to bezpiecznie (bez pomyłki), oraz (2) potencjalnie narusza zasadę najmniejszego zaskoczenia (73). Kod bez blokady jest wystarczająco trudny, aby poprawnie napisać. Więc nie bądź przypadkowy w użyciu broni atomowej: nie są tanie i nie optymalizują wiele. Nie zawsze jest łatwo uniknąć zbędnych operacji atomowych za pomocą std::shared_ptr<T>, ponieważ nie ma jej niematomicznej wersji (chociaż jedna z odpowiedzi tutaj daje łatwy sposób na zdefiniowanie shared_ptr_unsynchronized<T> dla gcc).


Powrót do num++; num-=2; kompilowanie jakby było num--: Kompilatory mogą to robić , chyba że num jest volatile std::atomic<int>. Jeśli zmiana kolejności jest możliwa, jak-jeśli reguła pozwala kompilatorowi zdecydować w czasie kompilacji, że zawsze dzieje się w ten sposób. Nic nie gwarantuje, że obserwator może zobaczyć wartości pośrednie (wynik num++).

Tzn. jeśli uporządkowanie, w którym nic nie staje się widoczne globalnie między tymi operacjami, jest zgodne z wymaganiami porządkowymi ź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 synchronizację z relacjami z innymi wątkami, które patrzą na num, i jest to zarówno acquire-load, jak i release-store, który nie pozwala na zmianę kolejności innych operacji w tym wątku. W przypadku x86 może to być w stanie skompilować do MFENCE, zamiast lock add dword [num], 0 (tj. num += 0).

Jak wspomniano w PR0062 , bardziej agresywne łączenie nie sąsiadujących operacji atomowych w czasie kompilacji może być złe (np. licznik postępu tylko jest aktualizowany 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 shared_ptr jest tworzona i niszczona, jeśli kompilator może udowodnić, że inny shared_ptr obiekt istnieje przez cały czas życia tymczasowego.)

Nawet num++; num-- łączenie może zaszkodzić uczciwości implementacji blokady, gdy jeden wątek odblokowuje się i ponownie blokuje od razu. Jeśli nigdy nie zostanie wydany w asm, nawet sprzęt mechanizmy arbitrażowe nie dadzą kolejnej nitce szansy na złapanie zamka w tym momencie.


Z aktualnymi gcc6. 2 i clang 3.9, nadal otrzymujesz oddzielne operacje locked nawet z memory_order_relaxed w najbardziej oczywistym przypadku optymalizacji. (Godbolt compiler explorer więc 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
 206
Author: Peter Cordes,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-02-25 02:23:29

...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 Obserwujący wątek (nawet ignorując opóźnienia synchronizacji pamięci podręcznej) nie ma możliwości obserwowania poszczególnych zmian.

Porównaj z:

#include <atomic>

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

Gdzie wynikiem jest:

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. obserwowalne w innym wątku i
  2. respektując podobne modyfikacje zachodzące w innych nici.

Atomicity nie jest tylko na poziomie instrukcji, obejmuje cały rurociąg od procesora, przez pamięć podręczną, do pamięci i z powrotem.

Dalsze informacje

Dotyczące efektu optymalizacji aktualizacji std::atomics.

Standard c++ posiada regułę "as if", która pozwala kompilatorowi na zmianę kolejności kodu, a nawet przepisanie kodu pod warunkiem, że wynik ma dokładnie takie same efekty obserwowalne (w tym efekty uboczne), jak gdyby po prostu wykonał Twój kod.

Zasada as-if jest konserwatywna, w szczególności dotyczy atomiki.

Rozważmy:

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

Ponieważ nie ma blokad mutex, Atomic ani żadnych innych konstrukcji wpływających na sekwencjonowanie między wątkami, argumentowałbym, że kompilator może dowolnie przepisać tę funkcję jako NOP, np:

void incdec(int&) {
    // nada
}

Dzieje się tak dlatego, że w modelu pamięci c++ nie ma możliwości, aby inny wątek obserwował wynik przyrostu. Oczywiście. być inne, jeśli num było volatile (może wpływać na zachowanie sprzętu). Ale w tym przypadku ta funkcja będzie jedyną funkcją modyfikującą tę pamięć (w przeciwnym razie program jest źle uformowany).

Jest to jednak inna gra w piłkę:
void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

num jest atomowy. Zmiany w nimmuszą być widoczne dla innych wątków, które oglądają. Zmiany, które same wprowadzają te wątki (takie jak ustawienie wartości na 100 pomiędzy przyrostem i zmniejszeniem) będą miały bardzo dalekosiężny 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
 40
Author: Richard Hodges,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2020-06-20 09:12:55

Bez wielu komplikacji instrukcja jak add DWORD PTR [rbp-4], 1 jest bardzo CISC stylu.

Wykonuje trzy operacje: załaduj operand z pamięci, zwiększ go, Zapisz operand z powrotem do pamięci.
Podczas tych operacji CPU nabywa i zwalnia magistralę dwa razy, między innymi każdy inny agent może ją zdobyć, a to narusza atomiczność.

AGENT 1          AGENT 2

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

X jest zwiększany tylko raz.

 39
Author: Margaret Bloom,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-08 15:14:53

Instrukcja add jest a nie atomowa. Odwołuje się do pamięci, a dwa rdzenie procesora mogą mieć różną lokalną pamięć podręczną tej pamięci.

IIRC atomowy wariant instrukcji add nazywa się lock xadd

 11
Author: Sven Nilsson,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-08 14:54:43

Ponieważ linia 5, która odpowiada num++ jest jedną instrukcją, czy możemy wnioskować, że num++ jest atomowe w tym przypadku?

Niebezpieczne jest wyciąganie wniosków w oparciu o" inżynierię odwrotną " generowanego zespołu. Na przykład, wydaje się, że skompilowałeś kod z wyłączoną optymalizacją, w przeciwnym razie kompilator wyrzuciłby tę zmienną lub załadowałby 1 bezpośrednio do niej bez wywoływania operator++. Ponieważ generowany zespół może się znacznie zmienić, w oparciu o optymalizację flagi, procesor docelowy itp., twój wniosek opiera się na piasku.

Również Twój pomysł, że jedna instrukcja montażu oznacza, że operacja jest atomowa, jest błędny. To add nie będzie atomowe w systemach wielordzeniowych, nawet w architekturze x86.
 10
Author: Slava,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-10-25 14:38:06

Nawet jeśli twój kompilator zawsze emitował to jako operację atomową, dostęp do {[2] } z dowolnego innego wątku jednocześnie stanowiłby wyścig danych zgodnie ze standardami C++11 i C++14, a program miałby nieokreślone zachowanie.

Ale jest jeszcze gorzej. Po pierwsze, jak już wspomniano, Instrukcja generowana przez kompilator podczas zwiększania zmiennej może zależeć od poziomu optymalizacji. Po drugie, kompilator może zmienić kolejność Inne dostęp do pamięci ++num Jeśli num nie 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 przyjmiemy optymistycznie, że ++ready jest "atomowa" i że kompilator generuje pętlę sprawdzającą w razie potrzeby (jak powiedziałem, jest to UB i dlatego kompilator może ją usunąć, zastąpić nieskończoną pętlą itp.), kompilator nadal może przesunąć przypisanie wskaźnika, lub co gorsza inicjalizację vector do punktu po operacji inkrementacji, powodując chaos w nowym wątku. W praktyce nie zdziwiłbym się wszystko, jeśli kompilator optymalizujący usunął zmienną ready i pętlę sprawdzającą całkowicie, ponieważ nie wpływa to na obserwowalne zachowanie zgodnie z regułami języka(w przeciwieństwie do prywatnych nadziei).

W rzeczywistości, na zeszłorocznej konferencji Meeting C++, słyszałem od dwóch programistów, że bardzo chętnie wdrażają optymalizacje, które sprawiają, że naiwnie napisane wielowątkowe programy źle się zachowują, o ile pozwalają na to reguły języka, jeśli nawet niewielka poprawa wydajności jest widoczna w języku C++. poprawnie napisane programy.

Wreszcie, nawet jeśli Nie dbałeś o przenośność, a Twój kompilator był magicznie miły, procesor, którego używasz, jest bardzo prawdopodobny typu SUPERSALARNEGO CISC i będzie rozkładał instrukcje na mikro-operacje, zmieniał kolejność i/lub spekulatywnie je wykonywał, w stopniu ograniczonym tylko przez synchronizację prymitywów, takich jak (w Intelu) prefiks LOCK lub ogrodzenia pamięci, w celu maksymalizacji operacji na sekundę.

W skrócie mówiąc, naturalne obowiązki programowania bezpiecznego dla wątku to:

  1. Twoim obowiązkiem jest pisanie kodu, który ma dobrze zdefiniowane zachowanie zgodnie z regułami języka (a w szczególności standardowym modelem pamięci języka).
  2. Twoim obowiązkiem kompilatora jest generowanie 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 jego własną architekturą model pamięci.
Jeśli chcesz zrobić to po swojemu, w niektórych przypadkach może to po prostu zadziałać, ale zrozum, że gwarancja jest nieważna, a Ty ponosisz wyłączną odpowiedzialność za wszelkie niepożądane rezultaty. :-)

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. sprawdzania ready nie można zoptymalizować zgodnie z regułami językowymi.
  2. The ++ready dzieje się-przed czekiem, który widzi {[7] } jako nie zero, a inne operacje nie mogą być uporządkowane wokół tych operacji. Dzieje się tak dlatego, że ++ready i sprawdzanie są sekwencyjnie spójne, co jest innym terminem opisanym w modelu pamięci C++ i które zabrania tego konkretnego uporządkowania. Dlatego kompilator nie może zmieniać kolejności instrukcji, a także musi powiedzieć procesorowi, że nie może np. odłożyć zapisu do vec NA po przyroście ready. sekwencyjnie spójne jest najsilniejszą gwarancją dotyczącą atomiki w standard językowy. Mniejsze (i teoretycznie tańsze) Gwarancje są dostępne np. za pomocą innych metod std::atomic<T>, ale są one zdecydowanie tylko dla ekspertów i mogą nie być zbytnio optymalizowane przez programistów, ponieważ są rzadko używane.
 9
Author: Arne Vogel,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-08 17:22:36

Na jednordzeniowej maszynie x86 Instrukcja add będzie zazwyczaj atomowa w odniesieniu do innego kodu na procesorze1. Przerwanie nie może rozdzielić ani jednej instrukcji na środek.

Out-of-order execution jest wymagane, aby zachować iluzję instrukcji wykonujących pojedynczo w kolejności w obrębie jednego rdzenia, więc każda instrukcja uruchomiona na tym samym procesorze stanie się całkowicie przed lub całkowicie po add.

Nowoczesne systemy x86 są wielordzeniowe, więc specjalny przypadek uniprocesora nie ma zastosowania.

Jeśli ktoś celuje w mały wbudowany komputer i nie ma planów przeniesienia kodu na cokolwiek innego, atomowa natura instrukcji "add" może zostać wykorzystana. Z drugiej strony platformy, na których operacje są z natury atomowe, stają się coraz rzadsze.

(to nie pomaga, jeśli piszesz w C++, chociaż. Kompilatory nie mają opcji wymagającej num++ kompilacji do pamięci docelowej add lub xadd BEZ a lock prefiks. Mogą wybrać załadowanie num do rejestru i zapisanie wyniku przyrostu za pomocą oddzielnej instrukcji, i prawdopodobnie zrobią to, jeśli użyjesz wyniku.)


Przypis 1: prefiks lock istniał nawet na oryginalnym 8086, ponieważ urządzenia We/Wy działają jednocześnie z procesorem; sterowniki w systemie jednordzeniowym muszą lock add atomicznie zwiększyć wartość w pamięci urządzenia, jeśli urządzenie może ją również zmodyfikować, lub w odniesieniu do dostępu DMA.

 9
Author: supercat,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2018-07-16 20:23:12

W czasach, gdy komputery x86 miały jeden procesor, użycie pojedynczej instrukcji zapewniało, że przerwania nie będą rozdzielać odczytu/modyfikacji/zapisu i jeśli pamięć nie będzie używana jako bufor DMA, w rzeczywistości była atomowa (A C++ nie wspominało o wątkach w standardzie, więc nie było to rozwiązywane).

Kiedy rzadko było mieć podwójny procesor (np. Dual-socket Pentium Pro) na pulpicie klienta, skutecznie użyłem tego, aby uniknąć prefiksu blokady na jednordzeniowej maszynie i poprawić wydajność.

Dzisiaj pomogłoby to tylko w walce z wieloma wątkami, które były ustawione na to samo powinowactwo CPU, więc wątki, o które się martwisz, wejdą w grę tylko poprzez wygaśnięcie time slice i uruchomienie drugiego wątku na tym samym procesorze (rdzeniu). To nie jest realistyczne.

W nowoczesnych procesorach x86/x64 pojedyncza instrukcja jest dzielona na kilka mikroprocesorów , a ponadto odczyt i zapis pamięci jest buforowany. Tak różne wątki działają na różne procesory nie tylko zobaczą to jako nieatomowe, ale mogą zobaczyć niespójne wyniki dotyczące tego, co czyta z pamięci i co zakłada, że inne wątki przeczytały do tego momentu: musisz dodać ogrodzenia pamięci , aby przywrócić zdrowe zachowanie.

 7
Author: JDługosz,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2019-02-05 00:25:02

Nie. https://www.youtube.com/watch?v=31g0YE61PLQ W odcinku "nie"pojawia się w odcinku "Biuro".]}

Czy zgadzasz się, że będzie to możliwe wyjście 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 sprawić, że tylko będzie możliwe wyjście dla programu, niezależnie od tego, w jaki sposób kompilator chce. ie a main (), który po prostu wystawia 100s.

Jest to reguła "jak gdyby".

I niezależnie od wyjście, można myśleć o synchronizacji wątku w ten sam sposób-jeśli wątek a robi num++; num--; i wątek B czyta num wielokrotnie, to możliwe poprawne przeplatanie jest takie, że wątek B nigdy nie czyta między num++ i num--. Ponieważ to przeplatanie jest poprawne, kompilator może sprawić, że tylko będzie możliwe przeplatanie. I po prostu całkowicie usuń incr / decr.

Jest tu kilka ciekawych implikacji:

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

(tj. wyobraź sobie, że niektóre inne wątki aktualizują pasek postępu UI na podstawie progress)

Czy kompilator może zamienić to na:

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

progress += local;

Prawdopodobnie jest to słuszne. Ale prawdopodobnie nie to, na co programista liczył: - (

Komitet wciąż nad tym pracuje. Obecnie "działa", ponieważ Kompilatory nie optymalizują zbytnio atomiki. Ale to się zmienia.

I nawet gdyby progress również było niestabilne, to i tak byłoby ważne:

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

while (local--)
    progress++;

:-/

 4
Author: tony,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-12 22:25:37

Tak, ale...

Atomic nie jest tym, co chciałeś powiedzieć. Pewnie pytasz źle.

Przyrost jest z pewnością atomowy . Jeśli pamięć masowa nie jest wyrównana (a ponieważ pozostawiono wyrównanie do kompilatora, nie jest), musi być wyrównana w obrębie jednej linii bufora. W przeciwieństwie do specjalnych instrukcji nie buforowania strumieniowego, każdy zapis przechodzi przez pamięć podręczną. Kompletne linie pamięci podręcznej są atomicznie odczytywane i zapisywane, nigdy nic inaczej.
Dane mniejsze od cacheline są oczywiście również zapisywane atomicznie (ponieważ otaczająca linia cache jest).

Czy to bezpieczne?

To jest inne pytanie i istnieją co najmniej dwa dobre powody, aby odpowiedzieć definitywnie "nie!".

Po pierwsze, istnieje możliwość, że inny rdzeń może mieć kopię tej linii pamięci podręcznej w L1 (L2 i w górę jest zwykle współdzielone, ale L1 jest normalnie per-core!), a jednocześnie modyfikuje tę wartość. Oczywiście, że dzieje się też atomicznie, ale teraz masz dwie" poprawne " (poprawnie, atomicznie, zmodyfikowane) wartości-która z nich jest teraz naprawdę poprawna?
Oczywiście procesor jakoś to rozwiąże. Ale wynik może nie być tym, czego oczekujesz.

Po drugie, jest kolejność pamięci, lub inaczej sformułowane dzieje się-przed gwarancjami. Najważniejszą rzeczą w instrukcjach atomowych jest nie tyle to, że są one atomowe . To rozkaz.

Masz możliwość wyegzekwowania gwarancja, że wszystko, co dzieje się w pamięci, jest realizowane w pewnej gwarantowanej, dobrze określonej kolejności, w której masz gwarancję "zdarzyło się wcześniej". To zamówienie może być tak "zrelaksowany" (Czytaj jako: w ogóle) lub tak rygorystyczne, jak trzeba.

Na przykład, możesz ustawić wskaźnik na jakiś blok danych (np. wyniki obliczeń), a następnie atomicznie zwolnić znacznik "data is ready". Ktokolwiek zdobędzie tę flagę, będzie myślał, że wskaźnik jest prawidłowy. I rzeczywiście, to będzie Zawsze być ważnym wskaźnikiem, nigdy nic innego. To dlatego, że zapis do wskaźnika miał miejsce przed operacją atomową.

 2
Author: Damon,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-08 18:07:34

To, że wyjście pojedynczego kompilatora, na określonej architekturze procesora, z wyłączonymi optymalizacjami (ponieważ gcc nawet nie kompiluje ++ do add podczas optymalizacji w szybkim i brudnym przykładzie), wydaje się sugerować, że zwiększanie w ten sposób jest atomowe, nie oznacza, że jest to zgodne ze standardami (spowodowałbyś niezdefiniowane zachowanie podczas próby uzyskania dostępu num w wątku), i tak jest złe, ponieważ add jest , a nie. Atomic w x86.

Zauważ, że Atomics (używając instrukcji lock prefix) są stosunkowo ciężkie dla x86 (zobacz tę odpowiednią odpowiedź ), ale nadal znacznie mniej niż mutex, co nie jest zbyt odpowiednie w tym przypadku użycia.

Poniższe wyniki pochodzą z clang++ 3.8 podczas kompilacji z -Os.

Zwiększenie int przez odniesienie, "regularny" sposób:

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

To zestawia się w:

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

Inkrementacja int przekazywana przez odniesienie, droga atomowa:

#include <atomic>

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

Ten przykład, który nie jest dużo bardziej złożony niż zwykły sposób, po prostu dostaje prefiks lock dodany do instrukcji incl - ale uwaga, jak wcześniej wspomniano, jest to , a nie tanie. To, że montaż wygląda na krótki, nie oznacza, że jest szybki.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq
 2
Author: Asu,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-05-23 12:18:09

Gdy kompilator używa tylko jednej instrukcji do przyrostu, a maszyna jest jednowątkowa, kod jest bezpieczny. ^^

 -2
Author: Bonita Montero,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-05-06 06:02:39

Spróbuj skompilować ten sam kod na maszynie innej niż x86, a szybko zobaczysz bardzo różne wyniki montażu.

Powód num++ wydaje się, że jest atomowa, ponieważ na maszynach x86 inkrementacja 32-bitowej liczby całkowitej jest w rzeczywistości atomowa (zakładając, że nie ma miejsca odzyskiwanie pamięci). Ale nie jest to gwarantowane przez standard c++, ani nie jest prawdopodobne, aby miało to miejsce na komputerze, który nie używa zestawu instrukcji x86. Więc ten kod nie jest międzyplatformowy bezpieczny od wyścigu warunki.

Nie masz także silnej gwarancji, że ten kod jest bezpieczny nawet na architekturze x86, ponieważ x86 nie ustawia obciążeń i nie zapisuje ich do pamięci, chyba że zostanie to wyraźnie określone. Tak więc, jeśli wiele wątków próbowało zaktualizować tę zmienną jednocześnie, mogą one zakończyć się zwiększeniem wartości buforowanych (nieaktualnych)

Powodem, dla którego mamy std::atomic<int> i tak dalej jest to, że gdy pracujemy z architekturą, w której atomiczność podstawowych obliczenia nie są gwarantowane, masz mechanizm, który zmusi kompilator do generowania kodu atomowego.

 -3
Author: Xirema,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-09-08 14:55:07