Czym dokładnie jest std :: atomic?

174

Rozumiem, że std::atomic<>jest to obiekt atomowy. Ale do jakiego stopnia atomowa? W moim rozumieniu operacja może być atomowa. Co dokładnie oznacza uczynienie obiektu atomowym? Na przykład, jeśli istnieją dwa wątki współbieżnie wykonujące następujący kod:

a = a + 12;

Czy zatem cała operacja (powiedzmy add_twelve_to(int)) jest atomowa? Czy też są zmiany w zmiennej atomic (tak operator=())?

ciekawy facet
źródło
9
Musisz użyć czegoś takiego a.fetch_add(12), jak, jeśli chcesz atomowego RMW.
Kerrek SB
Tak, tego nie rozumiem. Co to znaczy uczynić obiekt atomowym. Gdyby istniał interfejs, można by go po prostu uczynić atomowym za pomocą muteksu lub monitora.
2
@AaryamanSagar rozwiązuje problem wydajności. Muteksy i monitory powodują obciążenie obliczeniowe. Użycie std::atomicpozwala bibliotece standardowej zdecydować, co jest potrzebne do osiągnięcia atomowości.
Drew Dormann
1
@AaryamanSagar: std::atomic<T>to typ, który pozwala na operacje atomowe. W magiczny sposób nie poprawia to Twojego życia, nadal musisz wiedzieć, co chcesz z tym zrobić. Jest to bardzo specyficzny przypadek użycia, a zastosowania operacji atomowych (na obiekcie) są na ogół bardzo subtelne i należy je rozpatrywać z nielokalnej perspektywy. Więc jeśli już tego nie wiesz i dlaczego chcesz operacji atomowych, typ ten prawdopodobnie nie jest dla ciebie zbyt przydatny.
Kerrek SB

Odpowiedzi:

188

Każda instancja i pełna specjalizacja std :: atomic <> reprezentuje typ, na którym mogą jednocześnie działać różne wątki (ich instancje), bez wywoływania niezdefiniowanego zachowania:

Obiekty typu atomowego to jedyne obiekty C ++ wolne od wyścigów danych; to znaczy, jeśli jeden wątek zapisuje do obiektu atomowego, podczas gdy inny wątek czyta z niego, zachowanie jest dobrze zdefiniowane.

Ponadto dostęp do obiektów atomowych może ustanawiać synchronizację między wątkami i porządkować dostęp do pamięci nieatomowej, jak określono w std::memory_order.

std::atomic<>opakowuje operacje, które w pre-C ++ 11 razy musiały być wykonywane przy użyciu (na przykład) powiązanych funkcji z MSVC lub bultinami atomowymi w przypadku GCC.

Ponadto std::atomic<>zapewnia większą kontrolę, zezwalając na różne zamówienia pamięci, które określają synchronizację i ograniczenia kolejności. Jeśli chcesz przeczytać więcej o atomach w C ++ 11 i modelu pamięci, przydatne mogą być poniższe linki:

Zauważ, że w typowych przypadkach użycia prawdopodobnie użyłbyś przeciążonych operatorów arytmetycznych lub innego ich zestawu :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Ponieważ składnia operatora nie pozwala na określenie kolejności pamięci, operacje te będą wykonywane z std::memory_order_seq_cst, ponieważ jest to domyślna kolejność dla wszystkich operacji atomowych w C ++ 11. Gwarantuje to sekwencyjną spójność (całkowite uporządkowanie globalne) pomiędzy wszystkimi operacjami atomowymi.

W niektórych przypadkach może to jednak nie być wymagane (i nic nie jest darmowe), więc możesz chcieć użyć bardziej przejrzystego formularza:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Teraz twój przykład:

a = a + 12;

nie zostanie przeliczona na pojedynczą atomową operację: spowoduje to a.load()(która sama jest atomowa), a następnie dodanie między tą wartością 12a a.store()(również atomową) wyniku końcowego. Jak wspomniałem wcześniej, std::memory_order_seq_cstzostanie tutaj użyty.

Jeśli jednak napiszesz a += 12, będzie to operacja atomowa (jak zauważyłem wcześniej) i jest z grubsza równoważna a.fetch_add(12, std::memory_order_seq_cst).

