Praktische Multithreading-Best-Practices: C++-Edition — Fehler mit RAII und jthread strukturell ausschließen

· · Windows, Multithreading, C++, Visual Studio, Geschäftsanwendungen, Fehleruntersuchung, Design

Änderungsverlauf (Erstfassung, veröffentlicht am 2. Aug 2026)
Erstveröffentlichung
Diesen Artikel zitieren(DOI (registriertes Archiv): 10.5281/zenodo.22175852)

Die folgenden DOIs verweisen auf bereits archivierte Versionen, die vom aktuellen Text abweichen können. Verwenden Sie die URL dieser Seite, um auf den aktuellen Text zu verweisen.

Go Komura (2026). Praktische Multithreading-Best-Practices: C++-Edition — Fehler mit RAII und jthread strukturell ausschließen. KomuraSoft LLC. https://comcomponent.com/de/blog/multithreading-best-practices-cpp/

DOI (registriertes Archiv)
10.5281/zenodo.22175852
DOI (zuletzt registrierte Version)
10.5281/zenodo.22175853

„Eine Ausnahme trat auf, und die Aufräumarbeit eines std::thread hat die ganze Anwendung mitgerissen.“ „Wir haben das Stoppflag gesetzt, aber der Worker kommt nicht zurück.“ „Ein Design, das unter C# funktionierte, haben wir nach C++ übertragen – und jetzt stürzt es gelegentlich ab.“ Beim Multithreading in C++ müssen Sie, bevor Sie die Arbeit aneinanderreihen, festlegen, wie gemeinsam genutzte Daten geschützt werden und wie Threads enden.

Gerade in C++ ist eine Datenrace nicht bloß ein Rechenfehler, sondern undefiniertes Verhalten (undefined behavior). Dass zufällig ein korrektes Ergebnis herauskam, taugt nicht als Beleg für Sicherheit.1

Dieser Artikel ist die C++-Edition der Multithreading-Praxisreihe und richtet sich an Entwickler, die Geschäftsanwendungen, Anlagensteuerungen und DLLs in modernem C++ (C++17/20) schreiben. Die Reihenfolge ist: Ausführungsweise wählen, gemeinsame Nutzung reduzieren, Synchronisierung und Stoppen umsetzen, anschließend Windows-spezifische Einschränkungen und die Verifikation prüfen. Beispiele, die C++20 voraussetzen, sind an der jeweiligen Stelle kenntlich gemacht.

Dieselben Prinzipien sind für andere Sprachen in der „.NET-Edition“, der „C-Edition“ und der „Java-Edition“ ausgearbeitet; der vorliegende Artikel ist aber für sich lesbar.

1. Zuerst das Fazit — gemeinsame Nutzung und Lebensdauer festlegen, bevor Sie „Synchronisierung hinzufügen“

Zuerst reduzieren Sie gemeinsam genutzten veränderlichen Zustand und schützen, was gemeinsam bleibt, mit der Standardbibliothek und RAII. Anschließend machen Sie alles vom Stoppantrag bis zum abgeschlossenen Join zu einem einzigen Entwurf.1

Reihenfolge der Entscheidungen Was festzulegen ist Stelle in diesem Artikel
1. Ausführungsweise Eine einmalige Aufgabe, parallele Verarbeitung einer Menge oder ein langlebiger Worker? Versuchen Sie, E/A-Wartezeiten durch zusätzliche Threads zu lösen? Kapitel 3
2. Besitzer der Daten Lassen sich durch Aufteilen, Wertübergabe, Unveränderlichkeit oder eine Queue Schreibzugriffe auf gemeinsam genutzten Zustand reduzieren? Kapitel 4
3. Schutz des verbleibenden Gemeinsamen Welche Daten schützt welcher Mutex? Haben Sie die einzelne Aktualisierung per atomic von der Konsistenz mehrerer Variablen getrennt? Kapitel 5
4. Ablauf des Beendens Erreicht der Stoppantrag sowohl wartende als auch beschäftigte Threads? Können Sie Ausnahmen festhalten und am Ende joinen? Kapitel 6
5. Einbettung und Verifikation Werden die Einschränkungen von DLL, UI und COM eingehalten, und lässt sich mit Logs, Dumps und Lasttests untersuchen? Kapitel 7 bis 8

Die Standardwerkzeuge sind std::jthread für die Lebensdauer des Threads, RAII für Locks, das prädikatbehaftete wait für das Warten und std::atomic für ein einzelnes gemeinsames Flag oder einen Zähler. volatile dient nicht der Synchronisierung. Vor C++20 bauen Sie das Stoppen aus einem atomaren Flag und einer Bedingungsvariable auf.2345

Die Wahl der Werkzeuge allein macht die Arbeit aber nicht fertig. Auch wenn jthread automatisch joint, wartet er weiter, wenn die Verarbeitung nicht enden kann. Auch wenn atomic einen Zeiger austauscht, schützt das nicht die Lebensdauer des alten Objekts. Prüfen Sie beides später anhand der Codebeispiele.

In der Abbildung kennzeichnet eine durchgezogene Linie eine stets geltende Beziehung und eine gestrichelte Linie eine bedingte (die Bedingungen stehen bei jeder Beziehung auf der Detailseite). Die vollständige Liste der Beziehungen (27 insgesamt, mit Beleg und Sicherheitsgrad) und die Definitionen der wichtigsten Konzepte sind auf der Detailseite der Wissenskarte (auf Japanisch) zusammengestellt. Daten: JSON-LD / Turtle

2. Drei Voraussetzungen, die zuerst klar sein müssen — Wettlauf, gegenseitiges Warten und RAII

2.1. Eine Wettlaufsituation hängt von der Ausführungsreihenfolge ab, und eine Datenrace in C++ ist undefiniertes Verhalten

Eine Wettlaufsituation (Race Condition) ist ein Fehler, bei dem sich das Ergebnis danach richtet, in welcher Reihenfolge mehrere Threads eine Verarbeitung erreichen. Zerlegt man das ++count eines gemeinsamen Zählers in „lesen → addieren → zurückschreiben“, sieht man, wie zwei Threads denselben Wert lesen und eine Aktualisierung verloren geht.

Beispiel für eine verlorene Aktualisierung eines gemeinsamen ZählersSchematisch, wie eine Aktualisierung verloren geht, wenn zwei Threads denselben Wert lesen, jeweils addieren und zurückschreiben.count ist 10A und B lesen beide 10Jeder addiert lokal auf 11A schreibt 11 zurückB schreibt ebenfalls 11 zurückIn C++ ist das Ergebnis nicht darauf beschränkt

Abbildung 1: Schema eines verlorenen Inkrements; es begrenzt nicht das Ergebnis von undefiniertem Verhalten in C++.

Das ist ein Schema zum Verständnis von Wettläufen. Greifen in C++ mehrere Threads dieselbe nicht-atomare Speicherstelle an, schreibt mindestens einer, und fehlt die nötige Synchronisierung, ist das nach dem Standard undefiniertes Verhalten durch eine Datenrace. Das tatsächliche Ergebnis beschränkt sich nicht darauf, dass „eine Addition verloren geht“.1

Der Compiler optimiert unter der Annahme, dass keine Datenrace existiert. Deshalb lässt sich das vom Quellcode erwartete Verhalten nicht mehr garantieren – einschließlich Umsortierung und Zusammenlegung von Bedingungsprüfungen sowie Lese- und Schreibzugriffen. Gehen Sie nicht davon aus, dass „entweder der alte oder der neue Wert gelesen wird“. Ein volatile bool als Stoppflag löst das ebenfalls nicht, weil es in C++ keine Synchronisierung zwischen Threads ist.15

