Multithreading-Programm bleibt im optimierten Modus hängen, läuft aber normal in -O0

68

Ich habe ein einfaches Multithreading-Programm wie folgt geschrieben:

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

Es verhält sich normalerweise im Debug-Modus in Visual Studio oder -O0in gc c und druckt das Ergebnis nach 1Sekunden aus. Aber es blieb hängen und druckt nichts im Release- Modus oder -O1 -O2 -O3.

sz ppeter
quelle
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Samuel Liew

Antworten:

100

Zwei Threads, die auf eine nicht atomare, nicht geschützte Variable zugreifen, sind UB. Dies betrifft finished. Sie könnten einen finishedTyp std::atomic<bool>eingeben, um dies zu beheben.

Mein Fix:

#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;
}

Ausgabe:

result =1023045342
main thread id=140147660588864

Live-Demo auf coliru


Jemand könnte denken: 'Es ist ein bool- wahrscheinlich ein bisschen. Wie kann das nicht atomar sein? ' (Ich habe es getan, als ich selbst mit Multithreading angefangen habe.)

Beachten Sie jedoch, dass das Nicht-Zerreißen nicht das einzige ist, was std::atomic was Sie erhalten. Außerdem wird der gleichzeitige Lese- und Schreibzugriff von mehreren Threads genau definiert, sodass der Compiler nicht davon ausgehen kann, dass beim erneuten Lesen der Variablen immer derselbe Wert angezeigt wird.

Das boolErstellen eines unbewachten, nicht atomaren Geräts kann zusätzliche Probleme verursachen:

  • Der Compiler kann entscheiden, die Variable in ein Register oder sogar mehrere CSE-Zugriffe in einen zu optimieren und eine Last aus einer Schleife zu heben.
  • Die Variable kann für einen CPU-Kern zwischengespeichert werden. (Im wirklichen Leben haben CPUs kohärente Caches . Dies ist kein wirkliches Problem, aber der C ++ - Standard ist locker genug, um hypothetische C ++ - Implementierungen auf nicht kohärentem gemeinsam genutztem Speicher abzudecken, wo atomic<bool>mit memory_order_relaxedSpeichern / Laden funktionieren würde, aber wovolatile jedoch nicht. Verwenden volatil dafür wäre UB, obwohl es in der Praxis auf echten C ++ - Implementierungen funktioniert.)

Um dies zu verhindern, muss der Compiler ausdrücklich angewiesen werden, dies nicht zu tun.


Ich bin ein wenig überrascht über die sich entwickelnde Diskussion über die mögliche Beziehung volatilezu diesem Thema. Daher möchte ich meine zwei Cent ausgeben:

Scheff
quelle
4
Ich func()warf einen Blick darauf und dachte: "Ich könnte das weg optimieren." Der Optimierer kümmert sich überhaupt nicht um Threads und erkennt die Endlosschleife und verwandelt sie glücklich in eine "Weile (wahr)", wenn wir Godbolt betrachten .org / z / Tl44iN können wir das sehen. Wenn es fertig ist True, kehrt es zurück. Wenn nicht, geht es in einen bedingungslosen Sprung zurück zu sich selbst (eine Endlosschleife) bei Label.L5
Baldrickk
2
@val: Es gibt grundsätzlich keinen Grund, volatileC ++ 11 zu missbrauchen, da Sie mit atomic<T>und identisch mit asm werden können std::memory_order_relaxed. Auf realer Hardware funktioniert dies jedoch: Caches sind kohärent, sodass eine Ladeanweisung einen veralteten Wert nicht mehr lesen kann, sobald sich ein Speicher auf einem anderen Kern zum Cache verpflichtet. (MESI)
Peter Cordes
5
@PeterCordes Using volatileist jedoch immer noch UB. Sie sollten wirklich niemals davon ausgehen, dass UB definitiv und eindeutig sicher ist, nur weil Sie sich nicht vorstellen können, wie es schief gehen könnte und es funktioniert hat, als Sie es ausprobiert haben. Das hat Menschen immer und immer wieder verbrannt.
David Schwartz
2
@Damon Mutexes haben Release / Acquisition-Semantik. Der Compiler darf das Auslesen nicht optimieren, wenn zuvor ein Mutex gesperrt war, also finishedmit einem std::mutexWerk schützen (ohne volatileoder atomic). Tatsächlich können Sie alle Atomics durch ein "einfaches" Wert + Mutex-Schema ersetzen. es würde immer noch funktionieren und nur langsamer sein. atomic<T>darf einen internen Mutex verwenden; Nur atomic_flagist garantiert sperrenfrei.
Erlkoenig
42

Scheffs Antwort beschreibt, wie Sie Ihren Code reparieren können. Ich dachte, ich würde ein paar Informationen darüber hinzufügen, was in diesem Fall tatsächlich passiert.

Ich habe Ihren Code bei Godbolt mit Optimierungsstufe 1 ( -O1) kompiliert . Ihre Funktion kompiliert wie folgt:

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

Also, was passiert hier? Zuerst haben wir einen Vergleich: cmp BYTE PTR finished[rip], 0- Dies prüft, ob finishedfalsch ist oder nicht.

Wenn es nicht falsch ist (auch bekannt als wahr), sollten wir die Schleife beim ersten Lauf verlassen. Dies erreicht durchjne .L4 die j UMPS wenn n ot e Qual zu Label .L4wobei der Wert von i( 0) für eine spätere Verwendung in einem Register gespeichert wird , und die Funktion kehrt zurück.

Wenn es jedoch falsch ist, gehen wir zu

.L5:
  jmp .L5

Dies ist ein bedingungsloser Sprung zum Beschriften, .L5der zufällig der Sprungbefehl selbst ist.

Mit anderen Worten, der Thread wird in eine Endlos-Besetzt-Schleife gebracht.

Warum ist das passiert?

Für den Optimierer liegen Threads außerhalb seines Zuständigkeitsbereichs. Es wird davon ausgegangen, dass andere Threads nicht gleichzeitig Variablen lesen oder schreiben (da dies ein Datenrassen-UB wäre). Sie müssen ihm mitteilen, dass die Zugriffe nicht optimiert werden können. Hier kommt Scheffs Antwort ins Spiel. Ich werde mich nicht die Mühe machen, ihn zu wiederholen.

Da dem Optimierer nicht mitgeteilt wird, dass sich die finishedVariable möglicherweise während der Ausführung der Funktion ändern kann, sieht er, finisheddass sie von der Funktion selbst nicht geändert wird, und geht davon aus, dass sie konstant ist.

Der optimierte Code stellt die beiden Codepfade bereit, die sich aus der Eingabe der Funktion mit einem konstanten Bool-Wert ergeben. Entweder wird die Schleife unendlich ausgeführt, oder die Schleife wird nie ausgeführt.

beim -O0Compiler (wie erwartet) wird der Schleifenkörper und Vergleich nicht weg optimiert:

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

Daher funktioniert die Funktion, wenn sie nicht optimiert ist, ist der Mangel an Atomizität hier normalerweise kein Problem, da der Code und der Datentyp einfach sind. Wahrscheinlich ist das Schlimmste, auf das wir hier stoßen könnten, ein Wert, der ium eins von dem abweicht, was er sein sollte.

Ein komplexeres System mit Datenstrukturen führt mit größerer Wahrscheinlichkeit zu beschädigten Daten oder einer fehlerhaften Ausführung.