Co do twojego komentarza:

Regularny intma ładunki atomowe i zapasy. Po co go owijać atomic<>?

Twoje stwierdzenie jest prawdziwe tylko w przypadku architektur, które zapewniają taką gwarancję atomowości dla sklepów i / lub obciążeń. Istnieją architektury, które tego nie robią. Ponadto zwykle wymagane jest, aby operacje musiały być wykonywane na adresie wyrównanym do słów / dwordów, aby były atomowe, std::atomic<>co gwarantuje, że będą atomowe na każdej platformie, bez dodatkowych wymagań. Co więcej, pozwala pisać taki kod:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Zwróć uwagę, że warunek asercji zawsze będzie prawdziwy (a zatem nigdy nie zostanie wyzwolony), więc zawsze możesz mieć pewność, że dane są gotowe po whilewyjściu z pętli. Tak jest ponieważ:

  • store()do flagi jest wykonywane po sharedDataustawieniu (zakładamy, że generateData()zawsze zwraca coś pożytecznego, w szczególności nigdy nie zwraca NULL) i używa std::memory_order_releaseorder:

memory_order_release

Operacja magazynu z taką kolejnością pamięci wykonuje operację zwalniania : po tym magazynie nie można zmienić kolejności odczytów ani zapisów w bieżącym wątku . Wszystkie zapisy w bieżącym wątku są widoczne w innych wątkach, które pobierają tę samą zmienną atomową

  • sharedDatajest używany po zakończeniu whilepętli, a zatem after load()from flag zwróci wartość niezerową. load()używa std::memory_order_acquirekolejności:

std::memory_order_acquire

Operacja ładowania z tą kolejnością pamięci wykonuje operację pobierania w odpowiedniej lokalizacji pamięci: żadne odczyty ani zapisy w bieżącym wątku nie mogą być ponownie uporządkowane przed tym ładowaniem. Wszystkie zapisy w innych wątkach, które zwalniają tę samą zmienną atomową, są widoczne w bieżącym wątku .

Daje to precyzyjną kontrolę nad synchronizacją i umożliwia jawne określenie, w jaki sposób Twój kod może / nie może / będzie / nie zachowywał się. Nie byłoby to możliwe, gdyby gwarancją była sama atomowość. Zwłaszcza jeśli chodzi o bardzo interesujące modele synchronizacji, takie jak porządkowanie wydania-konsumowania .

Mateusz Grzejek
źródło
2
Czy faktycznie istnieją architektury, które nie mają obciążeń atomowych i magazynów dla prymitywów, takich jak ints?
7
Nie chodzi tylko o atomowość. chodzi także o porządkowanie, zachowanie w systemach wielordzeniowych itp. Możesz przeczytać ten artykuł .
Mateusz Grzejek 13.08.15
4
@AaryamanSagar Jeśli się nie mylę, nawet na x86 odczyty i zapisy są atomowe TYLKO wtedy, gdy są wyrównane na granicach słów.
przeciwko Shashenko
@MateuszGrzejek Odwołałem się do typu atomowego. Czy mógłbyś uprzejmie sprawdzić, czy poniższe elementy nadal gwarantują atomowe działanie przy przypisywaniu obiektu ideone.com/HpSwqo
xAditya3393
3
@TimMB Tak, normalnie miałbyś (co najmniej) dwie sytuacje, w których kolejność wykonywania może ulec zmianie: (1) kompilator może zmienić kolejność instrukcji (o ile pozwala na to standard), aby zapewnić lepszą wydajność kodu wyjściowego (w oparciu o wykorzystanie rejestrów procesora, przewidywania itp.) oraz (2) procesor może wykonywać instrukcje w innej kolejności, aby na przykład zminimalizować liczbę punktów synchronizacji pamięci podręcznej. Ograniczenia kolejności przewidziane dla std::atomic( std::memory_order) służą dokładnie do ograniczenia dozwolonych zmian kolejności.
Mateusz Grzejek
20

Rozumiem, std::atomic<>że to sprawia , że obiekt jest atomowy.

