Biorąc pod uwagę następną próbkę, która zamierza poczekać do kolejnych sklepów z wątków 42 w zmiennej udostępnionej shared bez zamków i bez oczekiwania na zakończenie wątku, dlaczego będzie wymagane {X3}} lub {X3}} lub zalecane zagwarantowanie poprawności współbieżności?

#include <atomic>
#include <cassert>
#include <cstdint>
#include <thread>

int main()
{
  int64_t shared = 0;
  std::thread thread([&shared]() {
    shared = 42;
  });
  while (shared != 42) {
  }
  assert(shared == 42);
  thread.join();
  return 0;
}

Z systemem GCC 4.8.5 i domyślne, próbka działa zgodnie z oczekiwaniami.

6
horstr 25 marzec 2021, 00:52

1 odpowiedź

Najlepsza odpowiedź

Wydaje się, że test wskazuje , że próbka jest poprawna , ale nie jest . Podobny kod może łatwo skończyć w produkcji, a nawet może działać bezbłędnie przez lata.

Możemy rozpocząć się, skompilując próbkę za pomocą -O3. Teraz próbka zawiesza się w nieskończoność. (Domyślnie jest -O0, bez optymalizacji / konsekwencji debugowania, która jest nieco podobna do tworzenia każdej zmiennej {x2}}, , który jest powodem, dla którego test nie ujawnił kodu jako niebezpiecznego .)

Aby dostać się do korzenia przyczyny, musimy sprawdzić wygenerowany zespół. Po pierwsze, GCC 4.8.5 -O0 Montaż X86_64 odpowiadający nieoptymalizowanemu binarnym binarnym:

        // Thread B:
        // shared = 42;
        movq    -8(%rbp), %rax
        movq    (%rax), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
.L11:
        movq    -32(%rbp), %rax     # Check shared every iteration
        cmpq    $42, %rax
        jne     .L11

Wątek B Wykonuje prosty sklep wartości 42 w shared. Wątek odczytu shared dla każdej iteracji pętli, aż do porównania wskazuje równość.

Teraz porównujemy się z wynikiem -O3:

        // Thread B:
        // shared = 42;
        movq    8(%rdi), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
        cmpq    $42, (%rsp)         # check shared once
        je      .L87                # and skip the infinite loop or not
.L88:
        jmp     .L88                # infinite loop
.L87:

Optymalizacje związane z -O3 zastąpił pętlę jednym porównaniem, a jeśli nie jest równa, nieskończona pętla pasujące do oczekiwanego zachowania. Z GCC 10.2 pętla jest zoptymalizowana. (W przeciwieństwie do C, nieskończone pętle bez skutków ubocznych lub niestabilnych dostępu są niezdefiniowane zachowanie w C ++).

Problem polega na tym, że kompilator i jego optymalizator nie są świadomi implikacji współbieżnych wdrażania. W związku z tym wniosek musi być taki, że shared nie może zmienić wątku A - pętla jest równoważna z martwym kodem. (Lub umieścić go innym sposobem, wyścigi danych są UB, a optymalizator może założyć, że program nie spotyka UB. Jeśli czytasz zmienną nie-atomową, to nie znaczy, że nikt inny nie pisze. To jest tym, co pozwala kompilatom wciągające ładunki z pętli i podobnie sklepy zlew, które są bardzo cennymi optymalizacjami dla normalnego przypadku zmiennych nie-wspólnych.)

Rozwiązanie wymaga przekazania kompilatora, że shared jest zaangażowany w komunikację między nicią. Jednym ze sposobów na osiągnięcie, który może być volatile. Podczas gdy rzeczywiste znaczenie volatile różni się w różnych kompilatorach i gwarancji, jeśli istnieją, są specyficzne dla kompilatora, ogólny konsensus jest taki, że volatile zapobiega optymalizacji lotnych dostępu w zakresie buforowania opartego na rejestracji. Jest to niezbędne dla kodu o niskim poziomie, który współdziała z sprzętem i ma swoje miejsce w równoległym programowaniu, choć z trendem spadkowym ze względu na wprowadzenie {x4}}.

Z volatile int64_t shared wygenerowane instrukcje zmieniają się w następujący sposób:

        // Thread B:
        // shared = 42;
        movq    24(%rdi), %rax
        movq    $42, (%rax)

        // Thread A:
        // while (shared != 42) {
        // }
