Ich habe gehört, dass i ++ nicht threadsicher ist, ist ++ i threadsicher?

90

Ich habe gehört, dass i ++ keine thread-sichere Anweisung ist, da es in der Assembly darauf hinausgeht, den ursprünglichen Wert irgendwo als temporär zu speichern, ihn zu erhöhen und dann zu ersetzen, was durch einen Kontextwechsel unterbrochen werden könnte.

Ich wundere mich jedoch über ++ i. Soweit ich das beurteilen kann, würde sich dies auf eine einzelne Assembly-Anweisung reduzieren, z. B. 'add r1, r1, 1', und da es sich nur um eine Anweisung handelt, wäre sie durch einen Kontextwechsel nicht unterbrechbar.

Kann jemand klarstellen? Ich gehe davon aus, dass eine x86-Plattform verwendet wird.

Samoz
quelle
Nur eine Frage. Welche Szenarien würden für zwei (oder mehr) Threads erforderlich sein, um auf eine solche Variable zuzugreifen? Ich frage hier ehrlich, nicht kritisieren. Es ist gerade um diese Stunde, mein Kopf kann an nichts denken.
OscarRyz
5
Eine Klassenvariable in einer C ++ - Klasse, die eine Objektanzahl beibehält?
Paxdiablo
1
Gutes Video über die Sache, die ich gerade heute gesehen habe, weil mir ein anderer Mann gesagt hat: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb
1
neu markiert als C / C ++; Java wird hier nicht berücksichtigt, C # ist ähnlich, aber es fehlt eine so fest definierte Speichersemantik.
Tim Williscroft
1
@Oscar Reyes Angenommen, Sie haben zwei Threads, die beide die Variable i verwenden. Wenn Thread eins den Thread nur erhöht, wenn er sich an einem bestimmten Punkt befindet, und der andere den Thread nur verringert, wenn er sich an einem anderen Punkt befindet, müssten Sie sich um die Sicherheit des Threads sorgen.
Samoz

Antworten:

157

Du hast falsch gehört. Es kann durchaus sein, dass dies "i++"für einen bestimmten Compiler und eine bestimmte Prozessorarchitektur threadsicher ist, aber in den Standards überhaupt nicht vorgeschrieben ist. Da Multithreading nicht Teil der ISO C- oder C ++ - Standards (a) ist , können Sie nichts als threadsicher betrachten, basierend auf dem, was Ihrer Meinung nach kompiliert wird.

Es ist durchaus machbar, dass ++ieine beliebige Reihenfolge wie folgt kompiliert werden kann:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

Das wäre auf meiner (imaginären) CPU, die keine Anweisungen zum Inkrementieren des Speichers enthält, nicht threadsicher. Oder es kann klug sein und es kompilieren in:

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)

wo lockdeaktiviert und unlockaktiviert Interrupts. Aber selbst dann ist dies in einer Architektur mit mehr als einer dieser CPUs, die sich den Speicher teilen, möglicherweise nicht threadsicher ( lockmöglicherweise werden nur Interrupts für eine CPU deaktiviert).

Die Sprache selbst (oder Bibliotheken dafür, wenn sie nicht in die Sprache integriert ist) bietet threadsichere Konstrukte, und Sie sollten diese verwenden, anstatt von Ihrem Verständnis (oder möglicherweise Missverständnissen) davon abzuhängen, welcher Maschinencode generiert wird.

Dinge wie Java synchronizedund pthread_mutex_lock()(für C / C ++ unter einigen Betriebssystemen verfügbar) sind das, worauf Sie achten müssen (a) .


(a) Diese Frage wurde gestellt, bevor die Standards C11 und C ++ 11 abgeschlossen wurden. Diese Iterationen haben jetzt Threading-Unterstützung in die Sprachspezifikationen eingeführt, einschließlich atomarer Datentypen (obwohl sie und Threads im Allgemeinen zumindest in C optional sind ).

paxdiablo
quelle
8
+1 für die Betonung, dass dies kein plattformspezifisches Problem ist, ganz zu schweigen von einer klaren Antwort ...
RBerteig
2
Herzlichen Glückwunsch zu Ihrem C Silber Abzeichen :)
Johannes Schaub - litb
Ich denke, Sie sollten genau sagen, dass kein modernes Betriebssystem Programme im Benutzermodus zum Ausschalten von Interrupts autorisiert und pthread_mutex_lock () nicht Teil von C. ist
Bastien Léonard
@ Bastien, kein modernes Betriebssystem würde auf einer CPU laufen, die keine Speicherinkrementanweisung hatte :-) Aber Ihr Punkt ist über C.
paxdiablo
5
@ Bastien: Bull. RISC-Prozessoren haben im Allgemeinen keine Anweisungen zum Speichern von Speicher. Mit dem Tripplet Laden / Hinzufügen / Speichern können Sie dies beispielsweise auf PowerPC tun.
Derobert
42

Sie können weder für ++ i noch für i ++ eine pauschale Aussage treffen. Warum? Erwägen Sie, eine 64-Bit-Ganzzahl auf einem 32-Bit-System zu erhöhen. Sofern die zugrunde liegende Maschine nicht über eine Quad-Wort-Anweisung "Laden, Inkrementieren, Speichern" verfügt, erfordert das Inkrementieren dieses Werts mehrere Anweisungen, von denen jede durch einen Thread-Kontextwechsel unterbrochen werden kann.

Zusätzlich, ++i ist nicht immer "eine zum Wert hinzufügen". In einer Sprache wie C erhöht das Inkrementieren eines Zeigers tatsächlich die Größe des Objekts, auf das gezeigt wird. Das heißt, wenn ies sich um einen Zeiger auf eine 32-Byte-Struktur handelt, werden ++i32 Bytes hinzugefügt. Während fast alle Plattformen einen atomaren Befehl "Inkrementwert an Speicheradresse" haben, haben nicht alle einen atomaren Befehl "Wert an Speicheradresse einen beliebigen Wert hinzufügen".

Jim Mischel
quelle
35
Wenn Sie sich nicht auf langweilige 32-Bit-Ganzzahlen in einer Sprache wie C ++, ++ beschränken, kann ich natürlich wirklich einen Webservice aufrufen, der einen Wert in einer Datenbank aktualisiert.
Eclipse
16

Sie sind beide thread-unsicher.

Eine CPU kann nicht direkt mit dem Speicher rechnen. Dies geschieht indirekt, indem der Wert aus dem Speicher geladen und mit CPU-Registern berechnet wird.

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

In beiden Fällen gibt es eine Racebedingung, die zu einem unvorhersehbaren i-Wert führt.

Angenommen, es gibt zwei gleichzeitige ++ i-Threads, die jeweils das Register a1 bzw. b1 verwenden. Und mit Kontextumschaltung wie folgt ausgeführt:

register int a1, b1;

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

Im Ergebnis wird i nicht zu i + 2, sondern zu i + 1, was falsch ist.

Um dies zu beheben, stellen Moden-CPUs während des Intervalls, in dem eine Kontextumschaltung deaktiviert ist, eine Art LOCK, UNLOCK-CPU-Anweisungen bereit.

Verwenden Sie unter Win32 InterlockedIncrement (), um i ++ für die Thread-Sicherheit auszuführen. Es ist viel schneller als sich auf Mutex zu verlassen.

Yogman
quelle
6
"Eine CPU kann nicht direkt mit dem Speicher rechnen" - Dies ist nicht korrekt. Es gibt CPU-s, bei denen Sie "direkt" auf Speicherelementen rechnen können, ohne diese zuerst in ein Register laden zu müssen. Z.B. MC68000
Darklon
1
LOCK- und UNLOCK-CPU-Anweisungen haben nichts mit Kontextwechseln zu tun. Sie sperren Cache-Zeilen.
David Schwartz
11