To kwestia perspektywy ... nie można jej zastosować do dowolnych obiektów i sprawić, by ich operacje stały się atomowe, ale można użyć dostarczonych specjalizacji dla (większości) typów całkowitych i wskaźników.

a = a + 12;

std::atomic<>nie upraszcza tego (używa wyrażeń szablonowych do) do pojedynczej operacji atomowej, zamiast tego operator T() const volatile noexceptelement wykonuje atomową load()od a, następnie dodaje się dwanaście i operator=(T t) noexceptrobi store(t).

Tony Delroy
źródło
O to właśnie chciałem zapytać. Zwykły int ma niepodzielne ładunki i zapasy. Po co owijać go atomic <>
8
@AaryamanSagar Po prostu zmodyfikowanie normalnego intnie zapewnia, że ​​zmiana jest widoczna z innych wątków, ani też nie zapewnia, że ​​zobaczysz zmiany innych wątków, a niektóre rzeczy, takie jak, my_int += 3nie są gwarantowane atomowo, chyba że używasz std::atomic<>- mogą obejmować Pobierz, a następnie dodaj, a następnie zapisz sekwencję, w której inny wątek próbujący zaktualizować tę samą wartość może przyjść po pobraniu i przed zapisaniem i zepsuć aktualizację wątku.
Tony Delroy
Zwykła modyfikacja normalnej wartości int nie gwarantuje, że zmiana będzie widoczna w innych wątkach ”. Jest gorzej: każda próba zmierzenia tej widoczności skutkowałaby UB.
ciekawy facet
8

std::atomic istnieje, ponieważ wiele ISA ma dla niego bezpośrednie wsparcie sprzętowe

To, o czym mówi standard C ++, std::atomiczostało przeanalizowane w innych odpowiedziach.

Zobaczmy teraz, do czego się std::atomickompiluje, aby uzyskać inny wgląd.

Głównym wnioskiem z tego eksperymentu jest to, że współczesne procesory mają bezpośrednie wsparcie dla niepodzielnych operacji na liczbach całkowitych, na przykład przedrostek LOCK w x86, i std::atomiczasadniczo istnieje jako przenośny interfejs dla tych instrukcji : Co oznacza instrukcja „lock” w asemblerze x86? W wersji aarch64 zostanie użyty LDADD .

To wsparcie pozwala na szybsze alternatywy dla bardziej ogólnych metod, takich jak std::mutex, które mogą uczynić atomowymi bardziej złożone sekcje z wieloma instrukcjami, kosztem wolniejszego niż std::atomicdlatego, std::mutexże wywołuje futexsystemowe w Linuksie, który jest znacznie wolniejszy niż instrukcje środowiska użytkownika emitowane przez std::atomic, zobacz też: Czy std :: mutex tworzy ogrodzenie?

Rozważmy następujący program wielowątkowy, który inkrementuje zmienną globalną w wielu wątkach, z różnymi mechanizmami synchronizacji w zależności od tego, który z definicji preprocesora jest używany.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub upstream .

Kompiluj, uruchamiaj i dezasembluj:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Wyjątkowo prawdopodobne „nieprawidłowe” wyniki wyścigu dla main_fail.out:

expect 400000
global 100000

i deterministyczne „właściwe” wyjście innych:

expect 400000
global 400000

Demontaż main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Demontaż main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Demontaż main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Wnioski:

  • wersja nieatomowa zapisuje globalny do rejestru i zwiększa rejestr.

    Dlatego na końcu najprawdopodobniej cztery zapisy powrócą do globalnego z tą samą „złą” wartością 100000.

  • std::atomickompiluje się do lock addq. Prefiks LOCK powoduje incniepodzielne pobieranie, modyfikowanie i aktualizowanie pamięci.

  • nasz jawny przedrostek LOCK zestawu wbudowanego kompiluje się prawie do tego samego co std::atomic, z wyjątkiem tego, że inczamiast niego używany jest nasz add. Nie jestem pewien, dlaczego wybrał GCC add, biorąc pod uwagę, że nasz INC wygenerował dekodowanie o 1 bajt mniejsze.

ARMv8 może używać LDAXR + STLXR lub LDADD w nowszych procesorach: Jak rozpocząć wątki w zwykłym C?

Przetestowano w Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
źródło