.L87:
        movq    8(%rsp), %rax
        cmpq    $42, %rax
        jne     .L87

Pętla nie może być już wyeliminowana, ponieważ należy się założyć, że shared zmienił się, choć nie ma dowodów na to w formie kodu. W rezultacie próbka działa teraz z -O3.

Jeśli volatile rozwiązuje problem, dlaczego kiedykolwiek potrzebowałbyś std::atomic? Dwa aspekty istotne dla kodu bez blokady są tym, co sprawia, że std::atomic istotne: operacja pamięci Atomowość i kolejność pamięci.

Aby zbudować sprawę do atomowości ładowania / sklepu, przeglądamy wygenerowany zespół skompilowany z GCC4.8.5 {{ X0}} (wersja 32-bitowa) dla volatile int64_t shared:

        // Thread B:
        // shared = 42;
        movl    4(%esp), %eax
        movl    12(%eax), %eax
        movl    $42, (%eax)
        movl    $0, 4(%eax)

        // Thread A:
        // while (shared != 42) {
        // }
.L88:                               # do {
        movl    40(%esp), %eax
        movl    44(%esp), %edx
        xorl    $42, %eax
        movl    %eax, %ecx
        orl     %edx, %ecx
        jne     .L88                # } while(shared ^ 42 != 0);

W przypadku 32-bitowej generacji kodu X86, 64-bitowe ładunki i sklepy są zazwyczaj podzielone na dwie instrukcje. Dla kodu pojedynczego gwintowanego nie jest to problem. W przypadku kodu wielokrotnego, oznacza to, że inny wątek może zobaczyć częściowy wynik 64-bitowej pracy pamięci, pozostawiając miejsce na nieoczekiwane niespójności, które mogą nie powodować problemów w 100 procent czasu, ale może wystąpić losowo i prawdopodobieństwo wystąpienia i prawdopodobieństwo wystąpienia jest silnie pod wpływem otaczającego kodu i wzory użycia oprogramowania. Nawet jeśli GCC zdecydował się na wygenerowanie instrukcji, które domyślnie gwarantują atomowość, która nadal nie wpłynęłaby na innych kompilatorów i może nie posiadać prawdziwych dla wszystkich obsługiwanych platform.

Aby osłonić częściowe obciążenia / sklepy we wszystkich okolicznościach i we wszystkich kompilatorach i obsługiwanych platformach, std::atomic. Sprawdźmy, jak std::atomic wpływa na wygenerowane montaż. Zaktualizowana próbka:

#include <atomic>
#include <cassert>
#include <cstdint>
#include <thread>

int main()
{
  std::atomic<int64_t> shared;
  std::thread thread([&shared]() {
    shared.store(42, std::memory_order_relaxed);
  });
  while (shared.load(std::memory_order_relaxed) != 42) {
  }
  assert(shared.load(std::memory_order_relaxed) == 42);
  thread.join();
  return 0;
}

Wygenerowany montaż 32-bitowy oparty na GCC 10.2 ({x0}}: https://godbolt.org/Z / 8SPS55NZT):

        // Thread B:
        // shared.store(42, std::memory_order_relaxed);
        movl    $42, %ecx
        xorl    %ebx, %ebx
        subl    $8, %esp
        movl    16(%esp), %eax
        movl    4(%eax), %eax       # function arg: pointer to  shared
        movl    %ecx, (%esp)
        movl    %ebx, 4(%esp)
        movq    (%esp), %xmm0       # 8-byte reload
        movq    %xmm0, (%eax)       # 8-byte store to  shared
        addl    $8, %esp

        // Thread A:
        // while (shared.load(std::memory_order_relaxed) != 42) {
        // }
.L9:                                # do {
        movq    -16(%ebp), %xmm1       # 8-byte load from shared
        movq    %xmm1, -32(%ebp)       # copy to a dummy temporary
        movl    -32(%ebp), %edx
        movl    -28(%ebp), %ecx        # and scalar reload
        movl    %edx, %eax
        movl    %ecx, %edx
        xorl    $42, %eax
        orl     %eax, %edx
        jne     .L9                 # } while(shared.load() ^ 42 != 0);

Aby zagwarantować atomowość dla ładunków i sklepów, kompilator emituje 8-bajtowy SSE2 movq Instrukcja (do / z dolnej połowy 128-bitowego rejestru SSE). Dodatkowo, montaż pokazuje, że pętla pozostaje nienaruszona, nawet jeśli usunięto volatile.

Używając std::atomic w próbce, jest to gwarantowane

  • Std :: Obciążenia atomowe i sklepy nie podlegają buforowaniu opartym na rejestrze
  • STD :: Obciążenia atomowe i sklepy nie pozwalają na obserwowanie wartości częściowych

Standard C ++ w ogóle nie mówi o rejestrach, ale mówi:

Wdrożenia powinny wykonać sklepy atomowe widoczne dla obciążeń atomowych w rozsądnym czasie.

Chociaż to pozostawia miejsce do interpretacji, buforowanie {x0}} ładunki, jak wywołane w naszej próbce (bez lotności lub atomowej) wyraźnie będzie naruszeniem - sklep może nigdy nie stać widoczny. Aktualne kompilatory Nawet nie optymalizuj atomów w jednym bloku, jak 2 dostęp w tej samej iteracji.

Na X86, naturalnie wyrównane obciążenia / sklepy (gdzie adres jest wielokrotnością rozmiaru obciążenia / sklepu) są Atomic do 8 bajtów bez specjalnych instrukcji. Dlatego GCC jest w stanie użyć movq.

atomic<T> z dużą {X1}} może być obsługiwany bezpośrednio przez sprzęt, w którym to przypadku kompilator może spaść z powrotem do używania mutax.

Duża T (np. Rozmiar 2 rejestrów) na niektórych platformach może wymagać operacji Atomic RMW (jeśli kompilator nie będzie po prostu powrócić do blokowania), które są czasami zapewniane w większym rozmiarze niż największy wydajny czysty -Load / pure-sklep, który jest gwarantowany atomowy. (np. na x86-64, {x1}}, lub ramię ldrexd / strexd Pętla ponawiająca Retry). Pojedynczy indujący Atomic RMWS (jak X86 zastosowań) wewnętrznie obejmuje blokadę linii pamięci podręcznej lub Zamek autobusowy. Na przykład, starsze wersje clang -m32 dla x86 użyje lock cmpxchg8b zamiast movq dla 8-bajtowego obciążenia czystego lub czystego sklepu.

Jaki jest drugi aspekt wspomniany powyżej i co oznacza std::memory_order_relaxed? Zarówno kompilator, jak i CPU mogą zmienić kolejność operacji pamięci, aby zoptymalizować wydajność. Głównym ograniczeniem zmiany kolejności jest to, że wszystkie ładunki i sklepy muszą wydawać się wykonywane w kolejności podanej przez kod (Zamówienie programu). Dlatego, w przypadku komunikacji między nicią, należy wziąć pod uwagę kolejność pamięci w celu ustalenia wymaganego nakazu pomimo próby zmiany kolejności. Wymagana kolejność pamięci może być określona dla ładunków i sklepów std::atomic. std::memory_order_relaxed nie nakłada żadnej konkretnej kolejności.

Wzajemne wykluczenia Primitives egzekwowanie konkretnej kolejności pamięci (zlecenie uwalniania), dzięki czemu operacje pamięci pozostanie w zakresie zamka i sklepów wykonane przez wcześniejszych właścicieli blokujących są widoczne dla kolejnych właścicieli blokujących. Tak więc, używając zamków, wszystkie wzniesione tutaj aspekty są adresowane tylko za pomocą obiektu blokującego. Gdy tylko wyrwałeś się z zamków komfortowych, musisz być uważany za konsekwencje i czynniki wpływające na poprawność współbieżności.

Bycie tak samo wyraźnym, jak to możliwe, o komunikacji wewnątrz wątku jest dobrym punktem wyjścia, dzięki czemu kompilator jest świadomy kontekstu ładunku / sklepu i może odpowiednio wygenerować kod. W miarę możliwości, preferuj std::atomic<T> za pomocą {{x1} } (O ile scenariusz wywołuje konkretną kolejność pamięci) do volatile T (i oczywiście T). Ponadto, w miarę możliwości, woli, aby nie przewrócić własnego kodu bez blokady, aby zmniejszyć złożoność kodu i zmaksymalizować prawdopodobieństwo poprawności.

10
Peter Cordes 25 marzec 2021, 00:30