Program wielowątkowy utknął w trybie zoptymalizowanym, ale działa normalnie w -O0

68

Napisałem proste programy wielowątkowe w następujący sposób:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Zachowuje się normalnie w trybie debugowania w Visual Studio lub -O0w gc c i drukuje wynik po 1kilku sekundach. Ale utknął i nie drukuje niczego w trybie Release lub -O1 -O2 -O3.

sz ppeter
źródło
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Samuel Liew

Odpowiedzi:

100

Dwa wątki, uzyskujące dostęp do nieatomowej, niechronionej zmiennej to UB Dotyczy to finished. Można zrobić finishedtypu std::atomic<bool>, aby to naprawić.

Moja poprawka:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Wynik:

result =1023045342
main thread id=140147660588864

Demo na żywo na coliru


Ktoś może pomyśleć: „To bool- prawdopodobnie trochę. Jak to może być nieatomowe? (Zrobiłem to, kiedy sam zacząłem z wielowątkowością.)

Ale zauważ, że brak łez nie jest jedyną rzeczą, która std::atomicci daje. Umożliwia także dobrze zdefiniowany dostęp do odczytu i zapisu z wielu wątków, uniemożliwiając kompilatorowi zakładanie, że ponowne czytanie zmiennej zawsze zobaczy tę samą wartość.

Wykonanie boolniestrzeżonego, nieatomowego atomu może powodować dodatkowe problemy:

  • Kompilator może zdecydować o zoptymalizowaniu zmiennej w rejestrze lub nawet wielokrotnym dostępie CSE do jednego i wyciągnięciu obciążenia z pętli.
  • Zmienna może być buforowana dla rdzenia procesora. (W prawdziwym życiu, procesory mają spójnej pamięci podręcznej . To nie jest prawdziwy problem, ale standardem C ++ jest luźna wystarczy na pokrycie hipotetyczny C ++ implementacje na niekoherentnego pamięci współdzielonej, gdzie atomic<bool>ze memory_order_relaxedsklepu / obciążenie będzie działać, ale gdzie volatilenie. Za pomocą niestabilna w tym przypadku byłaby UB, nawet jeśli działa w praktyce na rzeczywistych implementacjach C ++.)

Aby temu zapobiec, należy wyraźnie powiedzieć kompilatorowi, aby tego nie robił.


Jestem trochę zaskoczony ewoluującą dyskusją dotyczącą potencjalnego związku volatiletego problemu. Dlatego chciałbym wydać dwa centy:

Scheff
źródło
4
Rzuciłem okiem na jedno func()i pomyślałem: „Mogę to zoptymalizować”. Optymalizator wcale nie dba o nici i wykryje nieskończoną pętlę i z przyjemnością zamieni ją w „chwilę” (prawda) „Jeśli spojrzymy na godbolta .org / z / Tl44iN możemy to zobaczyć. Jeśli skończone True, zwraca. Jeśli nie, przechodzi w bezwarunkowy skok do siebie (nieskończona pętla) w wytwórni.L5
Baldrickk
2
@val: w zasadzie nie ma powodu do nadużyć volatilew C ++ 11, ponieważ możesz uzyskać identyczny asm z atomic<T>i std::memory_order_relaxed. Działa to jednak na prawdziwym sprzęcie: pamięci podręczne są spójne, więc instrukcja ładowania nie może czytać przestarzałej wartości, gdy sklep na innym rdzeniu podejmie tam buforowanie. (MESI)
Peter Cordes
5
@PeterCordes Korzystanie volatilejest jednak nadal UB. Naprawdę nigdy nie powinieneś zakładać, że coś, co jest zdecydowanie i wyraźnie UB jest bezpieczne tylko dlatego, że nie możesz wymyślić, w jaki sposób mogłoby się nie udać i zadziałało, gdy próbowałeś. To powoduje, że ludzie palą się w kółko.
David Schwartz
2
@Damon Mutexes mają semantykę wydania / akwizycji. Kompilator nie może optymalizować odczytu, jeśli muteks był wcześniej zablokowany, więc ochrona finishedza pomocą std::mutexdziała (bez volatilelub atomic). W rzeczywistości możesz zastąpić wszystkie atomiki „prostą” wartością + schematem mutex; nadal działałoby i działało wolniej. atomic<T>wolno używać wewnętrznego muteksu; atomic_flaggwarantuje się tylko bez blokady.
Erlkoenig
42

Odpowiedź Scheffa opisuje, jak naprawić twój kod. Pomyślałem, że dodam trochę informacji o tym, co faktycznie dzieje się w tej sprawie.

Skompilowałem Twój kod na Godbolt, używając poziomu optymalizacji 1 ( -O1). Twoja funkcja kompiluje się tak:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Co się tu dzieje? Po pierwsze, mamy porównanie: cmp BYTE PTR finished[rip], 0- to sprawdza, czy finishedjest fałszywe, czy nie.

Jeśli nie jest to fałsz (inaczej prawda), powinniśmy wyjść z pętli przy pierwszym uruchomieniu. To osiągnąć przez jne .L4który j umps gdy n ot e qual do etykiety .L4, gdy wartość i( 0) przechowywana jest w rejestrze do późniejszego wykorzystania i powrót funkcji.

Jeśli jednak jest to fałsz, przechodzimy do

.L5:
  jmp .L5

Jest to bezwarunkowy skok, .L5którego etykietą jest tak naprawdę samo polecenie skoku.

Innymi słowy, wątek jest umieszczany w nieskończonej zajętej pętli.

Dlaczego to się stało?

Jeśli chodzi o optymalizator, wątki są poza jego zakresem. Zakłada, że ​​inne wątki nie odczytują ani nie zapisują zmiennych jednocześnie (ponieważ byłoby to UB wyścigu danych). Musisz powiedzieć, że nie może zoptymalizować dostępu. Właśnie tutaj pojawia się odpowiedź Scheffa. Nie zawracam sobie głowy powtórzeniem go.

Ponieważ optymalizatorowi nie powiedziano, że finishedzmienna może potencjalnie ulec zmianie podczas wykonywania funkcji, widzi, że finishednie jest modyfikowana przez samą funkcję i zakłada, że ​​jest stała.

Zoptymalizowany kod zapewnia dwie ścieżki kodu, które będą wynikały z wejścia do funkcji ze stałą wartością bool; albo uruchamia pętlę nieskończenie, albo pętla nigdy nie jest uruchamiana.

w -O0kompilatorze (zgodnie z oczekiwaniami) nie optymalizuje treści pętli i porównuje:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

dlatego funkcja, gdy niezoptymalizowana działa, brak atomowości tutaj zwykle nie stanowi problemu, ponieważ kod i typ danych są proste. Prawdopodobnie najgorsze, na co moglibyśmy się tutaj natknąć, to wartość, iktóra nie jest zgodna z tym, co powinno być.

Bardziej złożony system ze strukturami danych znacznie bardziej prawdopodobne jest uszkodzenie danych lub nieprawidłowe wykonanie.

Baldrickk
źródło
3
C ++ 11 sprawia, że ​​wątki i model pamięci obsługujący wątki są częścią samego języka. Oznacza to, że kompilatory nie mogą wymyślić zapisu nawet do atomiczmiennych niezmiennych w kodzie, który nie zapisuje tych zmiennych. np. if (cond) foo=1;nie można go przekształcić w asm, foo = cond ? 1 : foo;ponieważ jest tak, ponieważ ładunek + magazyn (nie atomowa RMW) może nadepnąć na zapis z innego wątku. Kompilatory już tego unikały, ponieważ chciały być przydatne do pisania programów wielowątkowych, ale C ++ 11 oficjalnie nakazało, aby kompilatory nie łamały kodu, w którym piszą 2 wątki a[1]ia[2]
Peter Cordes
2
Ale tak, inne niż zawyżenia temat kompilatory nie są świadomi wątków w ogóle , Twoja odpowiedź jest poprawna. UB wyścigów danych umożliwia podnoszenie obciążeń zmiennych nieatomowych, w tym globałów, oraz innych agresywnych optymalizacji, których oczekujemy dla kodu jednowątkowego. Programowanie MCU - optymalizacja C ++ O2 pęka, podczas gdy pętla na elektronice. SE jest moją wersją tego wyjaśnienia.
Peter Cordes
1
@PeterCordes: Jedną z zalet Javy korzystającej z GC jest to, że pamięć dla obiektów nie będzie ponownie przetwarzana bez interwencji globalnej bariery pamięci między starym i nowym użyciem, co oznacza, że ​​każdy rdzeń, który bada obiekt, zawsze zobaczy pewną wartość, którą posiada które odbyło się w pewnym momencie po opublikowaniu odniesienia. Chociaż globalne bariery pamięci mogą być bardzo drogie, jeśli są często używane, mogą znacznie zmniejszyć zapotrzebowanie na bariery pamięci w innym miejscu, nawet jeśli są używane oszczędnie.
supercat
1
Tak, wiedziałem, że to właśnie próbujesz powiedzieć, ale nie sądzę, że twoje sformułowanie 100% to znaczy. Powiedzenie optymalizatora „całkowicie je ignoruje”. nie jest całkiem w porządku: dobrze wiadomo, że prawdziwe ignorowanie wątków podczas optymalizacji może obejmować ładowanie / modyfikowanie bajtu w magazynie słów / słów, co w praktyce powodowało błędy, w których dostęp jednego wątku do znaku char lub pola bitowego na napisz do sąsiedniego członka struktury. Zobacz lwn.net/Articles/478657 do pełnej historii, i jak tylko model pamięci C11 / C ++ 11 sprawia, że taka optymalizacja nielegalne, nie tylko niepożądane w praktyce.
Peter Cordes
1
Nie, to dobrze .. Dzięki @PeterCordes. Doceniam poprawę.
Baldrickk
5

Ze względu na kompletność w krzywej uczenia się; powinieneś unikać używania zmiennych globalnych. Wykonałeś dobrą robotę, ustawiając go na statyczny, więc będzie on lokalny dla jednostki tłumaczącej.

Oto przykład:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Na żywo w Wandbox

Zapomnienie
źródło
1
Można również zadeklarować finishedjako staticw bloku funkcyjnym. Nadal będzie inicjalizowany tylko raz, a jeśli zostanie zainicjowany na stałą, nie wymaga to blokowania.
Davislor,
Dostęp do finishedmoże również wykorzystywać tańsze std::memory_order_relaxedładunki i sklepy; nie ma wymaganego wrt zamówienia. inne zmienne w każdym wątku. Nie jestem pewien sugestii @ Davislorstatic ma sens; jeśli masz wiele wątków z liczbą obrotów, nie musisz zatrzymywać ich wszystkich z tą samą flagą. Chcesz napisać inicjalizację finishedw sposób, który kompiluje się do samej inicjalizacji, a nie do magazynu atomowego. (Podobnie jak w przypadku finished = false;domyślnej składni inicjalizującej C ++ 17. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Umieszczenie flagi w obiekcie pozwala na istnienie więcej niż jednej dla różnych pul wątków, jak mówisz. Oryginalny projekt miał jednak jedną flagę dla wszystkich wątków.
Davislor