CodeGym /Kurse /C++ SELF /Data race und UB: eine kurze Brücke zum Race-Modell

Data race und UB: eine kurze Brücke zum Race-Modell

C++ SELF
Level 72, Lektion 0
Verfügbar

1. Warum sich überhaupt wegen Races sorgen

Wenn Sie jemals Multithread-Code geschrieben haben und dachten „na ja, ich habe zwei Threads gestartet, sie haben etwas berechnet, die Ausgabe sah richtig aus – also ist es OK“, dann herzlichen Glückwunsch: Sie haben die klassische Anfänger-Prüfung bestanden. Das Problem ist, dass Multithreading sich erfolgreich verstellen kann, als ob alles in Ordnung wäre, besonders bei kleinen Beispielen und „auf meinem Laptop“.

Der Hauptgrund, warum wir dem ganze eine ganze Vorlesung widmen: Multithreading-Fehler leben fast immer im Modus „manchmal“. Sie stürzen selten stabil ab. Sie tauchen gern in Produktion am Freitagabend auf. Und das Schlimmste: ein Teil der Fehler in C++ gehört formal zu Undefined Behavior – das heißt, der Compiler darf „tun, was er will“, und Sie können die Korrektheit nicht beweisen, selbst wenn tausendmal hintereinander das richtige Resultat ausgegeben wird.

Race condition und data race: wo liegt der Unterschied

Die Begriffe klingen ähnlich, beide betreffen „Rennen“, beide klingen bedrohlich – kein Wunder, dass sie verwechselt werden. Aber es sind zwei verschiedene Probleme. Wenn Sie sie unterscheiden, wird die Hälfte der Multithreading-Bugs zumindest erklärbar, statt mystisch.

Logisches Rennen

Race condition (logisches Rennen) – das ist eine Situation, in der das Ergebnis davon abhängt, in welcher Reihenfolge Aktionen der Threads ausgeführt werden. Dabei kann der Speicherzugriff formal korrekt sein (z. B. alles durch Mutex geschützt), aber das Szenario „wer zuerst war“ beeinflusst die Business-Logik.

Datenrennen

Data race (Datenrennen) – das ist eine Situation, in der zwei Threads gleichzeitig auf denselben Speicherbereich zugreifen, mindestens einer davon schreibt, und es keine korrekte Synchronisation gibt. In C++ ist das besonders kritisch, weil ein data race als Undefined Behavior gilt – das heißt, das Verhalten des Programms ist nicht durch den Standard bestimmt.

Zur Veranschaulichung – eine kleine Tabelle (nicht auswendig lernen, eher „fühlen“):

Phänomen Worum es geht Kann es UB geben? Wie wird es üblicherweise behoben
Race condition Logik und Reihenfolge der Ereignisse Nicht unbedingt Protokolldesign, Queues, Zustände, manchmal Mutexes/condition_variable
Data race Inkonsistenter konkurrierender Speicherzugriff Ja, in C++ ist das UB Mutex, Atomics, korrekte Synchronisation

Data race in einfachen Worten

Ohne akademisches Vokabular (obwohl der Standard dieses sehr liebt): Ein data race entsteht, wenn zwei Threads gleichzeitig auf dieselbe Variable zugreifen, wobei mindestens einer schreibt, und ihr euch nicht über Zugriffsregeln geeinigt habt (kein Mutex, keine atomare Variable, kein anderes Synchronisationsprotokoll).

Stellen Sie sich eine Küche vor. Zwei Köche salzen gleichzeitig dieselbe Suppe, einer rührt schon, der andere hat noch nicht probiert, und niemand hat vereinbart, wer den Löffel hält. Manchmal ist die Suppe in Ordnung. Manchmal zu salzig. Manchmal fliegt der Löffel gegen die Wand. Das ist ein data race – die Situation „der Löffel fliegt gegen die Wand“, nicht nur „etwas zu salzig“.

Das kürzeste Beispiel, das harmlos aussieht, aber tatsächlich problematisch ist:

#include <iostream>
#include <thread>

int main() {
    int x = 0;

    std::jthread t1([&] { ++x; });
    std::jthread t2([&] { ++x; });

    std::cout << x << '\n'; // kann "2" sein, kann alles Mögliche sein (formal UB)
}

Wichtig: selbst wenn Sie häufig eine 2 sehen, ist dieser Code formell nicht korrekt, weil zwei Threads ohne Synchronisation in dasselbe int schreiben.

2. Warum data race in C++ UB ist

Hier möchte man oft fragen: „Warum so streng? Warum nicht einfach sagen ‚das Ergebnis ist unvorhersehbar‘?“ Der Grund ist, dass es in C++ zwei mächtige „Treibende Kräfte“ gibt, die Ausführung optimieren: der Compiler und die CPU. Beide dürfen Operationen umordnen und vereinfachen, sofern das die Bedeutung im Single-Thread-Kontext nicht ändert.

