Słyszałem, że i ++ nie jest bezpieczny dla wątków, czy ++ i jest bezpieczny dla wątków?


90

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.


Tylko pytanie. Jakie scenariusze wymagałyby, aby dwa (lub więcej) wątki miały dostęp do takiej zmiennej? Szczerze proszę tutaj, nie krytykuję. Właśnie o tej godzinie moja głowa nie może o niczym myśleć.
OscarRyz,

5
Zmienna klasy w klasie C ++ obsługująca liczbę obiektów?
paxdiablo,

1
Dobry film na ten temat, który właśnie dzisiaj obejrzałem, ponieważ powiedział mi inny facet: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb

1
zmieniono tag na C / C ++; Java nie jest tutaj brana pod uwagę, C # jest podobny, ale brakuje mu tak sztywno zdefiniowanej semantyki pamięci.
Tim Williscroft,

1
@Oscar Reyes Załóżmy, że masz dwa wątki, używając zmiennej i. Jeśli jeden wątek zwiększa gwint tylko wtedy, gdy znajduje się w pewnym punkcie, a drugi zmniejsza go tylko wtedy, gdy jest w innym miejscu, musiałbyś się martwić o bezpieczeństwo nici.
samoz

Odpowiedzi:


158

Ź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 ++ikompilowanie 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 lockwyłącza i unlockwłą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ęć ( lockmoż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 synchronizedi pthread_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).


8
+1 za podkreślenie, że nie jest to kwestia specyficzna dla platformy, nie wspominając o jasnej odpowiedzi ...
RBerteig

2
gratulacje za srebrną odznakę C :)
Johannes Schaub - litb

Myślę, że powinieneś sprecyzować, że żaden nowoczesny system operacyjny nie autoryzuje programów w trybie użytkownika do wyłączania przerwań, a pthread_mutex_lock () nie jest częścią C.
Bastien Léonard

@Bastien, żaden nowoczesny system operacyjny nie działałby na procesorze, który nie miałby instrukcji zwiększania pamięci :-) Ale twój punkt widzenia dotyczy C.
paxdiablo

5
@Bastien: Bull. Procesory RISC na ogół nie mają instrukcji zwiększania pamięci. Tryplet load / add / stor to sposób, w jaki robisz to na przykład na PowerPC.
derobert

42

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 ++inie 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śli ijest wskaźnikiem do struktury 32-bajtowej, ++idodaje 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”.


35
Oczywiście, jeśli nie ograniczysz się do nudnych 32-bitowych liczb całkowitych, w języku takim jak C ++, ++ mogę naprawdę być wywołaniem usługi sieciowej, która aktualizuje wartość w bazie danych.
Eclipse,

16

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:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

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.


6
„Procesor nie może wykonywać obliczeń matematycznych bezpośrednio z pamięcią” - to nie jest dokładne. Istnieją procesory, w których można wykonywać obliczenia matematyczne „bezpośrednio” na elementach pamięci, bez konieczności uprzedniego ładowania ich do rejestru. Na przykład. MC68000
darklon

1
Instrukcje LOCK i UNLOCK CPU nie mają nic wspólnego z przełącznikami kontekstu. Blokują linie pamięci podręcznej.
David Schwartz

11

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.


Faktycznie źle w środowiskach, w których dostęp int (odczyt / zapis) jest atomowy. Istnieją algorytmy, które mogą działać w takich środowiskach, nawet jeśli brak barier pamięciowych może oznaczać, że czasami pracujesz na nieaktualnych danych.
MSalters

2
Mówię tylko, że atomowość nie gwarantuje bezpieczeństwa nici. Jeśli jesteś na tyle sprytny, aby projektować wolne od zamków struktury danych lub algorytmy, śmiało. Ale nadal musisz wiedzieć, jakie gwarancje da ci twój kompilator.
Eclipse,

10

Jeśli chcesz uzyskać atomowy przyrost w C ++, możesz użyć bibliotek C ++ 0x ( std::atomictyp 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 architektur i 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.


Odczyty i zapisy są atomowe w większości nowoczesnych procesorów, jeśli dane są naturalnie wyrównane i mają prawidłowy rozmiar, nawet w przypadku SMP i optymalizujących kompilatorów. Istnieje jednak wiele zastrzeżeń, zwłaszcza w przypadku maszyn 64-bitowych, więc upewnienie się, że dane spełniają wymagania każdej maszyny, może być kłopotliwe.
Dan Olson,

Dzięki za aktualizację. Prawda, odczyty i zapisy są atomowe, ponieważ twierdzisz, że nie mogą być ukończone w połowie, ale Twój komentarz podkreśla, jak podchodzimy do tego faktu w praktyce. To samo z barierami pamięciowymi, nie wpływają one na atomowy charakter operacji, ale na to, jak podchodzimy do tego w praktyce.
Dan Olson,


4

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


4

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ą LOCKprefiksu. 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 volatiletutaj 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).


2

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.


1
InterlockedIncrement () to funkcja systemu Windows; wszystkie moje komputery z Linuksem i nowoczesne komputery z systemem OS X są oparte na architekturze x64, więc stwierdzenie, że InterlockedIncrement () jest „znacznie bardziej przenośny” niż kod x86, jest raczej fałszywe.
Pete Kirkham,

Jest znacznie bardziej przenośny w tym samym sensie, że C jest znacznie bardziej przenośny niż montaż. Celem jest odizolowanie się od polegania na konkretnym wygenerowanym zestawie dla określonego procesora. Jeśli martwisz się o inne systemy operacyjne, InterlockedIncrement można łatwo opakować.
Dan Olson,

2

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.


1

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.


1

Wrzuć i do lokalnego magazynu wątków; nie jest atomowy, ale to nie ma znaczenia.


1

AFAIK, Zgodnie ze standardem C ++, odczyt / zapis do pliku intjest 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 = 0począ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 = 2w pamięć.

Ale w przypadku obu wątków każdy wątek zapisuje swoje zmiany, a więc Wątek A zapisuje z i = 1powrotem do pamięci, a Wątek B zapisuje i = 1do 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.


0

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.


4
Nie można dowiedzieć się, czy coś jest bezpieczne dla wątków, testując to - problemy z wątkami mogą występować raz na milion. Sprawdzasz to w swojej dokumentacji. Jeśli dokumentacja nie gwarantuje ochrony wątków, nie jest.
Eclipse,

2
Zgadzam się z @Josh tutaj. Coś jest bezpieczne dla wątków tylko wtedy, gdy można to udowodnić matematycznie poprzez analizę kodu źródłowego. Żadna ilość testów nie może się do tego zbliżyć.
Rex M

To była świetna odpowiedź aż do ostatniego zdania.
Rob K

0

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


0

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

    }
}

Wygląda podobnie do funkcji test_and_set.
samoz

1
Napisałeś „bez blokowania”, ale czy „synchronizacja” nie oznacza blokowania?
Corey Trager
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.