2.2. Deadlock entsteht, wenn die „gewarteten Gegenüber“ einen Kreis bilden

Ein Deadlock ist ein Zustand, in dem jede Seite auf die Freigabe eines Locks wartet, den die andere hält, und keine weiterkommt. Es genügt, dass Thread A Lock 1 hält und auf Lock 2 wartet, während Thread B Lock 2 hält und auf Lock 1 wartet.

Zirkuläres Warten auf zwei LocksWartet jede Seite auf den Lock, den die andere hält, kommt keine von beiden weiter.wartet auf Lock 2wartet auf Lock 1A hält Lock 1B hält Lock 2

Abbildung 2: Bilden die Wartepfeile einen Kreis, bleibt jede Seite stehen und wartet darauf, dass die andere weitergeht.

Bei Wettlauf wie bei Deadlock kann die problematische Ausführungsreihenfolge auf der Entwicklungsmaschine ausbleiben und erst beim Kunden mit anderer Kernzahl oder Last auftreten. Ein Debugger oder zusätzliches Logging kann das Timing verändern, sodass sich der Fehler nicht mehr reproduziert. Deshalb ist es wichtig, die Stellen zu reduzieren, an denen synchronisiert werden muss, noch bevor man korrekt synchronisiert.

2.3. Mit RAII die Aufräumarbeit einschließlich der Ausnahmepfade zur Struktur machen

C++ hat kein finally; stattdessen gibt es RAII (Resource Acquisition Is Initialization), das die Ressourcenverwaltung an die Lebensdauer eines Objekts bindet. Das Objekt, das einen Lock erworben hat, gibt ihn frei, wenn es den Gültigkeitsbereich verlässt; das Objekt, das einen Thread besitzt, joint ihn bei der Zerstörung.1

Nicht nur der normale Abschluss, sondern auch die Pfade, die den Gültigkeitsbereich über einen frühen Return oder eine Ausnahme verlassen, sitzen auf derselben Aufräumarbeit. Lesen Sie die folgenden jthread- und Lock-Wrapper als Werkzeuge, die diese Idee umsetzen, wird die Wahl zwischen ihnen leichter.

3. Die Ausführungsweise wählen — zuerst Aufgaben, Threads nur wenn nötig

3.1. Einmalige Arbeit, Verarbeitung einer Menge und E/A-Warten trennen

Der Grundsatz „keine Threads selbst vermehren“ gilt auch in C++. Sehen Sie zuerst die Form der Arbeit an und wählen Sie das Werkzeug, das sie ausdrückt.

Form der Arbeit Wichtige Optionen Zuerst zu prüfen
Eine einmalige asynchrone Verarbeitung und die Entgegennahme des Ergebnisses std::async und std::future Die Startrichtlinie und die Lebensdauer des future
Parallele Verarbeitung einer Collection PPL, parallele Algorithmen aus C++17 Die Arbeit pro Iteration und der Umgang mit Ausnahmen
Ein Worker mit eigener Lebensdauer std::jthread aus C++20 Wo der Stoppantrag beobachtet wird, und das Join
Warten auf E/A Unter Windows OVERLAPPED-E/A oder IOCP Asynchrone E/A statt zusätzlicher Threads
Zuerst die Ausführungsweise wählen, dann die Lebensdauer festlegenEine zur Form der Arbeit passende Ausführungsweise wählen und festlegen, wie Ergebnis und Stoppen dafür verwaltet werden.Einmalig oder MengenverarbeitungLanglebige VerarbeitungAuf E/A wartenForm der Arbeit prüfenWas wird ausgeführtAufgaben oder parallele AlgorithmenLebensdauer des Workers verwaltenAsynchrone E/A erwägenErgebnis, Ausnahmen und Ende entwerfen

Abbildung 3: Bevor Sie Threads vermehren, legen Sie die Form der Arbeit und fest, wer das Ende verwaltet.

Vermeiden Sie es, Threads nur zu vermehren, um auf E/A zu warten. OVERLAPPED-E/A und IOCP, die in nativem Windows-Code diese Rolle übernehmen, behandelt „Die Tiefen von Windows-E/A, Teil 2“.

3.2. Bei async „Startbedingung“ und „wie lange das Ergebnis gehalten wird“ gemeinsam festlegen

std::async ist bequem, aber Sie dürfen den zurückgegebenen future nicht verwerfen. Zwei Punkte sind zu beachten.6

Der erste ist das Warten bei der Zerstörung. Ist eine mit std::launch::async gestartete Arbeit noch nicht abgeschlossen, wenn das future oder shared_future zerstört wird, das zuletzt den gemeinsamen Zustand hält, entsteht ein Warten auf den Abschluss. Verwerfen Sie den Rückgabewert an Ort und Stelle, warten Sie am Ende dieses Ausdrucks, obwohl Sie asynchron arbeiten wollten – das entspricht einer seriellen Ausführung.

Der zweite ist die verzögerte Ausführung. Geben Sie keine Startrichtlinie an, kann die Implementierung deferred wählen. Dann wird die Arbeit nicht ausgeführt, solange niemand get() oder wait() aufruft. Wo Sie die Ausführung in einem anderen Thread sicher brauchen, geben Sie std::launch::async explizit an und legen fest, wer das future wie lange hält.

Startrichtlinie von async und Lebensdauer des futureUnterscheidet das Warten bei der Zerstörung nach Start mit async von dem Fall, in dem unter deferred nichts läuft, solange niemand wartet.asyncdeferredstd::async aufrufenGewählte StartrichtlinieLäuft in einem anderen ThreadLetztes future wird zerstörtWartet auf Abschluss, falls unfertigAusführung bis get oder wait verzögertLäuft nicht, wenn keines aufgerufen wird

Abbildung 4: Das Verhalten hängt nicht nur von der Startrichtlinie ab, sondern auch davon, wie lange das future gehalten wird.

3.3. In parallelen Schleifen Körnung und Ausnahmegrenzen prüfen

concurrency::parallel_for und parallel_for_each aus der PPL (Parallel Patterns Library) wenden eine Verarbeitung parallel auf die Elemente einer Menge an. Ist die Arbeit einer einzelnen Iteration jedoch zu klein, übersteigt der Overhead von Fork/Join und Scheduling den Gewinn. Der Grundsatz lautet, Parallelität möglichst an der äußersten Schleife auszudrücken.7

Bei den parallelen Algorithmen aus C++17 können Sie std::execution::par verwenden. MSVC parallelisiert die wichtigen Algorithmen, das heißt aber nicht, dass jeder Algorithmus stets parallel läuft.8

Außerdem führt eine Ausnahme, die aus der Elementverarbeitung eines Algorithmus mit Standard-Ausführungsrichtlinie entweicht, zu std::terminate. Das nötige try/catch gehört nicht nur in den Aufrufer, sondern in den Callback der Elementverarbeitung. Das ist derselbe Gedanke wie die Ausnahmegrenze der Thread-Funktion in Kapitel 6.

3.4. Wer einen Thread besitzt, muss join auch auf Ausnahmepfaden garantieren

Ein std::thread, der zerstört wird, während er noch joinable ist – weder gejoint noch abgetrennt –, ruft std::terminate auf. Unabhängig davon, ob die Arbeit der Thread-Funktion fertig ist, muss die Objektseite das Join abschließen.9

Das folgende Beispiel zeigt diese Falle.

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← fliegt hier eine Ausnahme …
    worker.join();      // ← join wird nie erreicht; der Destruktor von worker ruft terminate
}