Wenn im Code ein data race auftaucht, sagen Sie dem Compiler im Grunde: „Ich verstoße gegen die Regeln, aber tu so, als wäre nichts passiert.“ Der Compiler antwortet: „Okay, dann optimiere ich so, wie es mir passt. Beschwere dich später nicht.“

Klassiker: Stopp-Flag ohne Synchronisation

Ein sehr anschauliches Beispiel ist ein „Stopp-Flag“ ohne Synchronisation. Anfänger machen oft folgendes: ein Thread läuft in einer Schleife und wartet, bis stop true wird, ein anderer Thread setzt es.

#include <thread>

int main() {
    bool stop = false;

    std::jthread worker([&] {
        while (!stop) {
            // "warten"
        }
    });

    std::jthread stopper([&] {
        stop = true;
    });
}

Auf menschlicher Ebene scheint es: „der stopper setzt stop auf true, und der worker geht raus“. In der Praxis kann der Compiler jedoch entscheiden, dass stop innerhalb des worker nirgends verändert wird (er sieht keine korrekte Synchronisation!), und die while (!stop) als „endlose Schleife“ behandeln. Das ist kein „Compiler-Bug“, sondern die Folge davon, dass Sie dem Compiler das Recht gegeben haben, so zu denken, weil das Modell verletzt wurde.

Warum join() die Race nicht „aufhebt“

Eine häufige Überlegung: „Okay, ich habe verstanden: gleichzeitig darf man nicht. Aber wenn ich auf die Threads gewartet habe (join()) und erst danach das Ergebnis lese – ist das sicher?“ Leider nein. join() (oder RAII-Warten in std::jthread) garantiert, dass der Thread beendet ist, hebt aber nicht auf, dass während seiner Laufzeit zwei Threads ein data race hätten.

Beispiel, das psychologisch beruhigt, aber standardmäßig immer noch regelwidrig ist:

#include <iostream>
#include <thread>

int main() {
    int x = 0;

    std::jthread t1([&] { ++x; });
    std::jthread t2([&] { ++x; });

    // t1 und t2 werden vor dem Beenden von main beendet (das macht jthread)
    std::cout << x << '\n';
}

Das Problem ist nicht das Lesen von x am Ende. Das Problem ist, dass während der Ausführung konkurrierende Schreibzugriffe stattgefunden haben.

Als Analogie: „Ich warte, bis zwei Personen aufgehört haben, gleichzeitig dasselbe Dokument ohne Konfliktauflösung zu editieren“ – das heißt nicht, dass keine Konflikte stattgefunden haben. Es bedeutet nur, dass die Bearbeitung beendet ist.

Warum sleep_for nur Symptome lindert

Ein beliebtes „Allheilmittel“ ist sleep_for. Die Logik: „Wenn Threads sich in die Quere kommen, verteile ich sie zeitlich etwas.“ Das klingt plausibel, ist aber vergleichbar damit, an einem Auto mit einem Schraubenschlüssel zu klopfen: manchmal springt es an, aber die Methode ist nicht vom Hersteller empfohlen.

Beispiel eines „schlechten Beruhigungsmittels“:

#include <chrono>
#include <iostream>
#include <thread>

int main() {
    int x = 0;

    std::jthread t1([&] {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        ++x;
    });
    std::jthread t2([&] {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        ++x;
    });

    std::cout << x << '\n'; // "häufiger" 2, aber das ist immer noch UB
}

Ja, Delays können die Wahrscheinlichkeit einer Kollision reduzieren, aber sie schaffen keine Regeln für Speicherzugriffe. Damit bleibt der Code formal inkorrekt. Außerdem kann auf einem anderen Rechner, unter anderer Last, bei anderer CPU-Frequenz oder anderem Scheduler alles wieder aufflammen.

4. Wie man data race behebt: mutex und atomic

Wenn wir sagen „ein data race beheben“, meinen wir eigentlich: „Zugriffe auf gemeinsame Daten konsistent machen“. Die grundlegenden Mittel sind zwei: Mutex und Atomics.

Ein Mutex ist gut, weil er „die Tür zusperrt“: solange ein Thread in der kritischen Sektion ist, wartet der andere. Das ist nützlich, wenn Sie mehrere zusammenhängende Variablen haben und Invarianten wie „wenn A geändert wird, muss B ebenfalls geändert werden“ erhalten möchten.

Atomic ist gut, weil er Operationen auf einer Variable thread-sicher macht (z. B. Counter, Flag). Aber atomic ist keine „Wundermittel für die gesamte Anwendung“, sondern ein punktuelles Werkzeug.

Interessant ist, dass es auch in der Standardbibliothek Diskussionen darüber gibt, wie Container helfen sollten, „Data race avoidance…“ zu unterstützen. Das ist kein Zufall: das Thema ist so zentral, dass man ohne es das Verhalten von Containern kaum korrekt beschreiben könnte.

Ein Zähler: zwei korrekte Fixes

Version mit Mutex:

#include <iostream>
#include <mutex>
#include <thread>

int main() {
    int x = 0;
    std::mutex m;

    std::jthread t1([&] { std::lock_guard<std::mutex> lock(m); ++x; });
    std::jthread t2([&] { std::lock_guard<std::mutex> lock(m); ++x; });

    std::cout << x << '\n'; // 2
}

Version mit atomic:

#include <atomic>
#include <iostream>
#include <thread>

int main() {
    std::atomic<int> x{0};

    std::jthread t1([&] { ++x; });
    std::jthread t2([&] { ++x; });

    std::cout << x.load() << '\n'; // 2
}

Beachten Sie: wir diskutieren hier noch nicht die „feinen Einstellungen“ von Atomics. Wichtig ist die eiserne Regel: ein gewöhnliches int darf nicht ohne Synchronisation von mehreren Threads verändert werden. Dagegen ist std::atomic<int> dafür geeignet.

Praktisches Beispiel: Mini-TaskRunner und Fortschritt

Damit das nicht abstrakt bleibt, binden wir die Idee an eine Übungsanwendung. Angenommen, wir haben einen Mini-TaskRunner: er startet mehrere Worker, jeder „bearbeitet eine Aufgabe“, und wir möchten zählen, wie viele Aufgaben erledigt sind, um den Fortschritt auszugeben.

Die naive Implementierung sieht hübsch aus… und bricht logisch (und manchmal schlimmer – gerät in UB), weil mehrere Threads in dieselbe Variable schreiben.

#include <iostream>
#include <thread>
#include <vector>

int main() {
    int processed = 0;

    std::vector<std::jthread> workers;
    for (int i = 0; i < 4; ++i) {
        workers.emplace_back([&] { ++processed; });
    }

    std::cout << processed << '\n'; // "erwartet 4", aber grundsätzlich UB
}

Warum das schlechter Code ist – jetzt ist es klar: processed ist gemeinsam, und es wird gleichzeitig hineingeschrieben.

Korrigieren wir minimal – machen wir den Zähler atomar. Genau hier ist atomic passend: ein unabhängiger Fortschrittszähler ohne komplexe Invarianten.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::atomic<int> processed{0};

    std::vector<std::jthread> workers;
    for (int i = 0; i < 4; ++i) {
        workers.emplace_back([&] { ++processed; });
    }

    std::cout << processed.load() << '\n'; // 4
}

Wenn wir jedoch zwei zusammenhängende Variablen hätten, zum Beispiel processed und errors, und wir möchten eine konsistente Statistik ausgeben („zuerst Fehler inkrementieren, dann Verarbeitete“), ist es oft einfacher und verständlicher, einen Mutex zu verwenden und den Zustand in einem „Paket“ zu aktualisieren. Atomics können das zwar auch lösen, aber das ist ein Bereich, in dem man leicht ein wackeliges Protokoll zusammenbaut.

5. Wie man lernt, data races zu erkennen und sie von „gewöhnlichen Bugs“ zu unterscheiden

Die unangenehmste Eigenschaft eines data race ist, dass er nicht immer wie ein „offensichtlicher Fehler“ aussieht. Manchmal ist es ein banales ++, manchmal das Setzen eines Flags, manchmal ein Lesezugriff „nur zum Loggen“. Daher sind weniger Tricks, sondern eher die Gewohnheit, die richtigen Fragen zu stellen, entscheidend.

Mentalmodell: drei Fragen zum gemeinsamen Objekt

Ein nützliches mentales Modell: data race tritt dort auf, wo ein gemeinsames, veränderbares Objekt existiert. Also suchen Sie beim Durchschauen des Codes Antworten auf drei Fragen: wo lebt das Objekt (wer „sieht“ es), wer verändert es, und nach welchen Regeln erlauben wir den Zugriff.

Man kann das als kleine Flussdiagramm zeichnen, das man im Kopf behalten sollte:

flowchart TD
    A[Es gibt eine Variable/ein Objekt] --> B{Sehen 2+ Threads darauf?}
    B -- nein --> C[Data race ist unmöglich]
    B -- ja --> D{Gibt es in mindestens einem Thread eine Schreiboperation?}
    D -- nein --> E[Normalerweise sicher, aber prüfe lifetime/invalidation]
    D -- ja --> F{Gibt es Synchronisation? mutex/atomic/protokoll}
    F -- ja --> G["Data race behoben (prüfe Logik)"]
    F -- nein --> H[Data race => UB]

Beachten Sie den Zweig „nur lesen“: der ist oft sicher, aber andere Probleme tauchen auf (z. B. Objektlebensdauer, Invalidation von Referenzen etc.). Heute liegt der Fokus auf Schreibzugriffen ohne Synchronisation.

Mini-Diagnose: UB oder deterministischer Bug

Wenn ein Programm „manchmal 4 ausgibt, manchmal 3“, möchte man gern sagen: „Das ist ein Bug, ich behebe ihn später“. Im Multithreading ist eine nützlichere Disziplin: kläre zuerst, ob es ein deterministischer logischer Fehler ist oder ein Verstoß gegen Speicherregeln. Erstes behandelt man mit Design, zweites mit Synchronisation.

Praktisch (ohne Tools) helfen zwei Dinge.

Erstens – die Gewohnheit, „Sicherungen“ mittels assert zu setzen, um zumindest unmögliche Zustände zu entdecken. Zum Beispiel, wenn Ihr TaskRunner erwartet, dass processed nie negativ ist und die Anzahl der Aufgaben nicht überschreitet, kann man das während der Entwicklung temporär prüfen. Ja, assert heilt Races nicht, aber es hilft den Moment zu fangen, wenn der Zustand „aus der Bahn geraten“ ist.

#include <atomic>
#include <cassert>

int main() {
    std::atomic<int> processed{0};

    ++processed;
    int v = processed.load();

    assert(v >= 0);
}

Zweitens – Sanitizer und Diagnose-Tools (wir haben sie schon kennengelernt): sie können data race als Fehlerklasse erkennen. Aber selbst ohne Tools ist die wichtigste Waffe das korrekte mentale Modell: wenn es eine gemeinsame int gibt, die von mehreren Threads ohne Synchronisation geschrieben wird, ist das kein „könnte ein Bug sein“, sondern ein rotes Warnsignal.

6. Typische Fehler

Fehler Nr.1: „Da es ein int ist, ist es sicher – er ist ja klein“.
Die Größe eines Typs macht den Zugriff nicht Thread-sicher. Das Problem ist nicht, ob alles in ein Register passt, sondern dass zwei Threads gleichzeitig nicht abgestimmte Lese-/Schreib-Operationen ausführen. Das verletzt das Speicher-Modell von C++ und führt zu UB.

Fehler Nr.2: „Ich lese ohne Mutex, schreibe aber mit Mutex – das sollte passen“.
Das ist eine tückische Falle: es scheint, als sei die Schreiboperation geschützt, also wird das Lesen schon etwas Vernünftiges sehen. In der Praxis kollidiert ein ungeschütztes Lesen trotzdem mit einer geschützten Schreiboperation und kann ein data race sein. Wenn ein Objekt durch einen Mutex geschützt wird – schützt sowohl Lese- als auch Schreibzugriffe (oder verwende ein anderes korrektes Protokoll).

Fehler Nr.3: „Ich benutze sleep_for, dann stoßen die Threads nicht mehr zusammen“.
Verzögerung ist keine Synchronisation. Sie schafft keine Regeln, wer wann schreiben darf, sondern ändert nur zufällig den Zeitplan. Auf einem Rechner wirkt es „besser“, auf einem anderen bricht es wieder zusammen. Am schlimmsten: so entsteht ein Bug, der kaum reproduzierbar ist.

Fehler Nr.4: „join() löst alles“.
join() garantiert, dass der Thread beendet ist, aber hebt nicht auf, dass während der Ausführung ein Datenrennen stattgefunden haben könnte. Wenn zwei Threads während ihrer Laufzeit ohne Synchronisation in dieselbe Variable geschrieben haben, sind die Regeln bereits gebrochen – das Endergebnis kann beliebig sein, selbst wenn Sie danach sauber gewartet haben.

Fehler Nr.5: „Ich mache ein atomic für das Flag, der Rest bleibt normal – dann ist alles fair“.
Ein Atomic löst das Problem für die atomare Variable selbst. Wenn Sie jedoch ein Paar zusammenhängender Daten haben, macht ein einzelner Atomic daneben nicht den gesamten Code thread-sicher. Für zusammenhängende Invarianten ist es meist einfacher, mit einem Mutex zu beginnen und später zu optimieren, wenn Sie das Protokoll und die Zuständigkeitsgrenzen genau verstehen.

1
Aufgabe
C++ SELF, Level 72, Lektion 0
Gesperrt
Sicherer Zähler
Sicherer Zähler
1
Aufgabe
C++ SELF, Level 72, Lektion 0
Gesperrt
Vorfallsbericht
Vorfallsbericht
1
Aufgabe
C++ SELF, Level 72, Lektion 0
Gesperrt
Summe der Lagerbestände
Summe der Lagerbestände
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION