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=())?


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 .


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


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.

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.