Ein join() am Ende allein schützt den Ausnahmepfad in der Mitte nicht. Verwenden Sie std::thread, garantieren Sie das Join mit try/catch oder RAII. Ist C++20 verfügbar, machen Sie std::jthread zum Standard; ist der Thread joinable, stellt der Destruktor einen Stoppantrag und joint anschließend. Unter MSVC stehen <stop_token> und jthread ab Visual Studio 2019 16.9 zur Verfügung.210

Zerstörung eines Thread-Objekts und JoinDie Zerstörung eines joinable thread führt zu terminate, die Zerstörung eines jthread stellt einen Stoppantrag und joint.NeinJathreadjthreadThread-Objekt zerstörenIst es joinableNichts zum JoinenWelcher Typ ist esstd::terminateStoppantrag stellen und joinenDie Arbeit selbst muss enden

Abbildung 5: jthread automatisiert das Join, beendet die Arbeit aber nicht zwangsweise.

Was hier automatisiert wird, ist der Stoppantrag und das Join auf der Besitzerseite. Es ist keine Funktion, die aus der Thread-Funktion entweichende Ausnahmen auffängt oder eine nicht endende Verarbeitung zwangsweise beendet. Kapitel 6 entwirft diese beiden Punkte getrennt.

detach() verwenden Sie grundsätzlich nicht. Weil jedes Mittel zum Joinen verloren geht, konkurriert die Zerstörung statischer Variablen oder des Heaps mit der Ausführung des Threads und führt zu Abstürzen beim Beenden. Machen Sie „auf das Ende warten zu können“ zur Grundanforderung des Entwurfs.

4. Gemeinsame Nutzung reduzieren — Aufteilen, Wertübergabe, Unveränderlichkeit und Queues

4.1. Statt einer gemeinsamen Summe Teilsummen pro Thread halten

Gemeinsam genutzter veränderlicher Zustand, die Ursache von Wettläufen, lässt sich reduzieren, bevor Sie Locks hinzufügen. Bei paralleler Aggregation aktualisiert nicht jeder Thread eine gemeinsame Summe, sondern jeder Thread bildet eine lokale Teilsumme und führt sie am Ende genau einmal zusammen.

Schreibzugriffe auf den gemeinsamen Zustand sinken von „bei jeder Iteration“ auf „einmal pro Thread“, sodass Synchronisierungskosten und konfliktträchtige Stellen kleiner werden. Für die Aktualisierung beim Zusammenführen eignet sich sowohl ein std::mutex als auch fetch_add auf einem std::atomic.

Teilsummen pro Thread am Ende zusammenführenStatt die gemeinsame Summe jedes Mal zu aktualisieren, wird die Teilsumme jedes Threads am Ende genau einmal auf die Summe angewendet.Eingabe aufteilenTeilsumme von Thread ATeilsumme von Thread BAm Ende synchronisieren und zusammenführenGemeinsame Summe

Abbildung 6: Während der Iteration exklusive Daten aktualisieren und Schreibzugriffe auf das Gemeinsame auf das Zusammenführen konzentrieren.

4.2. Als Wert übergeben – aber prüfen, worauf die Kopie zeigt

Übergeben Sie die beim Start benötigten Daten per Kopie oder Move, sodass sie allein dieser Arbeit gehören, lässt sich spätere Synchronisierung reduzieren. Lambdas stützen sich nicht auf [&], sondern verwenden explizite Captures, grundsätzlich per Kopie oder Move. Nutzen Sie ein Referenz-Capture, muss das Referenzierte länger leben als der Thread.

Allerdings macht das Kopieren eines Zeigers das Objekt, auf das er zeigt, nicht exklusiv. Eine Struktur mit Rohzeigern oder shared_ptr teilt weiterhin über Aliase. „Sicher, weil der Wert kopiert wurde“ gilt nur, wenn der gesamte Wertegraph einschließlich der Referenzen ein tiefer Wertegraph ohne gemeinsam genutzten veränderlichen Zustand ist.

Ein kopierter Zeiger teilt das referenzierte Objekt weiterhinWird ein Wert mit Zeiger kopiert, sind die beiden Variablen verschieden, das referenzierte Objekt bleibt aber dasselbe.Zeigerwert kopierenZeiger im ursprünglichen WertDasselbe referenzierte ObjektZeiger in der KopieZum Ändern ist Synchronisierung nötig

Abbildung 7: Prüfen Sie nicht nur den kopierten Wert, sondern auch die Objekte, auf die er verweist.

4.3. Wenn Sie über const teilen, einen Zustand schaffen, den „niemand ändert“

Was nach der Konstruktion nicht mehr geändert wird – Konfiguration, Stammdaten, Berechnungseingaben – lässt sich schreibgeschützt teilen. std::shared_ptr<const Config> etwa verbietet Änderungen über genau dieses Handle.

Bleibt jedoch andernorts eine nicht-const-Referenz bestehen oder wird ein mutable-Member geändert, bleibt der Wettlauf. Nehmen Sie in den Entwurf auf, dass nach Abschluss der Konstruktion die nicht-const-Referenzen aufgegeben werden und danach niemand mehr schreibt.

Ist eine Änderung nötig, erzeugen Sie ein neues Objekt und tauschen es aus, statt das bestehende zu überschreiben. Die Synchronisierung des auszutauschenden Zeigers selbst und die Lebensdauerverwaltung des alten Objekts sind jedoch getrennt nötig. Kapitel 5.4 prüft das.

4.4. Queues eine Kapazitätsgrenze und ein stoppbares Warten geben

Leiten Sie die Übergabe zwischen Threads über eine Producer/Consumer-Queue, statt gemeinsame Variablen direkt anzufassen. Die hier behandelte Standardbibliothek von C++17/20 kennt keinen Channel, daher ist eine kleine Queue aus Mutex und Bedingungsvariable die Grundform.

Das folgende Beispiel setzt C++20 voraus. Weil ein wait verwendet wird, das ein std::stop_token entgegennimmt, ist die Bedingungsvariable std::condition_variable_any. Die Rückgabewerte von Push und Pop signalisieren, dass ein Stoppantrag beobachtet und die Operation aufgegeben wurde.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // Kapazität 0 ist eine Falle, in der jeder Push ewig wartet
            throw std::invalid_argument("capacity must be positive");
    }

    // Bei vollem Zustand auf Platz (oder einen Stoppantrag) warten. false bedeutet Stoppantrag.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // durch einen Stoppantrag geweckt
            if (st.stop_requested())                // sind Platz und Stopp gleichzeitig da, hat Stopp Vorrang,
                return false;                       // und nach Stoppbeginn werden keine Einträge mehr angenommen
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // Benachrichtigung außerhalb des Locks
        return true;
    }

    // Auf einen Stoppantrag (stop_token) oder das Eintreffen eines Elements warten. nullopt beim Stoppen.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // durch einen Stoppantrag geweckt
            if (st.stop_requested())                // sind Element und Stopp gleichzeitig da, hat Stopp Vorrang,
                return std::nullopt;                // und nach Stoppbeginn wird keine neue Arbeit begonnen
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // any gewählt, um das stop_token-fähige wait zu nutzen
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

An diesem Code sind drei Punkte zu prüfen.

Prüfpunkt Entsprechung im Code Verhindertes Problem
Was geschieht, wenn die Queue voll ist Kapazitätsgrenze setzen und in Push auf Platz warten. Kapazität 0 ablehnen Produktion überholt den Konsum, und der Speicher wächst unaufhörlich
Was nach der Rückkehr aus einem Warten zu prüfen ist wait ein Prädikat übergeben, das den Zustand der Queue prüft Rückkehr ohne Benachrichtigung oder eine Bedingung, die beim Aufwachen schon wieder anders ist
Ob sich ein Warten beim Stoppen verlassen lässt Das stop_token-fähige wait nutzen und den Stopp auch vor der Operation prüfen Nicht beenden können, während auf eine leere oder volle Queue gewartet wird

Dass die Erzeugerseite bei voller Queue wartet, wirkt als Gegendruck (Backpressure) und trägt Überlast stromaufwärts weiter. Außerdem kennen Bedingungsvariablen Scheinwecken ohne Benachrichtigung (Spurious Wakeup), deshalb schreiben Sie das Warten mit Prädikat. Das prädikatbehaftete wait übernimmt die Schleife, die die Bedingung erneut prüft.4

Queue mit Kapazitätsgrenze und Auflösen des WartensDie Erzeugerseite wartet bei vollem Zustand auf Platz, die Verbraucherseite bei leerem Zustand auf ein Element, und der Stoppantrag erreicht beide Wartevorgänge.ErzeugerseiteBei vollem Zustand auf Platz wartenQueue mit KapazitätsgrenzeBei leerem Zustand auf ein Element wartenVerbraucherseiteStoppantrag

Abbildung 8: Die Kapazitätsgrenze wirkt als Gegendruck, und der Stoppantrag erreicht auch das Warten von Erzeuger- und Verbraucherseite.

Die Politik dieses Beispiels ist: Sobald ein Stopp beobachtet wird, wird nicht alles Verbleibende abgearbeitet, sondern das nächste Einlegen und Entnehmen beendet. Allerdings sind der Stoppantrag und eine bereits laufende Operation nicht zu einer unteilbaren Verarbeitung zusammengefasst. Es gibt keine Garantie, dass eine Operation, die die Stoppprüfung gerade passiert hat, bevor der Antrag eintraf, zurückgerollt wird; laufende Arbeit behandelt das kooperative Stoppen in Kapitel 6.

Das Beispiel ist das Gerüst von Synchronisierung und Stoppen. Die nötigen Header und fachlichen Typen stellen Sie am Einbauort bereit; Fehlschläge von Element-Moves oder Queue-Operationen behandelt ebenfalls die Ausnahmegrenze in Kapitel 6.

5. Was gemeinsam bleibt, schützen — Lock-Disziplin und der Geltungsbereich von atomic

5.1. Locks den geschützten Daten zuordnen, nicht dem Code

Lässt sich gemeinsam genutzter veränderlicher Zustand nicht auf null bringen, ordnen Sie jeder Menge zu schützender Daten einen Mutex zu und nehmen bei jedem Zugriff denselben Mutex. Der Mutex wird nicht nach außen sichtbar; die Grundform ist, ihn als privates Mitglied zusammen mit den Daten zu halten.1

Während des Locks führen Sie nur die kurzen Lese- und Schreibzugriffe auf die geschützten Daten aus. Datei-E/A, Netzwerkaufrufe oder Callbacks – also externen Code – bei gehaltenem Lock aufzurufen, verlängert nicht nur die Haltezeit, sondern öffnet Pfade, die auf Locks in der aufgerufenen Stelle warten. Ziel ist die Form außerhalb des Locks vorbereiten, innerhalb nur austauschen.

Außerhalb des Locks vorbereiten und innen austauschenZeitaufwendige Vorbereitung wandert aus dem Lock heraus, und nur die Änderung der gemeinsamen Daten liegt in einem kurzen Lock-Abschnitt.Außerhalb des Locks vorbereitenLock per RAII erwerbenGemeinsame Daten ändernGültigkeitsbereich verlassen und freigebenExterne Benachrichtigungen und Ähnliches ausführen

Abbildung 9: Geschützte Daten und Mutex einander zuordnen und externe Verarbeitung aus der Haltezeit heraushalten.

5.2. Erwerb und Freigabe RAII überlassen, mehrere Locks gemeinsam nehmen

Paaren Sie lock() und unlock() von Hand, vergessen Sie die Freigabe bei frühem Return oder einer Ausnahme. Überlassen Sie Erwerb und Freigabe den folgenden Wrappern.

Wrapper Verwendung
std::lock_guard Die grundlegendste Form: einen Mutex nur für die Dauer eines Gültigkeitsbereichs halten
std::scoped_lock (C++17) Mehrere Mutexe gleichzeitig erwerben. Ein Deadlock-Vermeidungsalgorithmus löst das Reihenfolgeproblem3
std::unique_lock Wenn Sie zwischendurch freigeben und erneut erwerben wollen oder ihn an condition_variable::wait übergeben

Nehmen Sie mehrere Locks getrennt, vereinheitlichen Sie die Erwerbsreihenfolge über alle Threads. Werden die Locks gleichzeitig gebraucht, übergeben Sie sie gemeinsam an std::scoped_lock und überlassen die Deadlock-Vermeidung beim Erwerb der Bibliothek. Dieser Mechanismus betrifft den Erwerb der übergebenen Mutexe; andere zirkuläre Wartezeiten, etwa durch externe Aufrufe bei gehaltenem Lock, verhindert er nicht.3

Ein Beispiel, das zwei Konten gleichzeitig schützt. Achten Sie auf die Art des Lock-Erwerbs, nicht auf fachliche Prüfungen wie den Kontostand.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // Bei identischem Konto nichts tun (siehe Hinweis unten)
    std::scoped_lock lock(from.mtx, to.mtx);   // Beide zusammen; die Reihenfolge löst die Bibliothek
    from.balance -= amount;
    to.balance   += amount;
}

Die Identitätsprüfung am Anfang ist unverzichtbar. Wird dasselbe Konto für beide Argumente übergeben, übergeben Sie denselben nicht rekursiven Mutex zweimal – das führt zu einem Hänger oder undefiniertem Verhalten. Eine Funktion, die „beides sperrt“, muss dasselbe Objekt ausschließen.

5.3. shared_mutex und recursive_mutex erst nach Klärung des Einsatzes wählen

Für Daten, die oft gelesen und selten geschrieben werden, können Sie mit std::shared_mutex aus C++17 einen Lese-/Schreib-Lock verwenden.11

recursive_mutex ist ein Typ, der den erneuten Erwerb durch denselben Thread erlaubt. Dass rekursiver Erwerb nötig ist, ist jedoch oft ein Zeichen dafür, dass die Verantwortung des Locks verschwommen ist; prüfen Sie die Struktur, bevor Sie die Sache durch einen Typwechsel erledigen.

5.4. Atomare Aktualisierung und Objektlebensdauer sind getrennte Probleme

std::atomic bietet unteilbare Operationen auf eine einzelne Variable und eine auf memory_order basierende Ordnung. Das typische Einsatzgebiet ist die Aktualisierung von Zählern und Flags. Sollen mehrere Variablen als Satz konsistent bleiben, reicht es nicht, jede atomic zu machen; schützen Sie sie gemeinsam mit einem Mutex.5

Gerade std::atomic<T*> macht nur den Austausch des Zeigers unteilbar; es verlängert nicht die Lebensdauer dessen, worauf er zeigt. Lädt ein Leser den alten Zeiger und tauscht der Schreiber ihn unmittelbar danach aus und löscht das alte Objekt, greift der Leser auf bereits freigegebenen Speicher zu.

Ein atomarer Zeigeraustausch allein schützt die Lebensdauer nichtDer alte Zeiger, den ein Leser erhalten hat, verliert sein Ziel, wenn der Schreiber es nach dem Austausch löscht.Leser holt den alten ZeigerSchreiber tauscht den ZeigerSchreiber löscht das alte ObjektLeser greift auf das alte Objekt zuZugriff auf bereits freigegebenen SpeicherDie Unteilbarkeit des Austauschs allein reicht nicht

Abbildung 10: Den Austausch des Zeigers und die Lebensdauer des referenzierten Objekts müssen Sie getrennt schützen.

Tauschen Sie unveränderliche Objekte aus und teilen sie, wählen Sie ein Mittel, das Lebensdauerverwaltung einschließt – etwa das Austauschen eines lockgeschützten std::shared_ptr<const T> oder std::atomic<std::shared_ptr<T>> aus C++20. Auch mit shared_ptr dürfen Sie die referenzierten Daten nicht frei überschreiben; das bleibt wie in Kapitel 4.3.

Auch hier ist volatile kein Ersatz. In Geschäftsanwendungen verwenden Sie den Standard seq_cst von atomic oder schreiben es mit einem Mutex. Lockfreie Entwürfe, die memory_order lockern, sind eine fachliche Option und nur dort vertretbar, wo sich Notwendigkeit und Prüfmittel erklären lassen.

6. Das Stoppen vollenden — Antrag, Warteauflösung, Ausnahmebehandlung und join

6.1. request_stop ist ein Antrag; der abgeschlossene join bestätigt den Stopp

In der Review müssen Sie „wie es stoppt“ erklären können, noch bevor Sie die Startweise erklären. C++ kennt kein Mittel, einen Thread von außen sicher zwangsweise zu beenden. Die Gefahren von Win32s TerminateThread behandelt die C-Edition.

Die Grundlage ist kooperatives Stoppen. Die stoppende Seite stellt einen Antrag, der Thread selbst endet an einer Stelle, an der er aufräumen kann, und der Besitzer bestätigt den Abschluss des Join. In C++20 überträgt request_stop() von jthread den Antrag auf das stop_token, das die Thread-Funktion erhalten hat.2

In einer Berechnungsschleife prüfen Sie stop_requested(); während des Wartens nehmen Sie den Stoppantrag über condition_variable_any::wait(lock, st, pred) entgegen. Die Queue in Kapitel 4 ist so geformt, dass auch das Warten auf leer und voll über diesen Weg zurückkehrt. Dass sich ein Warten durch einen Stoppantrag auflösen lässt, ist nicht dasselbe wie ein Stopp, der einschließlich Scheduler und erneutem Lock-Erwerb in begrenzter Zeit abgeschlossen ist.

Kooperatives Stoppen macht alles bis zum Join zu einem PfadDer Besitzer stellt den Stoppantrag, Berechnung und Warten beobachten ihn und enden, und der abgeschlossene Join bestätigt den Stopp.Besitzer stellt StoppantragÜbertragung an das stop_tokenWährend der Berechnung den Antrag prüfenZugehöriges Warten auflösenAufräumen und returnJoin des Besitzers ist abgeschlossen

Abbildung 11: Den Stopp bestätigt nicht der Moment des Antrags, sondern der abgeschlossene Join.

6.2. Das Worker-Beispiel unter Lebensdauer und Doppelstart lesen

Als Nächstes ein C++20-Worker, der die BlockingQueue aus Kapitel 4 verwendet. WorkItem, Process und ReportError sind Typen und Funktionen der Fachseite. Insbesondere implementieren Sie ReportError so, dass es nicht wirft.

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // Lehnt einen zweiten Start ab, solange der Worker läuft.
            throw std::logic_error("already running"); // Würde man statt zu verweigern zuweisen, liefen
                                                       // zwei Worker parallel, während nach dem Start
                                                       // des neuen auf das Stoppen des alten gewartet wird
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // wacht auch bei einem Stoppantrag auf
                        try {
                            Process(*item, st);          // st auch an intern blockierende Verarbeitung übergeben
                        } catch (...) {
                            ReportError(std::current_exception());  // ein Fehlschlag wird festgehalten, dann weiter
                        }
                    }
                }
            } catch (...) {
                // Letzte Verteidigungslinie an der Thread-Grenze (auch Fehlschläge von Pop oder Move landen hier).
                // Entweicht hier eine Ausnahme, reißt std::terminate den ganzen Prozess mit,
                // deshalb darf ReportError selbst nicht werfen
                ReportError(std::current_exception());
            }
        });
    }
    // Kein explizites Stop nötig:
    // Destruktor von Worker → Destruktor von jthread → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // mit Kapazitätsgrenze (Kapitel 4)
    std::jthread thread_;
};

Am Anfang von Start() wird ein zweiter Start auf einem joinable Thread abgelehnt. Weisen Sie ohne Prüfung einen neuen jthread zu, können zwei Threads parallel laufen, während nach dem Start des neuen auf das Stoppen des alten gewartet wird. Diese Prüfung ist kein Lock, der gleichzeitige Start()-Aufrufe mehrerer Aufrufer synchronisiert. Die Voraussetzung ist, dass Start und Zerstörung auf der Besitzerseite seriell verwaltet werden.

Auch die Deklarationsreihenfolge der Mitglieder ist Teil der Lebensdauer. Im Beispiel steht thread_ nach queue_, daher wird thread_ zuerst zerstört. Erst nach Abschluss von Stoppantrag und Join wird die Queue zerstört, die der Worker verwendet.

Zerstörungsreihenfolge der Worker-Mitglieder und Lebensdauer der QueueDer später deklarierte jthread wird zuerst zerstört, und die Queue des Workers wird nach abgeschlossenem Join zerstört.Worker zerstörenSpäter deklariertes thread_ zerstörenStoppantrag und JoinEnde des Workers bestätigenqueue_ zerstören

Abbildung 12: Die Queue, auf die der Worker verweist, nicht vor dem Join zerstören.

6.3. jthread fängt keine Ausnahmen auf, die aus dem Worker entweichen

Automatisches Join und die Ausnahmebehandlung der Thread-Funktion sind getrennt. Entweicht eine Ausnahme aus der Funktion, führt das bei jthread ebenso wie bei std::thread zu std::terminate.

Das try/catch im Beispiel hat zwei Rollen.

Ausnahmegrenze Gegenstand Politik im Beispiel
Innen Fehlschlag einer einzelnen Arbeit in Process Festhalten und zur nächsten Arbeit weitergehen
Außen Fehlschlag der gesamten Schleife, einschließlich Pop und Element-Moves Als letzte Verteidigungslinie festhalten und nichts aus der Funktion entweichen lassen
Ausnahmegrenzen für eine Arbeit und für den ganzen ThreadDer Fehlschlag einer Arbeit und Fehlschläge wie Queue-Operationen werden an getrennten Grenzen aufgefangen, und keine Ausnahme entweicht aus der Thread-Funktion.Ausnahme einer ArbeitAusnahme beim Holen usw.Worker-SchleifeAus der Queue holenEine Arbeit verarbeitenInnen festhalten und fortfahrenAußen festhalten und beendenAuch das Festhalten darf nicht werfen

Abbildung 13: Statt sich auf das automatische Join zu verlassen, die Ausnahmegrenzen der Thread-Funktion explizit machen.

In der Praxis entscheiden Sie, ob nach einem einzelnen Fehlschlag weitergemacht oder über einen Fehlerkanal an den Besitzer gemeldet und gestoppt wird. Fangen Sie nicht auf und werfen still weg; machen Sie es beobachtbar.

6.4. Den Stopp-Pfad bis in Process hinein führen

Dass das Token auch an Process(*item, st) übergeben wird, liegt daran, dass der Thread auch während der Verarbeitung einer einzelnen Arbeit auf einen Stopp reagieren muss, nicht nur während des Wartens auf die Queue. Beobachtet eine lange Berechnung oder ein Netzwerkwarten den Antrag nicht, wartet das implizite Join des Destruktors, bis diese Verarbeitung endet.

Kooperatives Stoppen gelingt erst, wenn der Stopp-Pfad jede Wartestelle erreicht. Versehen Sie nicht unterbrechbare externe Aufrufe mit einem Timeout und setzen Sie eine Obergrenze für die Laufzeit einer einzelnen Arbeit. Ein Stopp-Token in der Argumentliste macht diese externe API nicht von selbst unterbrechbar.

6.5. Vor C++20 Stoppflag und Benachrichtigung als Paar behandeln

In Umgebungen ohne den Stoppmechanismus von C++20 bauen Sie dieselbe Struktur aus einem std::atomic<bool>-Stoppflag und notify_all der Bedingungsvariable auf. Nehmen Sie das Stoppflag auch in das Prädikat der wartenden Seite auf. Setzen Sie nur das Flag und benachrichtigen nicht, wacht der wartende Thread nicht auf.4

Außerdem braucht es auch bei atomarem Flag Disziplin, damit zwischen Bedingungsprüfung und Wartebeginn keine Benachrichtigung verloren geht. Vermitteln Sie Änderungen des Stoppzustands mit demselben Mutex wie die wartende Seite, und benachrichtigen Sie nach der Zustandsänderung. Prüfen Sie getrennt, dass Sie eine Datenrace auf dem Wert verhindern und dass Sie die Aufweckbenachrichtigung nicht verpassen.

7. In Windows einbinden — die Grenzen von Synchronisierungs-APIs, DLLs und UI

7.1. Gewöhnliches C++ nutzt die Standardbibliothek; Win32-Anbindung folgt aus den Anforderungen

In C++-Code, dem Portabilität wichtig ist, machen Sie std::mutex oder std::shared_mutex mit RAII zum Standard. Gründe für Win32-Synchronisierungsobjekte sind das Zusammenspiel mit Win32-Warte-APIs und die prozessübergreifende Synchronisierung.12

Situation Wahl
Gewöhnlicher prozessinterner Ausschluss std::mutex + RAII (Standard)
Viele Lesezugriffe, selten Schreibzugriffe std::shared_mutex
Mehrere Objekte gleichzeitig mit WaitForMultipleObjects erwarten Win32-Kernelobjekte wie Event und Mutex
Prozessübergreifender Ausschluss oder Benachrichtigung Benannte Mutexe, Events und Semaphoren
Prozessinterner Lock bei direkter Verwendung der Win32-API SRW-Lock (CRITICAL_SECTION nur bei Rekursionsbedarf)12
Wahl zwischen Standardsynchronisierung und Win32-AnbindungGewöhnlicher C++-Code setzt Standardsynchronisierung als Vorgabe, und Win32-Objekte werden gewählt, wenn Win32-Warten oder prozessübergreifende Synchronisierung nötig ist.NeinJaSynchronisierungsanforderungen prüfenWin32-Warten oder prozessübergreifendStandard-Mutex und RAII als VorgabeWin32-Objekte wählenBei direktem Win32 etwa SRW-Lock

Abbildung 14: Aus den Anforderungen an die Betriebssystemanbindung wählen und nicht mit gewöhnlichem prozessinternem Ausschluss vermischen.

Vermeiden Sie es, gewöhnlichen prozessinternen Ausschluss durch einen Win32-Mutex zu ersetzen. Dabei findet ein Kernelübergang statt, der für diesen Zweck unnötige Kosten verursacht. Die Linie lautet: Für neuen Code, der Win32 direkt verwendet, der SRW-Lock, und CRITICAL_SECTION nur, wenn derselbe Thread rekursiv erneut erwerben muss.12

Ein konkretes Design, das gemeinsam genutzten Speicher zwischen Prozessen schützt, finden Sie in „Fallstricke bei gemeinsam genutztem Speicher und praktische Best Practices“.

7.2. In DllMain weder starten, synchronisieren noch auf Threads warten

DllMain wird aufgerufen, während der Loader-Lock gehalten wird. Dort mit anderen Threads zu synchronisieren, auf ihr Ende zu warten oder LoadLibrary aufzurufen, führt zu Deadlocks und anderen Problemen.13

Initialisierung und Abschluss, die Threads starten oder joinen, verlagern Sie in explizite Funktionen außerhalb von DllMain. jthread ist keine Ausnahme; prüfen Sie, wo das automatische Join stattfindet.

Thread-Verarbeitung der DLL in explizite Funktionen trennenUnter gehaltenem Loader-Lock in DllMain nicht synchronisieren und Start sowie Warten auf das Ende in äußere Funktionen legen.DllMainLoader-Lock gehaltenHier weder starten, synchronisieren noch joinenExplizite Funktion außerhalb von DllMainStart, Stopp und Join verwalten

Abbildung 15: Auch das implizite Join durch Zerstörung eines Thread-Objekts darauf prüfen, wo es läuft.

7.3. UI-Aktualisierungen an den erzeugenden Thread übergeben und nicht über synchrone Benachrichtigungen aufeinander warten

Operationen an Fenstern und Steuerelementen bündeln Sie auf dem UI-Thread, der sie erzeugt hat. Aktualisieren Sie den Bildschirm nicht direkt vom Worker aus; die Grundform ist, asynchron mit PostMessage zu beauftragen und die Nachricht in der Fensterprozedur auf der UI-Seite zu behandeln.

Die synchrone Form SendMessage erzeugt, wenn sie aufgerufen wird, während der UI-Thread auf den Abschluss des Workers wartet, ein zirkuläres Warten. Benachrichtigungen vom Worker machen Sie asynchron, um genau diesen Pfad zu vermeiden.

Zirkuläres Warten durch synchrone Benachrichtigung zwischen UI und WorkerWartet die UI auf das Ende des Workers, während der Worker auf die Behandlung von SendMessage wartet, entsteht zirkuläres Warten.Wartet auf das Ende des WorkersWartet auf den Abschluss von SendMessageUI-ThreadWorker-ThreadBenachrichtigung vom WorkerMit PostMessage beauftragenAuf der UI-Seite behandeln

Abbildung 16: Die Beauftragung der UI standardmäßig asynchron halten und kein gegenseitiges Warten auf den Abschluss erzeugen.

Die Einschränkungen von STA/MTA, soweit COM im Spiel ist, finden Sie in „Grundlagen von COM STA/MTA“. C++/CLI hat eigene Einschränkungen: In mit /clr kompiliertem Code sind die Standard-Thread-Header wie <thread> und <mutex> blockiert.14

8. Verifizieren — in drei Schichten vorsorgen: Entwurfssabelle, Beobachtung und Stresstest

8.1. Bevor Sie auf Ergebnisse schauen, prüfen Sie, ob sich gemeinsame Nutzung und Stoppen erklären lassen

Auch wenn die gewöhnlichen Tests durchlaufen, kann das nur heißen, dass in diesem Lauf kein Wettlauf auftrat. Die erste Verteidigungslinie ist der bisherige Entwurf. In der Review prüfen Sie das tabellarisch.

Gegenstand Was sich erklären lassen muss
Gemeinsam genutzte veränderliche Daten Wer liest und schreibt, und welcher Mutex schützt
Mehrere Locks Ob die Erwerbsreihenfolge einheitlich ist oder sie mit scoped_lock gemeinsam genommen werden
Übergebene Daten Ob nach dem Kopieren Aliase bleiben und ob die Lebensdauer reicht
Stoppen Wo der Antrag beobachtet, das Warten aufgelöst und wer gejoint wird

Ein Entwurf, der diese Zuordnung nicht erklären kann, ist nicht fertig, auch wenn er läuft.

8.2. Anomalien mit Timeouts, Logs und Dumps sichtbar machen

Für einen Lock, der eigentlich nicht scheitern dürfte, oder ein Warten, das nie endet, erwägen Sie Timeouts wie timed_mutex::try_lock_for und condition_variable::wait_for. Bleibt eine Zeitüberschreitung im Log, wird ein stiller Hänger zu einem erkennbaren Fehlschlag. Halten Sie auch die an der Thread-Grenze aufgefangenen Ausnahmen immer fest.

Tritt vor Ort ein Hänger oder Absturz auf, prüfen Sie aus dem Dump die Stacks aller Threads und verfolgen, ob Lock-Wartezeiten einen Kreis bilden. Die Vorbereitung behandelt „Design für Logs und Dumps bei Abstürzen von Windows-Apps“.

8.3. Ausführungsreihenfolge und Last auch im Release-Build durchschütteln

Stresstests – langes Laufen mit höherer Parallelität als der Kernzahl, Randomisieren der Verarbeitungsreihenfolge, künstliche Verzögerungen – machen problematische Ausführungsreihenfolgen leichter erreichbar. Belasten Sie nicht nur Debug-Builds, sondern auch optimierte Release-Builds.

Entwurf und Beobachtung vorbereiten, dann Stresstests fahrenGemeinsame Nutzung und Stoppen im Entwurf prüfen, mit Logs und Dumps beobachtbar machen und anschließend Last und Ausführungsreihenfolge variieren.Gemeinsame Nutzung, Locks, Lebensdauer und Stoppen prüfenLogs und Dumps vorbereitenMit variierter Last und Ausführungsreihenfolge prüfenGefundene Probleme in den Entwurf zurückführen

Abbildung 17: Einen bestandenen Test nicht allein als Sicherheitsbeweis nehmen; Entwurf, Beobachtung und Prüfung kombinieren.

Tests ersetzen den Entwurf nicht. Die Reihenfolge lautet: gemeinsame Nutzung und Lebensdauer strukturell schützen, Anomalien beobachten, dann per Prüfung schwache Stellen suchen.

9. Zusammenfassung — die C++-Checkliste

Zum Schluss prüfen Sie in der C++-Umsetzung, dass Sie Threads nicht direkt vermehren, gemeinsam genutzten veränderlichen Zustand reduzieren, Locks den Daten zuordnen und kooperativ stoppen.

  1. Wird std::thread ungeschützt verwendet (ließe sich jthread einsetzen, und ist join auch auf dem Ausnahmepfad garantiert)?
  2. Wird detach() verwendet?
  3. Sind Lambda-Captures explizit, und leben per Referenz erfasste Variablen länger als der Thread?
  4. Lässt sich sagen, dass es nicht eine einzige unsynchronisierte gemeinsame veränderliche Zugriffsstelle (= undefiniertes Verhalten) gibt?
  5. Gibt es kein handgeschriebenes lock() / unlock(), und werden mehrere Locks mit scoped_lock gemeinsam genommen?
  6. Ist jedes condition_variable::wait mit einem Prädikat versehen?
  7. Wird für ein gemeinsames Flag kein volatile verwendet (steht dort std::atomic)?
  8. Ist der Stopp-Pfad um stop_token (oder ein atomares Flag plus Benachrichtigung) entworfen, und bestätigt der Join den Abschluss des Zusammenführens?
  9. Wird das future von std::async nicht verworfen?
  10. Werden in DllMain keine Threads gestartet, synchronisiert oder gejoint?

In C++ ist eine Datenrace undefiniertes Verhalten; auf „läuft gewöhnlich“ können Sie sich nicht stützen. Folgen Sie dennoch den Konventionen von RAII und der Standardbibliothek, lassen sich Fehlschläge bei Aufräumen und Synchronisierung strukturell reduzieren.

Wählen Sie die Ausführungsweise, reduzieren Sie die gemeinsame Nutzung, schützen Sie, was gemeinsam bleibt, und vollenden Sie den Weg vom Stoppantrag bis zum Join. jthread, scoped_lock, das prädikatbehaftete wait und atomic in dieser Reihenfolge zu verwenden, ist die praktische Grundlage.

Verwandte Artikel

Verwandte Beratungsleistungen

Die KomuraSoft LLC übernimmt Multithreading-Design-Reviews für C++-Anwendungen und -DLLs, die Untersuchung wettlaufbedingter Fehler wie „stürzt gelegentlich ab“ oder „verhält sich nur im Release-Build seltsam“ (Dump-Analyse) sowie die Beratung zur Migration von Legacy-Thread-Code nach modernem C++.

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Dazu, dass CP.1 (gehen Sie davon aus, dass Ihr Code als Teil eines Multithread-Programms läuft) und CP.2 (vermeiden Sie Datenraces) als einleitende Regeln des Kapitels über Nebenläufigkeit und Parallelität vorangestellt werden; dazu, dass bei einer Datenrace keinerlei Garantie mehr gilt; sowie dazu, dass die Entwurfsregeln für nebenläufigen Code — der Umfang, in dem Locks gehalten werden, die Verwendung von RAII und Ähnliches — dort systematisch dargestellt sind. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. cppreference.com, std::jthread. Dazu, dass C++20s jthread sich von std::thread darin unterscheidet, dass sein Destruktor automatisch request_stop() aufruft und anschließend joint; dazu, dass es als führendes Argument der Thread-Funktion ein std::stop_token entgegennehmen kann; sowie dazu, dass dadurch sowohl das Zusammenführen des Threads als auch der Stoppantrag auch bei einer auftretenden Ausnahme garantiert sind. ↩ ↩2 ↩3

  3. Microsoft Learn, scoped_lock Class. Dazu, dass C++17s scoped_lock bei der Konstruktion einen oder mehrere Mutexe erwirbt und im Destruktor freigibt; dazu, dass mehrere übergebene Mutexe mit einem zu std::lock äquivalenten Deadlock-Vermeidungsalgorithmus erworben werden; dazu, dass die Freigabe auch bei einer geworfenen Ausnahme zuverlässig erfolgt; sowie dazu, dass bei nur einem einzigen Mutex auch lock_guard/unique_lock eine Option sind. ↩ ↩2 ↩3

  4. Microsoft Learn, <condition_variable>. Dazu, dass das Warten auf eine Bedingungsvariable einen Mutex erfordert und der Lock während der Wartezeit gelöst ist; dazu, dass Scheinwecken ohne Benachrichtigung existieren, weshalb die wartende Seite die Bedingung bei der Rückkehr explizit prüfen sollte, und die Prädikat-Form wait(lock, pred) diese Schleife übernimmt; sowie dazu, dass condition_variable_any mit einem beliebigen Mutex-Typ kombiniert werden kann. ↩ ↩2 ↩3

  5. Microsoft Learn, <atomic>. Dazu, dass atomare Operationen unteilbar sind, sodass andere Threads nur den Zustand vor oder nach der Operation beobachten können; dazu, dass auf Basis des memory_order-Arguments Ordnungsanforderungen für die Sichtbarkeit anderer atomarer Operationen festgelegt und dem widersprechende Compiler-Optimierungen unterdrückt werden; dazu, dass atomic_flag stets lockfrei ist; sowie dazu, dass dieser Header unter /clr:pure blockiert wird. ↩ ↩2 ↩3

  6. Microsoft Learn, <future>. Dazu, dass die Destruktoren von future und shared_future grundsätzlich nicht blockieren, mit der einzigen Ausnahme, dass das an eine mit std::async gestartete Aufgabe gebundene future (oder letzte shared_future) blockiert, bis der gemeinsame Zustand bereit ist, wenn sein Destruktor läuft, während die Aufgabe noch nicht abgeschlossen ist — ein Verhalten, das als Anmerkung im Standard ausdrücklich festgehalten ist. ↩

  7. Microsoft Learn, Best Practices in the Parallel Patterns Library. Dazu, dass Parallelisierung möglichst auf einer hohen Ebene (der äußeren Schleife) ausgedrückt werden sollte; dazu, dass in parallelen Schleifen, deren Arbeit pro Iteration klein oder unausgewogen ist, der Scheduling-Overhead von Fork/Join den Gewinn der parallelen Ausführung übersteigen kann; sowie dazu, dass diese Tendenz mit steigender Prozessorzahl stärker wird. ↩

  8. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Dazu, dass die C++17-Bibliothek der parallelen Algorithmen vollständig ist, „vollständig“ dabei aber nicht bedeutet, dass jeder Algorithmus in jedem Fall parallelisiert wird; sowie zur Implementierungsstrategie, die wichtigsten Algorithmen zu parallelisieren und für die übrigen dennoch die Signaturen mit Ausführungsrichtlinie bereitzustellen. ↩

  9. cppreference.com, std::thread::~thread. Dazu, dass der Destruktor von std::thread std::terminate aufruft, wenn er läuft, während der Thread noch joinable ist (weder gejoint noch abgetrennt) — also dazu, dass die Entscheidung für join oder detach vor der Zerstörung des Thread-Objekts getroffen sein muss. ↩

  10. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Dazu, dass P0660R10 (<stop_token> und jthread) sowie P1135R6 (die C++20-Synchronisierungsbibliothek) ab Visual Studio 2019 16.9 unterstützt werden; sowie zum versionsabhängigen Unterstützungsstand der C++-Standardbibliotheksfunktionen. ↩

  11. Microsoft Learn, C++ standard library header files. Dazu, dass die multithreading-relevanten Standardheader wie folgt aufgeführt sind: <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20) sowie <thread> (C++11). ↩

  12. Microsoft Learn, About Synchronization. Zu den Leitlinien für die Wahl von Win32-Synchronisierungsprimitiven: Für portabilitätsorientierten C++-Code werden std::mutex / std::shared_mutex und RAII empfohlen; Win32-Synchronisierungsobjekte werden verwendet, wenn eine Win32-Warte-API oder prozessübergreifende Synchronisierung benötigt wird; der Standard für neuen prozessinternen Code ist der SRW-Lock, CRITICAL_SECTION nur bei Rekursionsbedarf; und die Verwendung von Mutex für prozessinterne Synchronisierung ist ein „häufiger Fehler“, weil dabei immer ein Kernelübergang stattfindet. ↩ ↩2 ↩3

  13. Microsoft Learn, Dynamic-Link Library Best Practices. Dazu, dass DllMain aufgerufen wird, während der Loader-Lock gehalten wird, was gravierende Einschränkungen für die aufrufbaren APIs mit sich bringt; dazu, dass eine Synchronisierung mit anderen Threads innerhalb von DllMain zu einem Deadlock führen kann; dazu, dass der Aufruf von LoadLibrary und das Warten auf das Ende eines Threads typische verbotene Handlungen sind; dazu, dass Initialisierung so weit wie möglich verzögert und aus DllMain herausgenommen werden sollte; sowie dazu, dass eine Lock-Hierarchie definiert werden sollte, in der der Loader-Lock ganz oben steht. ↩

  14. Microsoft Learn, <thread>. Dazu, dass der Header <thread> die Klasse thread sowie Hilfsfunktionen wie sleep_for definiert; dazu, dass dieser Header in mit /clr kompiliertem Code blockiert wird; sowie dazu, dass sich mit dem Makro STDCPP_THREADS feststellen lässt, ob Thread-Unterstützung vorhanden ist. ↩

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Wie sollte man zwischen std::mutex und Win32s CRITICAL_SECTION bzw. SRW-Lock wählen?
Für gewöhnlichen C++-Code, bei dem Portabilität im Vordergrund steht, sind std::mutex / std::shared_mutex zusammen mit den RAII-Wrappern (lock_guard / scoped_lock) die erste Wahl. Zu Win32-Synchronisierungsobjekten greifen Sie, wenn Sie sie mit einer Win32-Warte-API wie WaitForMultipleObjects kombinieren wollen oder wenn Sie mit benannten Objekten prozessübergreifend synchronisieren müssen. Verwenden Sie innerhalb eines Prozesses die Win32-API direkt, ist der Standard für neuen Code der SRW-Lock; CRITICAL_SECTION verwenden Sie nur, wenn derselbe Thread rekursiv erneut erwerben muss. Einen Win32-Mutex für den prozessinternen Ausschluss zu verwenden, ist ein klassischer Fehler: Dabei findet immer ein Kernelübergang statt, und das ist langsam.
Darf man detach() von std::thread verwenden?
Grundsätzlich nicht. Ein abgetrennter Thread verliert jede Möglichkeit, gejoint zu werden, und Sie verlieren die Kontrolle darüber, ob er beim Beenden des Prozesses noch läuft. Ein klassischer Unfall: Ein abgetrennter Thread läuft weiter, nachdem statische Variablen oder der Heap bereits zerstört wurden, und verursacht so einen Absturz beim Beenden. Die Fähigkeit, auf das Ende warten zu können, ist eine Grundanforderung des Thread-Designs. Verwenden Sie daher jthread (automatisches Join) oder strukturieren Sie den Code bei thread so, dass vor Verlassen des Gültigkeitsbereichs immer gejoint wird. detach ist nur in der eng begrenzten Situation vertretbar, in der der Thread das Schicksal des Prozesses teilen darf und Sie garantieren können, dass er niemals auf gemeinsam genutzten Zustand zugreift.
Kann volatile in C++ zur Synchronisierung zwischen Threads verwendet werden?
Nein. Das volatile von C++ ist ein Qualifizierer für Lese- und Schreibzugriffe, die der Compiler nicht wegoptimieren soll – etwa bei speicherabgebildeter E/A – und garantiert weder Sichtbarkeit noch Ordnung zwischen Threads. Greifen mehrere Threads ohne Synchronisierung auf dieselbe Variable zu, ist das eine Datenrace und damit undefiniertes Verhalten. Verwenden Sie std::atomic für zwischen Threads geteilte Flags und Zähler und std::mutex, wenn Sie mehrere Variablen gemeinsam schützen müssen. std::atomic bietet sowohl die Unteilbarkeit der Operation als auch eine auf memory_order basierende Ordnung.
std::async wirkt praktisch. Gibt es Fallstricke?
Der größte Fallstrick ist der Destruktor des future. Das future (oder das letzte shared_future), das an eine mit std::async gestartete Aufgabe gebunden ist, blockiert bis zum Abschluss, wenn sein Destruktor läuft, während die Aufgabe noch nicht abgeschlossen ist. Verwerfen Sie den zurückgegebenen future, ohne ihn zu halten, entspricht das an dieser Stelle einer synchronen Ausführung – ein Unfall, bei dem Sie eigentlich asynchron arbeiten wollten, es aber seriell wurde. Hinzu kommt: Geben Sie keine Startrichtlinie an, liegt es im Ermessen der Implementierung, ob die Arbeit tatsächlich in einem anderen Thread läuft. Verwenden Sie es, verwalten Sie die Lebensdauer des future explizit und geben Sie std::launch::async überall dort an, wo Sie nebenläufige Ausführung sicher brauchen.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog