Słyszałem, że i ++ nie jest instrukcją bezpieczną dla wątków, ponieważ w asemblerze ogranicza się do przechowywania oryginalnej wartości gdzieś jako temp, zwiększania jej, a następnie zastępowania, co może zostać przerwane przez zmianę kontekstu.
Zastanawiam się jednak nad ++ i. O ile wiem, sprowadziłoby się to do pojedynczej instrukcji asemblacyjnej, takiej jak „dodaj r1, r1, 1”, a ponieważ jest to tylko jedna instrukcja, nie można jej przerywać przez zmianę kontekstu.
Czy ktoś może wyjaśnić? Zakładam, że używana jest platforma x86.
c++
c
multithreading
samoz
źródło
źródło
Odpowiedzi:
Źle usłyszałeś. Może się zdarzyć, że
"i++"
jest to bezpieczne wątkowo dla określonego kompilatora i określonej architektury procesora, ale w ogóle nie jest to wymagane w standardach. W rzeczywistości, ponieważ wielowątkowość nie jest częścią standardów ISO C ani C ++ (a) , nie można uznać niczego za bezpieczne wątkowo na podstawie tego, do czego będzie się kompilować.Całkiem wykonalne jest
++i
kompilowanie do dowolnej sekwencji, takiej jak:load r0,[i] ; load memory into reg 0 incr r0 ; increment reg 0 stor [i],r0 ; store reg 0 back to memory
który nie byłby bezpieczny wątkowo na moim (urojonym) procesorze, który nie ma instrukcji zwiększania pamięci. Lub może być sprytny i skompilować go do:
lock ; disable task switching (interrupts) load r0,[i] ; load memory into reg 0 incr r0 ; increment reg 0 stor [i],r0 ; store reg 0 back to memory unlock ; enable task switching (interrupts)
gdzie
lock
wyłącza iunlock
włącza przerwania. Ale nawet wtedy może to nie być bezpieczne wątkowo w architekturze, w której więcej niż jeden z tych procesorów współużytkuje pamięć (lock
może wyłączać przerwania tylko dla jednego procesora).Sam język (lub jego biblioteki, jeśli nie jest wbudowany w język) zapewni konstrukcje bezpieczne dla wątków i powinieneś ich używać, zamiast polegać na zrozumieniu (lub prawdopodobnie niezrozumieniu) tego, jaki kod maszynowy zostanie wygenerowany.
Rzeczy takie jak Java
synchronized
ipthread_mutex_lock()
(dostępne dla C / C ++ w niektórych systemach operacyjnych) są tym, czego należy się przyjrzeć (a) .(a) To pytanie zostało zadane przed ukończeniem standardów C11 i C ++ 11. Te iteracje wprowadziły teraz obsługę wątków do specyfikacji języka, w tym atomowych typów danych (chociaż one i ogólnie wątki są opcjonalne, przynajmniej w C).
źródło
Nie możesz wydać ogólnego oświadczenia na temat ++ i lub i ++. Czemu? Rozważ zwiększenie 64-bitowej liczby całkowitej w systemie 32-bitowym. O ile maszyna bazowa nie ma instrukcji „ładowanie, zwiększanie, przechowywanie” składającej się z czterech słów, zwiększanie tej wartości będzie wymagało wielu instrukcji, z których każda może zostać przerwana przez przełącznik kontekstu wątku.
Ponadto
++i
nie zawsze „dodaj jeden do wartości”. W języku takim jak C inkrementacja wskaźnika w rzeczywistości dodaje rozmiar wskazywanej rzeczy. Oznacza to, że jeślii
jest wskaźnikiem do struktury 32-bajtowej,++i
dodaje 32 bajty. Podczas gdy prawie wszystkie platformy mają instrukcję „zwiększania wartości pod adresem pamięci”, która jest atomowa, nie wszystkie mają atomową instrukcję „dodaj dowolną wartość do wartości pod adresem pamięci”.źródło
Oba są niebezpieczne dla wątków.
Procesor nie może wykonywać obliczeń matematycznych bezpośrednio z pamięcią. Robi to pośrednio, ładując wartość z pamięci i wykonując obliczenia na rejestrach procesora.
i ++
register int a1, a2; a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i; a2 = a1; a1 += 1; *(&i) = a1; return a2; // 4 cpu instructions
++ i
register int a1; a1 = *(&i) ; a1 += 1; *(&i) = a1; return a1; // 3 cpu instructions
W obu przypadkach występuje warunek wyścigu, który powoduje nieprzewidywalną wartość i.
Na przykład załóżmy, że istnieją dwa współbieżne wątki ++ i, z których każdy używa odpowiednio rejestrów a1, b1. I z przełączaniem kontekstu wykonanym w następujący sposób:
W rezultacie ja nie staje się i + 2, staje się i + 1, co jest niepoprawne.
Aby temu zaradzić, moden CPU zapewnia pewnego rodzaju instrukcje LOCK, UNLOCK cpu podczas okresu, w którym przełączanie kontekstu jest wyłączone.
W Win32 użyj funkcji InterlockedIncrement (), aby wykonać i ++ w celu zapewnienia bezpieczeństwa wątków. To znacznie szybsze niż poleganie na muteksie.
źródło
Jeśli dzielisz się nawet intem między wątkami w środowisku wielordzeniowym, potrzebujesz odpowiednich barier pamięci. Może to oznaczać używanie powiązanych instrukcji (zobacz na przykład InterlockedIncrement w win32) lub użycie języka (lub kompilatora), który zapewnia pewne gwarancje bezpieczeństwa wątków. Dzięki zmianie kolejności instrukcji na poziomie procesora i pamięci podręcznej oraz innym problemom, o ile nie masz tych gwarancji, nie zakładaj, że cokolwiek udostępniane między wątkami jest bezpieczne.
Edycja: Jedną rzeczą, którą możesz założyć w przypadku większości architektur, jest to, że jeśli masz do czynienia z odpowiednio wyrównanymi pojedynczymi słowami, nie otrzymasz jednego słowa zawierającego kombinację dwóch wartości, które zostały zmiksowane razem. Jeśli dwa zapisy wystąpią jeden na drugim, jeden wygra, a drugi zostanie odrzucony. Jeśli jesteś ostrożny, możesz to wykorzystać i zobaczyć, że zarówno ++ i, jak i i ++ są bezpieczne dla wątków w sytuacji z jednym zapisującym / wieloma czytnikami.
źródło
Jeśli chcesz uzyskać atomowy przyrost w C ++, możesz użyć bibliotek C ++ 0x (
std::atomic
typ danych) lub czegoś takiego jak TBB.Kiedyś wytyczne GNU dotyczące kodowania mówiły, że aktualizacja typów danych, które pasują do jednego słowa, jest „zwykle bezpieczna”, ale ta rada jest błędna dla maszyn SMP,
błędna dla niektórych architekturi błędna, gdy używa się optymalizującego kompilatora.Aby wyjaśnić komentarz dotyczący „aktualizowania jednowyrazowego typu danych”:
Możliwe jest, że dwa procesory na maszynie SMP zapisują w tej samej lokalizacji pamięci w tym samym cyklu, a następnie próbują propagować zmianę na inne procesory i pamięć podręczną. Nawet jeśli zapisywane jest tylko jedno słowo danych, więc zapisy trwają tylko jeden cykl, są one również wykonywane jednocześnie, więc nie można zagwarantować, który zapis się powiedzie. Nie otrzymasz częściowo zaktualizowanych danych, ale jeden zapis zniknie, ponieważ nie ma innego sposobu na rozwiązanie tego przypadku.
Porównaj i zamień poprawnie współrzędne między wieloma procesorami, ale nie ma powodu, aby sądzić, że każda zmienna przypisana jednowyrazowym typom danych będzie używać funkcji porównania i zamiany.
I chociaż optymalizujący kompilator nie wpływa na sposób kompilacji ładowania / przechowywania, może się zmienić, gdy nastąpi ładowanie / przechowywanie, powodując poważne problemy, jeśli spodziewasz się, że odczyty i zapisy będą się odbywać w tej samej kolejności, w jakiej pojawiają się w kodzie źródłowym ( najsłynniejsze jest podwójnie sprawdzane blokowanie nie działa w waniliowym C ++).
UWAGA Moja oryginalna odpowiedź również mówiła, że 64-bitowa architektura Intela była zepsuta podczas radzenia sobie z 64-bitowymi danymi. To nieprawda, więc zredagowałem odpowiedź, ale moja edycja twierdziła, że chipy PowerPC są zepsute. Jest to prawdą podczas wczytywania wartości bezpośrednich (tj. Stałych) do rejestrów (zobacz dwie sekcje o nazwie „Wskaźniki ładowania” pod listą 2 i listą 4). Ale jest instrukcja ładowania danych z pamięci w jednym cyklu (
lmw
), więc usunąłem tę część mojej odpowiedzi.źródło
Na x86 / Windows w C / C ++ nie powinieneś zakładać, że jest bezpieczny wątkowo. Powinieneś użyć InterlockedIncrement () i InterlockedDecrement (), jeśli potrzebujesz niepodzielnych operacji.
źródło
Jeśli twój język programowania nie mówi nic o wątkach, ale działa na platformie wielowątkowej, w jaki sposób jakikolwiek język programowania może być bezpieczny dla wątków?
Jak wskazywali inni: każdy wielowątkowy dostęp do zmiennych należy chronić za pomocą wywołań specyficznych dla platformy.
Istnieją biblioteki, które abstrahują od specyfiki platformy, a nadchodzący standard C ++ dostosował swój model pamięci do obsługi wątków (a tym samym może zagwarantować bezpieczeństwo wątków).
źródło
Nawet jeśli jest zredukowany do pojedynczej instrukcji asemblera, zwiększając wartość bezpośrednio w pamięci, nadal nie jest bezpieczny dla wątków.
Podczas zwiększania wartości w pamięci sprzęt wykonuje operację „odczyt-modyfikacja-zapis”: odczytuje wartość z pamięci, zwiększa ją i zapisuje z powrotem do pamięci. Sprzęt x86 nie ma możliwości inkrementacji bezpośrednio w pamięci; pamięć RAM (i pamięci podręczne) może tylko odczytywać i przechowywać wartości, a nie je modyfikować.
Teraz załóżmy, że masz dwa oddzielne rdzenie, albo w oddzielnych gniazdach, albo współużytkujące jedno gniazdo (z lub bez współdzielonej pamięci podręcznej). Pierwszy procesor odczytuje wartość i zanim będzie mógł ponownie zapisać zaktualizowaną wartość, drugi procesor odczytuje ją. Po tym, jak oba procesory zapiszą wartość z powrotem, zostanie ona zwiększona tylko raz, a nie dwukrotnie.
Jest sposób na uniknięcie tego problemu; Procesory x86 (i większość procesorów wielordzeniowych, które znajdziesz) są w stanie wykryć tego rodzaju konflikty w sprzęcie i ustawić ich sekwencję, dzięki czemu cała sekwencja odczytu-modyfikacji-zapisu wydaje się atomowa. Jednakże, ponieważ jest to bardzo kosztowne, odbywa się to tylko na żądanie kodu, na x86 zwykle za pomocą
LOCK
prefiksu. Inne architektury mogą to robić na inne sposoby, z podobnymi wynikami; na przykład, funkcja „load-linked / store-conditional” i „atomic Compare-and-swap” (ostatnie procesory x86 również mają to ostatnie).Zauważ, że użycie
volatile
tutaj nie pomaga; informuje tylko kompilator, że zmienna mogła zostać zmodyfikowana zewnętrznie i odczyty do tej zmiennej nie mogą być buforowane w rejestrze ani optymalizowane. Nie powoduje to, że kompilator używa atomowych prymitywów.Najlepszym sposobem jest użycie atomowych prymitywów (jeśli Twój kompilator lub biblioteki je mają) lub wykonanie inkrementacji bezpośrednio w asemblerze (używając poprawnych instrukcji atomowych).
źródło
Nigdy nie zakładaj, że przyrost skompiluje się do operacji atomowej. Użyj funkcji InterlockedIncrement lub innych podobnych funkcji na platformie docelowej.
Edycja: właśnie sprawdziłem to konkretne pytanie i przyrost na X86 jest atomowy w systemach jednoprocesorowych, ale nie w systemach wieloprocesorowych. Użycie przedrostka blokady może uczynić go atomowym, ale jest znacznie bardziej przenośne, aby użyć tylko funkcji InterlockedIncrement.
źródło
Zgodnie z tą lekcją asemblera na x86, możesz atomowo dodać rejestr do lokalizacji pamięci , więc potencjalnie twój kod może niepodzielnie wykonać '++ i' ou 'i ++'. Ale jak powiedziano w innym poście, język C ansi nie stosuje atomowości do operacji „++”, więc nie możesz być pewien, co wygeneruje Twój kompilator.
źródło
Standard C ++ z 1998 roku nie ma nic do powiedzenia na temat wątków, chociaż następny standard (w tym lub przyszłym roku) już tak. Dlatego nie można powiedzieć nic inteligentnego o bezpieczeństwie operacji bez odwoływania się do implementacji. Nie chodzi tylko o używany procesor, ale także o połączenie kompilatora, systemu operacyjnego i modelu wątku.
W przypadku braku dokumentacji przeciwnej nie zakładałbym, że jakiekolwiek działanie jest bezpieczne dla wątków, szczególnie w przypadku procesorów wielordzeniowych (lub systemów wieloprocesorowych). Nie ufałbym też testom, ponieważ problemy z synchronizacją wątków mogą pojawić się tylko przez przypadek.
Nic nie jest bezpieczne dla wątków, chyba że masz dokumentację, która mówi, że jest to dla konkretnego systemu, którego używasz.
źródło
Wrzuć i do lokalnego magazynu wątków; nie jest atomowy, ale to nie ma znaczenia.
źródło
AFAIK, Zgodnie ze standardem C ++, odczyt / zapis do pliku
int
jest atomowy.Jednak wszystko to pozwala pozbyć się niezdefiniowanego zachowania związanego z wyścigiem danych.
Ale nadal będzie wyścig danych, jeśli oba wątki spróbują zwiększyć
i
.Wyobraź sobie następujący scenariusz:
Niech
i = 0
początkowo:Wątek A odczytuje wartość z pamięci i przechowuje we własnej pamięci podręcznej. Wątek A zwiększa wartość o 1.
Wątek B odczytuje wartość z pamięci i przechowuje we własnej pamięci podręcznej. Wątek B zwiększa wartość o 1.
Jeśli to wszystko jest pojedynczym wątkiem, dostaniesz się
i = 2
w pamięć.Ale w przypadku obu wątków każdy wątek zapisuje swoje zmiany, a więc Wątek A zapisuje z
i = 1
powrotem do pamięci, a Wątek B zapisujei = 1
do pamięci.Jest dobrze zdefiniowany, nie ma częściowego zniszczenia, konstrukcji ani jakiegokolwiek rozerwania obiektu, ale nadal jest to wyścig danych.
Aby zwiększyć atomowo
i
, możesz użyć:std::atomic<int>::fetch_add(1, std::memory_order_relaxed)
Można zastosować rozluźnione porządkowanie, ponieważ nie obchodzi nas, gdzie ma miejsce ta operacja, obchodzi nas tylko to, że operacja przyrostu jest atomowa.
źródło
Mówisz „to tylko jedna instrukcja, nie można jej przerywać za pomocą przełącznika kontekstu”. - to wszystko dobrze dla jednego procesora, ale co z dwurdzeniowym procesorem? Wtedy naprawdę możesz mieć dwa wątki uzyskujące dostęp do tej samej zmiennej w tym samym czasie bez żadnych przełączników kontekstu.
Nie znając języka, odpowiedzią jest przetestowanie tego do cholery.
źródło
Myślę, że jeśli wyrażenie "i ++" jest jedyne w instrukcji, jest to równoważne z "++ i", kompilator jest na tyle inteligentny, że nie zachowuje wartości czasowej itp. Więc jeśli możesz ich używać zamiennie (w przeciwnym razie wygrałeś nie pytaj, którego użyć), nie ma znaczenia, którego używasz, ponieważ są prawie takie same (z wyjątkiem estetyki).
W każdym razie, nawet jeśli operator inkrementacji jest niepodzielny, nie gwarantuje to, że reszta obliczeń będzie spójna, jeśli nie użyjesz poprawnych blokad.
Jeśli chcesz samemu poeksperymentować, napisz program, w którym N wątków jednocześnie zwiększa wspólną zmienną M razy każdy ... jeśli wartość jest mniejsza niż N * M, to jakiś przyrost został nadpisany. Wypróbuj zarówno z preinkrementacją, jak i postincrementem i powiedz nam ;-)
źródło
W przypadku licznika polecam użycie idiomu porównania i zamiany, który jest zarówno niezablokowany, jak i bezpieczny dla wątków.
Tutaj jest w Javie:
public class IntCompareAndSwap { private int value = 0; public synchronized int get(){return value;} public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){ int oldValue = value; if (oldValue == p_expectedValue) value = p_newValue; return oldValue; } } public class IntCASCounter { public IntCASCounter(){ m_value = new IntCompareAndSwap(); } private IntCompareAndSwap m_value; public int getValue(){return m_value.get();} public void increment(){ int temp; do { temp = m_value.get(); } while (temp != m_value.compareAndSwap(temp, temp + 1)); } public void decrement(){ int temp; do { temp = m_value.get(); } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1)); } }
źródło