Baldrickk
quelle
3
C ++ 11 macht Threads und ein threadbewusstes Speichermodell zu einem Teil der Sprache. Dies bedeutet, dass Compiler keine Schreibvorgänge selbst für Nichtvariablen atomicin Code erfinden können , die diese Variablen nicht schreiben. zB if (cond) foo=1;kann nicht in asm umgewandelt werden, foo = cond ? 1 : foo;da dieser load + store (kein atomares RMW) auf einen Schreibvorgang von einem anderen Thread aus treten könnte. Compiler vermieden solche Dinge bereits, weil sie für das Schreiben von Multithread-Programmen nützlich sein wollten, aber C ++ 11 machte es offiziell, dass Compiler keinen Code brechen mussten, in den 2 Threads schreiben, a[1]unda[2]
Peter Cordes
2
Aber ja, abgesehen von dieser Übertreibung darüber, dass Compiler Threads überhaupt nicht kennen , ist Ihre Antwort richtig. Mit Data-Race-UB können viele nichtatomare Variablen, einschließlich globaler Variablen, und die anderen aggressiven Optimierungen, die wir für Single-Threaded-Code wünschen, angehoben werden. MCU-Programmierung - Die C ++ O2-Optimierung unterbricht die Schleife der Elektronik. SE ist meine Version dieser Erklärung.
Peter Cordes
1
@PeterCordes: Ein Vorteil von Java bei der Verwendung eines GC besteht darin, dass der Speicher für Objekte nicht ohne eine dazwischen liegende globale Speicherbarriere zwischen der alten und der neuen Verwendung recycelt wird. Dies bedeutet, dass jeder Kern, der ein Objekt untersucht, immer einen Wert sieht, den es hat gehalten einige Zeit nach der ersten Veröffentlichung der Referenz. Globale Speicherbarrieren können zwar sehr teuer sein, wenn sie häufig verwendet werden, sie können jedoch den Bedarf an Speicherbarrieren an anderer Stelle erheblich reduzieren, selbst wenn sie sparsam verwendet werden.
Supercat
1
Ja, ich wusste, dass Sie das sagen wollten, aber ich glaube nicht, dass Ihre Formulierung 100% bedeutet. Wenn Sie sagen, der Optimierer "ignoriert sie vollständig". ist nicht ganz richtig: Es ist bekannt, dass das Ignorieren von Threading bei der Optimierung Dinge wie das Laden / Ändern eines Bytes im Wort- / Wortspeicher beinhalten kann, was in der Praxis zu Fehlern geführt hat, bei denen der Zugriff eines Threads auf ein Zeichen oder ein Bitfeld auf einem Schritt erfolgt Schreiben Sie in ein benachbartes Strukturelement. Unter lwn.net/Articles/478657 finden Sie die vollständige Geschichte und wie nur das Speichermodell C11 / C ++ 11 eine solche Optimierung illegal und nicht nur in der Praxis unerwünscht macht.
Peter Cordes
1
Nein, das ist gut. Danke @PeterCordes. Ich schätze die Verbesserung.
Baldrickk
5

Der Vollständigkeit halber in der Lernkurve; Sie sollten die Verwendung globaler Variablen vermeiden. Sie haben gute Arbeit geleistet, indem Sie es statisch gemacht haben, sodass es lokal für die Übersetzungseinheit ist.

Hier ist ein Beispiel:

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

Lebe auf Zauberstabbox

Vergessenheit
quelle
1
Könnte auch finishedals staticinnerhalb des Funktionsblocks deklarieren . Es wird immer noch nur einmal initialisiert. Wenn es auf eine Konstante initialisiert wird, ist keine Sperrung erforderlich.
Davislor
Die Zugriffe auf finishedkönnten auch billigere std::memory_order_relaxedLadungen und Geschäfte nutzen; Es ist keine Bestellung erforderlich. andere Variablen in beiden Threads. Ich bin mir jedoch nicht sicher, ob @ Davislors Vorschlag staticSinn macht. Wenn Sie mehrere Spin-Count-Threads hätten, müssten Sie sie nicht alle mit demselben Flag stoppen. Sie möchten die Initialisierung von so schreiben finished, dass sie nur zu einer Initialisierung und nicht zu einem atomaren Speicher kompiliert wird. (Wie bei der finished = false;Standard-C ++ 17-Syntax des Initialisierers. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Wenn Sie das Flag in ein Objekt einfügen, kann es, wie Sie sagen, mehr als ein Flag für verschiedene Thread-Pools geben. Das ursprüngliche Design hatte jedoch eine einzige Flagge für alle Threads.
Davislor