Wenn Sie in einer Multi-Core-Umgebung sogar ein Int über mehrere Threads hinweg gemeinsam nutzen, benötigen Sie geeignete Speicherbarrieren. Dies kann bedeuten, dass Sie gesperrte Anweisungen verwenden (siehe z. B. InterlockedIncrement in Win32) oder eine Sprache (oder einen Compiler) verwenden, die bestimmte threadsichere Garantien bietet. Bei der Neuanordnung von Anweisungen auf CPU-Ebene sowie bei Caches und anderen Problemen sollten Sie, sofern Sie nicht über diese Garantien verfügen, nicht davon ausgehen, dass die gemeinsame Nutzung von Threads sicher ist.

Bearbeiten: Eine Sache, die Sie bei den meisten Architekturen annehmen können, ist, dass Sie, wenn Sie mit richtig ausgerichteten Einzelwörtern arbeiten, nicht ein einzelnes Wort erhalten, das eine Kombination aus zwei Werten enthält, die zusammengefügt wurden. Wenn zwei Schreibvorgänge übereinander erfolgen, gewinnt einer und der andere wird verworfen. Wenn Sie vorsichtig sind, können Sie dies nutzen und feststellen, dass entweder ++ i oder i ++ in der Situation mit einem einzelnen Schreiber / mehreren Lesern threadsicher sind.

Finsternis
quelle
Faktisch falsch in Umgebungen, in denen der int-Zugriff (Lesen / Schreiben) atomar ist. Es gibt Algorithmen, die in solchen Umgebungen funktionieren können, obwohl das Fehlen von Speicherbarrieren dazu führen kann, dass Sie manchmal an veralteten Daten arbeiten.
MSalters
2
Ich sage nur, dass Atomizität keine Fadensicherheit garantiert. Wenn Sie klug genug sind, um sperrenfreie Datenstrukturen oder Algorithmen zu entwerfen, fahren Sie fort. Sie müssen jedoch noch wissen, welche Garantien Ihr Compiler Ihnen geben wird.
Eclipse
10

Wenn Sie ein atomares Inkrement in C ++ wünschen, können Sie C ++ 0x-Bibliotheken (den std::atomicDatentyp) oder etwas wie TBB verwenden.

Es gab einmal eine Zeit, in der die GNU-Codierungsrichtlinien sagten, das Aktualisieren von Datentypen, die in ein Wort passen, sei "normalerweise sicher", aber dieser Rat ist für SMP-Maschinen falsch, für einige Architekturen falsch und bei Verwendung eines optimierenden Compilers falsch.


So verdeutlichen Sie den Kommentar "Aktualisieren des Datentyps mit einem Wort":

Es ist möglich, dass zwei CPUs auf einem SMP-Computer im selben Zyklus auf denselben Speicherort schreiben und dann versuchen, die Änderung auf die anderen CPUs und den Cache zu übertragen. Selbst wenn nur ein Datenwort geschrieben wird, sodass die Schreibvorgänge nur einen Zyklus dauern, werden sie gleichzeitig ausgeführt, sodass Sie nicht garantieren können, welcher Schreibvorgang erfolgreich ist. Sie erhalten keine teilweise aktualisierten Daten, aber ein Schreibvorgang verschwindet, da es keine andere Möglichkeit gibt, diesen Fall zu behandeln.

Das richtige Vergleichen und Austauschen koordiniert zwischen mehreren CPUs, aber es gibt keinen Grund zu der Annahme, dass jede variable Zuweisung von Ein-Wort-Datentypen das Vergleichen und Austauschen verwendet.

