Używam Cygwin GCC i uruchamiam ten kod:
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
unsigned u = 0;
void foo()
{
u++;
}
int main()
{
vector<thread> threads;
for(int i = 0; i < 1000; i++) {
threads.push_back (thread (foo));
}
for (auto& t : threads) t.join();
cout << u << endl;
return 0;
}
Zestawione z linii: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o
.
Drukuje 1000, co jest poprawne. Spodziewałem się jednak mniejszej liczby z powodu wątków nadpisujących poprzednio zwiększoną wartość. Dlaczego ten kod nie podlega wzajemnemu dostępowi?
Moja maszyna testowa ma 4 rdzenie i nie stawiam żadnych ograniczeń programowi, który znam.
Problem utrzymuje się przy zamianie treści udostępnionej na foo
coś bardziej złożonego np
if (u % 3 == 0) {
u += 4;
} else {
u -= 1;
}
c++
race-condition
mafu
źródło
źródło
u
wraca do pamięci. Procesor faktycznie zrobi niesamowite rzeczy, na przykład zauważy, że linia pamięciu
nie znajduje się w pamięci podręcznej procesora i ponownie uruchomi operację inkrementacji. Dlatego przejście z x86 na inną architekturę może otworzyć oczy!while true; do res=$(./a.out); if [[ $res != 1000 ]]; then echo $res; break; fi; done;
999 lub 998 w moim systemie.Odpowiedzi:
foo()
jest tak krótka, że każdy wątek prawdopodobnie kończy się, zanim następny w ogóle się pojawi. Jeśli dodasz sen na losowy czasfoo()
przedu++
, możesz zacząć widzieć, czego się spodziewasz.źródło
Ważne jest, aby zrozumieć, że sytuacja wyścigu nie gwarantuje, że kod będzie działał niepoprawnie, a jedynie, że może zrobić cokolwiek, ponieważ jest to niezdefiniowane zachowanie. W tym bieganie zgodnie z oczekiwaniami.
Szczególnie na maszynach X86 i AMD64 warunki wyścigu w niektórych przypadkach rzadko powodują problemy, ponieważ wiele instrukcji jest atomowych, a gwarancje spójności są bardzo wysokie. Gwarancje te są nieco ograniczone w systemach wieloprocesorowych, w których przedrostek blokady jest wymagany, aby wiele instrukcji było atomowych.
Jeśli inkrementacja na twoim komputerze jest atomową operacją, prawdopodobnie będzie działać poprawnie, mimo że zgodnie ze standardem językowym jest to niezdefiniowane zachowanie.
W szczególności spodziewam się, że w tym przypadku kod może być kompilowany do atomowej instrukcji Pobierz i dodaj (ADD lub XADD w zestawie X86), która jest rzeczywiście atomowa w systemach jednoprocesorowych, jednak w systemach wieloprocesorowych nie ma gwarancji, że będzie to atomowa i blokada byłby do tego zobowiązany. Jeśli korzystasz z systemu wieloprocesorowego, pojawi się okno, w którym wątki mogą zakłócać działanie i generować nieprawidłowe wyniki.
W szczególności skompilowałem twój kod do asemblera przy użyciu https://godbolt.org/ i
foo()
kompiluję do:Oznacza to, że wykonuje wyłącznie instrukcję dodawania, która dla pojedynczego procesora będzie atomowa (chociaż, jak wspomniano powyżej, nie dotyczy to systemu wieloprocesorowego).
źródło
inc [u]
nie jest atomowy.LOCK
Prefiks jest wymagane, aby instrukcja prawdziwie atomowy. PO po prostu ma szczęście. Przypomnij sobie, że nawet jeśli mówisz procesorowi „dodaj 1 do słowa pod tym adresem”, procesor nadal musi pobierać, zwiększać i przechowywać tę wartość, a inny procesor może robić to samo jednocześnie, powodując niepoprawny wynik.Myślę, że nie chodzi o to, że kładziesz się spać przed lub po
u++
. Chodzi raczej o to, że operacjau++
przekłada się na kod, który jest - w porównaniu z narzutem związanym z tworzeniem się wątków, które wywołująfoo
- bardzo szybko wykonywany w taki sposób, że jest mało prawdopodobne, aby został przechwycony. Jeśli jednak „przedłużysz” operacjęu++
, stan wyścigu stanie się znacznie bardziej prawdopodobny:wynik:
694
BTW: też próbowałem
i dawało mi to większość razy
1997
, ale czasami1995
.źródło
else u -= 1
kiedykolwiek zostać stracony? Nawet w równoległym środowisku wartość nigdy nie powinna nie pasować%2
, prawda?else u -= 1
jest wykonywane raz, przy pierwszym wywołaniu foo (), gdy u == 0. Pozostałe 999 razy u jest nieparzyste iu += 2
jest wykonywane, w wyniku czego u = -1 + 999 * 2 = 1997; tj. prawidłowe wyjście. Stan wyścigu czasami powoduje nadpisanie jednego z + = 2 przez równoległy wątek i otrzymujesz 1995.Cierpi na stan wyścigu. Umieścić
usleep(1000);
zanimu++;
sięfoo
i widzę inny wyjściowy (<1000) za każdym razem.źródło
Przewidywany odpowiedź dlaczego wyścigu nie manifest dla ciebie, choć nie istnieje, jest to, że
foo()
jest tak szybki, w porównaniu do czasu potrzebnego do rozpoczęcia wątku, że każdy kończy gwint przed następnym mogę nawet zacząć. Ale...Nawet w przypadku oryginalnej wersji wynik różni się w zależności od systemu: wypróbowałem ją na (czterordzeniowym) Macbooku i po dziesięciu uruchomieniach uzyskałem 1000 trzy razy, 999 sześć razy i 998 raz. Tak więc rasa jest dość rzadka, ale wyraźnie obecna.
Skompilowałeś z
'-g'
, który ma sposób na znikanie błędów. Ponownie skompilowałem Twój kod, wciąż niezmieniony, ale bez znaku'-g'
, i wyścig stał się znacznie wyraźniejszy: dostałem 1000 raz, 999 trzy razy, 998 dwa razy, 997 dwa razy, 996 raz i 992 raz.Re. sugestia dodania uśpienia - to pomaga, ale (a) ustalony czas uśpienia sprawia, że wątki są nadal wypaczone przez czas rozpoczęcia (zależnie od rozdzielczości timera), oraz (b) losowy sen rozciąga je, gdy chcemy przyciągnij je bliżej siebie. Zamiast tego zakodowałbym je, aby czekały na sygnał startu, więc mogę je wszystkie utworzyć, zanim pozwolę im zabrać się do pracy. W tej wersji (z lub bez
'-g'
) otrzymuję wyniki we wszystkich miejscach, tak niskie, jak 974, ale nie wyższe niż 998:źródło
-g
Flaga nie ma w żaden sposób „make błędy zniknie.”-g
Flaga na obu kompilatorów GNU i brzękiem po prostu dodaje się do symboli debugowania skompilowany binarny. Pozwala to na uruchamianie narzędzi diagnostycznych, takich jak GDB i Memcheck, w programach z danymi w postaci czytelnej dla człowieka. Na przykład, gdy Memcheck jest uruchamiany na programie z wyciekiem pamięci, nie poda numeru wiersza, chyba że program został zbudowany przy użyciu-g
flagi.-O2
zamiast z-g
”. Ale to powiedziawszy, jeśli nigdy nie miałeś radości z polowania na błąd, który ujawniłby się tylko wtedy, gdy zostałby skompilowany bez-g
, uważaj się za szczęściarza. Może się to zdarzyć w przypadku niektórych z najgorszych z subtelnych błędów aliasingu. Ja nie widziałem, choć nie niedawno i mogłem uwierzyć, może to był kaprys starego własnego kompilatora, więc będę ci wierzyć, tymczasowo, o nowoczesnej wersji GNU i Clang.-g
nie powstrzymuje Cię przed korzystaniem z optymalizacji. np.gcc -O3 -g
tworzy taki sam asm jakgcc -O3
, ale z metadanymi debugowania. gdb powie „zoptymalizowane”, jeśli spróbujesz wydrukować niektóre zmienne.-g
może zmienić względne położenie niektórych rzeczy w pamięci, jeśli którykolwiek z elementów, które dodaje, jest częścią.text
sekcji. Zdecydowanie zajmuje miejsce w pliku obiektowym, ale myślę, że po połączeniu to wszystko kończy się na jednym końcu segmentu tekstu (nie w sekcji) lub w ogóle nie jest częścią segmentu. Może może mieć wpływ na to, gdzie rzeczy są mapowane dla bibliotek dynamicznych.