Ein optimierender Compiler hat zwar keinen Einfluss darauf, wie ein Laden / Speichern kompiliert wird, kann sich jedoch ändern, wenn das Laden / Speichern erfolgt. Dies kann zu ernsthaften Problemen führen, wenn Sie erwarten, dass Ihre Lese- und Schreibvorgänge in derselben Reihenfolge erfolgen, in der sie im Quellcode angezeigt werden ( Das bekannteste doppelt überprüfte Sperren funktioniert in Vanilla C ++ nicht.

HINWEIS Meine ursprüngliche Antwort besagte auch, dass die 64-Bit-Architektur von Intel beim Umgang mit 64-Bit-Daten fehlerhaft war. Das ist nicht wahr, also habe ich die Antwort bearbeitet, aber meine Bearbeitung behauptete, PowerPC-Chips seien kaputt. Dies gilt auch für das Einlesen von Sofortwerten (dh Konstanten) in Register (siehe die beiden Abschnitte mit dem Namen "Laden von Zeigern" unter Listing 2 und Listing 4). Es gibt jedoch eine Anweisung zum Laden von Daten aus dem Speicher in einem Zyklus ( lmw), sodass ich diesen Teil meiner Antwort entfernt habe.

Max Lybbert
quelle
Lese- und Schreibvorgänge sind auf den meisten modernen CPUs atomar, wenn Ihre Daten natürlich ausgerichtet sind und die richtige Größe haben, selbst mit SMP und optimierten Compilern. Es gibt jedoch viele Einschränkungen, insbesondere bei 64-Bit-Computern. Daher kann es umständlich sein, sicherzustellen, dass Ihre Daten den Anforderungen auf jedem Computer entsprechen.
Dan Olson
Vielen Dank für die Aktualisierung. Richtig, Lese- und Schreibvorgänge sind atomar, da Sie angeben, dass sie nicht zur Hälfte abgeschlossen werden können. Ihr Kommentar zeigt jedoch, wie wir diese Tatsache in der Praxis angehen. Wie bei Speicherbarrieren wirken sie sich nicht auf die atomare Natur der Operation aus, sondern darauf, wie wir sie in der Praxis angehen.
Dan Olson
4

Wenn Ihre Programmiersprache nichts über Threads aussagt und dennoch auf einer Multithread-Plattform ausgeführt wird, wie kann ein Sprachkonstrukt threadsicher sein?

Wie andere betonten: Sie müssen jeden Multithread-Zugriff auf Variablen durch plattformspezifische Aufrufe schützen.

Es gibt Bibliotheken, die die Plattformspezifität abstrahieren, und der kommende C ++ - Standard hat sein Speichermodell angepasst, um mit Threads fertig zu werden (und somit die Thread-Sicherheit zu gewährleisten).

xtofl
quelle
4

Selbst wenn es auf eine einzelne Assembly-Anweisung reduziert wird und der Wert direkt im Speicher erhöht wird, ist es immer noch nicht threadsicher.

Beim Inkrementieren eines Werts im Speicher führt die Hardware eine "Lese-Änderungs-Schreib" -Operation aus: Sie liest den Wert aus dem Speicher, erhöht ihn und schreibt ihn zurück in den Speicher. Die x86-Hardware kann nicht direkt im Speicher erhöht werden. Der RAM (und die Caches) können nur Werte lesen und speichern, nicht ändern.

Angenommen, Sie haben zwei separate Kerne, entweder auf separaten Sockets oder gemeinsam für einen einzelnen Socket (mit oder ohne gemeinsam genutzten Cache). Der erste Prozessor liest den Wert, und bevor er den aktualisierten Wert zurückschreiben kann, liest der zweite Prozessor ihn. Nachdem beide Prozessoren den Wert zurückgeschrieben haben, wurde er nur einmal und nicht zweimal erhöht.

Es gibt eine Möglichkeit, dieses Problem zu vermeiden. x86-Prozessoren (und die meisten Multi-Core-Prozessoren, die Sie finden werden) können diese Art von Hardwarekonflikt erkennen und sequenzieren, sodass die gesamte Lese-, Änderungs- und Schreibsequenz atomar erscheint. Da dies jedoch sehr kostspielig ist, erfolgt dies nur auf Anforderung des Codes, auf x86 normalerweise über dasLOCK Präfix. Andere Architekturen können dies auf andere Weise mit ähnlichen Ergebnissen tun. Zum Beispiel Load-Linked / Store-Conditional und Atomic Compare-and-Swap (neuere x86-Prozessoren haben auch diesen letzten).

Beachten Sie, dass die Verwendung volatilehier nicht hilft. Es teilt dem Compiler nur mit, dass die Variable möglicherweise extern geändert wurde, und das Lesen dieser Variablen darf nicht in einem Register zwischengespeichert oder optimiert werden. Der Compiler verwendet keine atomaren Grundelemente.

Der beste Weg ist, atomare Grundelemente zu verwenden (sofern Ihr Compiler oder Ihre Bibliotheken über diese verfügen) oder das Inkrement direkt in der Assembly durchzuführen (unter Verwendung der richtigen atomaren Anweisungen).

CesarB
quelle
2

Gehen Sie niemals davon aus, dass ein Inkrement zu einer atomaren Operation kompiliert wird. Verwenden Sie InterlockedIncrement oder ähnliche Funktionen auf Ihrer Zielplattform.

Bearbeiten: Ich habe gerade diese spezielle Frage nachgeschlagen und das Inkrement auf X86 ist auf Einzelprozessorsystemen atomar, aber nicht auf Multiprozessorsystemen. Die Verwendung des Sperrpräfixes kann es atomar machen, aber es ist viel portabler, nur InterlockedIncrement zu verwenden.

Dan Olson
quelle
1
InterlockedIncrement () ist eine Windows-Funktion. Alle meine Linux-Boxen und modernen OS X-Maschinen basieren auf x64. Daher ist es ziemlich falsch zu sagen, dass InterlockedIncrement () "viel portabler" ist als x86-Code.
Pete Kirkham
Es ist viel portabler in dem Sinne, dass C viel portabler ist als Assembly. Das Ziel hier ist es, sich davon abzuhalten, sich auf eine bestimmte generierte Baugruppe für einen bestimmten Prozessor zu verlassen. Wenn andere Betriebssysteme Ihr Anliegen sind, kann InterlockedIncrement problemlos verpackt werden.
Dan Olson
2

Gemäß dieser Assembler-Lektion auf x86 können Sie einem Speicherort atomar ein Register hinzufügen , sodass Ihr Code möglicherweise atomar '++ i' oder 'i ++' ausführen kann. Wie in einem anderen Beitrag erwähnt, wendet die C ansi keine Atomizität auf die '++' - Operation an, sodass Sie nicht sicher sein können, was Ihr Compiler generieren wird.

Selso Liberado
quelle
1

Der C ++ - Standard von 1998 hat nichts über Threads zu sagen, obwohl der nächste Standard (der in diesem oder im nächsten Jahr fällig ist) dies tut. Daher können Sie nichts Intelligentes über die Thread-Sicherheit von Vorgängen sagen, ohne sich auf die Implementierung zu beziehen. Es wird nicht nur der Prozessor verwendet, sondern auch die Kombination aus Compiler, Betriebssystem und Thread-Modell.

Ohne gegenteilige Dokumentation würde ich nicht davon ausgehen, dass eine Aktion threadsicher ist, insbesondere bei Mehrkernprozessoren (oder Multiprozessorsystemen). Ich würde Tests auch nicht vertrauen, da Thread-Synchronisationsprobleme wahrscheinlich nur zufällig auftreten.

Nichts ist threadsicher, es sei denn, Sie haben eine Dokumentation, die besagt, dass es sich um das von Ihnen verwendete System handelt.

David Thornley
quelle
1

Wirf i in den lokalen Thread-Speicher. es ist nicht atomar, aber es spielt dann keine Rolle.


quelle
1

AFAIK, Nach dem C ++ - Standard sind Lese- / Schreibvorgänge in einem intatomar.

Dies alles ist jedoch, das undefinierte Verhalten, das mit einem Datenrennen verbunden ist, loszuwerden.

Es wird jedoch immer noch ein Datenrennen geben, wenn beide Threads versuchen, sich zu erhöhen i.

Stellen Sie sich folgendes Szenario vor:

Lassen Sie i = 0zunächst:

Thread A liest den Wert aus dem Speicher und speichert ihn in seinem eigenen Cache. Thread A erhöht den Wert um 1.

Thread B liest den Wert aus dem Speicher und speichert ihn in seinem eigenen Cache. Thread B erhöht den Wert um 1.

Wenn dies alles ein einzelner Thread ist, würden Sie i = 2in den Speicher bekommen.

Bei beiden Threads schreibt jeder Thread seine Änderungen, sodass Thread A i = 1in den Speicher zurückschreibt und Thread B i = 1in den Speicher schreibt .

Es ist gut definiert, es gibt keine teilweise Zerstörung oder Konstruktion oder irgendeine Art von Zerreißen eines Objekts, aber es ist immer noch ein Datenrennen.

Um atomar zu erhöhen, ikönnen Sie verwenden:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

Eine entspannte Reihenfolge kann verwendet werden, da es uns egal ist, wo diese Operation stattfindet. Wir kümmern uns nur darum, dass die Inkrementierungsoperation atomar ist.

Moshe Rabaev
quelle
0

Sie sagen: "Es ist nur eine Anweisung, die durch einen Kontextwechsel nicht unterbrochen werden kann." - das ist alles gut und schön für eine einzelne CPU, aber was ist mit einer Dual-Core-CPU? Dann können wirklich zwei Threads gleichzeitig auf dieselbe Variable zugreifen, ohne dass Kontextwechsel erforderlich sind.

Ohne die Sprache zu kennen, ist die Antwort, sie zu testen.

Chris
quelle
4
Sie können nicht herausfinden, ob etwas threadsicher ist, indem Sie es testen. Threading-Probleme können eins zu einer Million Mal auftreten. Sie schlagen es in Ihrer Dokumentation nach. Wenn Ihre Dokumentation nicht threadsicher garantiert, ist dies nicht der Fall.
Eclipse
2
Stimmen Sie hier mit @Josh überein. Etwas ist nur dann threadsicher, wenn es durch eine Analyse des zugrunde liegenden Codes mathematisch bewiesen werden kann. Keine Menge von Tests kann sich dem annähern.
Rex M
Es war eine großartige Antwort bis zu diesem letzten Satz.
Rob K
0

Ich denke, wenn der Ausdruck "i ++" der einzige in einer Anweisung ist, der "++ i" entspricht, ist der Compiler klug genug, um keinen zeitlichen Wert usw. beizubehalten. Wenn Sie sie also austauschbar verwenden können (andernfalls haben Sie gewonnen Fragen Sie nicht, welche Sie verwenden sollen), es spielt keine Rolle, welche Sie verwenden, da sie fast gleich sind (mit Ausnahme der Ästhetik).

Selbst wenn der Inkrementoperator atomar ist, garantiert dies nicht, dass der Rest der Berechnung konsistent ist, wenn Sie nicht die richtigen Sperren verwenden.

Wenn Sie selbst experimentieren möchten, schreiben Sie ein Programm, bei dem N Threads gleichzeitig eine gemeinsame Variable M-mal inkrementieren ... Wenn der Wert kleiner als N * M ist, wurde ein gewisses Inkrement überschrieben. Probieren Sie es mit Vor- und Nachinkrement aus und sagen Sie es uns ;-)

fortran
quelle
0

Für einen Zähler empfehle ich die Verwendung der Vergleichs- und Austauschsprache, die sowohl nicht sperrend als auch threadsicher ist.

Hier ist es in Java:

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

    }
}
AtariPete
quelle
Scheint einer Funktion test_and_set ähnlich zu sein.
Samoz
1
Sie haben "nicht sperren" geschrieben, aber bedeutet "synchronisiert" nicht Sperren?
Corey Trager