Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
· Go Komura · Windows, Multithreading, C++, Visual Studio, Geschäftsanwendungen, Fehleruntersuchung, Design
„Ein Design, das unter C# funktionierte, brachten wir nach C++ – und plötzlich stürzte es gelegentlich ab.“ „Wir haben std::thread verwendet, und als eine Ausnahme auftrat, starb die ganze Anwendung sofort über terminate.“ „Wir haben mit einem volatile bool-Flag gestoppt, aber nur im Release-Build stoppte es nicht.“ — Multithreading in C++ birgt eine Gefahr, die es in Managed-Sprachen so nicht gibt: Eine Datenrace wird als solche zu undefiniertem Verhalten (undefined behavior). Es bleibt nicht dabei, dass man nur einen kaputten Wert lesen könnte – die Optimierungsannahmen des Compilers brechen zusammen, und man gerät in einen Zustand, in dem buchstäblich alles passieren kann.
Dieser Artikel ist die C++-Edition unserer Multithreading-Praxisreihe. Er richtet sich an Entwickler, die Geschäftsanwendungen, Anlagensteuerungen und DLLs in modernem C++ (C++17/20) schreiben, und überträgt die Prinzipien des Multithread-Designs — Threads nicht direkt vermehren, gemeinsam genutzten veränderlichen Zustand reduzieren, Lock-Disziplin, das Stoppen zuerst entwerfen — auf die Werkzeuge von C++ und Windows, zusammen mit C++-spezifischen Fallstricken, gestützt auf Primärquellen mit Stand August 2026. Er ist so geschrieben, dass er für sich allein gelesen werden kann. Dieselben Prinzipien, für andere Sprachen ausgearbeitet, finden Sie auch in der „.NET-Edition“, der „C-Edition“ und der „Java-Edition“.
1. Das Wichtigste zuerst
- In C++ ist eine Datenrace nicht „man liest womöglich einen kaputten Wert“, sondern „undefiniertes Verhalten“. Keinen einzigen unsynchronisierten gemeinsam genutzten veränderlichen Zugriff übrig zu lassen, ist hier eine absolute Voraussetzung — mehr noch als in anderen Sprachen.1
- Verwenden Sie
std::threadnicht ungeschützt. Läuft der Destruktor einesstd::thread, während der Thread noch joinable ist, stirbt der Prozess sofort überstd::terminate. C++20sstd::jthreadjoint im Destruktor automatisch und hat einen Stopp-Mechanismus (stop_token) eingebaut.23 - Halten Sie Locks stets über RAII. Schreiben Sie
mtx.lock()nicht von Hand, sondern verwenden Sielock_guard/scoped_lock. Der Destruktor gibt zuverlässig frei, selbst wenn eine Ausnahme fliegt. Beim gleichzeitigen Erwerb mehrerer Locks kümmert sichscoped_lockmit einem Deadlock-Vermeidungsalgorithmus darum.4 volatileist kein Synchronisierungswerkzeug. Für gemeinsam genutzte Flags und Zähler verwenden Siestd::atomic, zum Schutz mehrerer Variablenstd::mutex.std::atomicbietet sowohl Unteilbarkeit als auch eine aufmemory_orderbasierende Ordnung.5- Führen Sie Rendezvous-Wartevorgänge mit der Prädikat-Form von
waitbeicondition_variabledurch. Bedingungsvariablen sind anfällig für Spurious Wakeups (das Aufwachen ohne Benachrichtigung), weshalb einwaitohne Prädikat ein Nährboden für Fehler ist.6 jthread+stop_token(C++20) ist die Grundform für das Stoppen eines Threads. In Umgebungen davor bauen Sie kooperatives Stoppen von Hand mitstd::atomic<bool>plus einer Bedingungsvariable auf. Betrachten Sie das erzwungene Beenden eines Threads als etwas, das in der Welt von C++ schlicht nicht existiert.3- Verwenden Sie
std::asyncerst, wenn Sie wissen, dass der Destruktor des future blockieren kann. Verwerfen Sie den Rückgabewert, entspricht das einer seriellen Ausführung.7 - Win32-Synchronisierungsobjekte haben ihren Platz nur beim „Zusammenspiel mit Win32-Warte-APIs“ und „prozessübergreifend“. Überall sonst ist es hinsichtlich Portabilität und Wartbarkeit vorteilhafter, gegen die Standardbibliothek zu schreiben.8
2. Warum Multithreading schwierig ist — Wettlaufsituationen, Deadlocks und undefiniertes Verhalten
Auf den Punkt gebracht bringt Multithreading, unabhängig von der Sprache, zwei Arten von Problemen mit sich.
Eine Wettlaufsituation (race condition) ist ein Fehler, bei dem sich das Ergebnis danach richtet, in welcher Reihenfolge mehrere Threads eine bestimmte Codestelle erreichen. Das klassische Beispiel ist ein gemeinsam genutzter Zähler: Der einzelne Ausdruck ++count zerfällt auf Maschinencode-Ebene in drei Schritte — Lesen → Addieren → Zurückschreiben. Treten zwei Threads gleichzeitig in diese drei Schritte ein, wird die Addition des einen durch das Zurückschreiben des anderen überschrieben und geht verloren. Das Ergebnis ändert sich von Lauf zu Lauf, und welches Ergebnis herauskommt, lässt sich nicht vorhersagen.
sequenceDiagram
participant A as Thread A
participant M as Geteilte Variable count
participant B as Thread B
Note over M: count = 10
A->>M: Lesen (10)
B->>M: Lesen (10)
A->>A: Lokal addieren (11)
B->>B: Lokal addieren (11)
A->>M: Zurückschreiben (11)
B->>M: Zurückschreiben (11)
Note over M: Zwei Additionen erfolgt,<br/>trotzdem count = 11 — die Addition von Thread A ging verloren
Abbildung 1: Die klassische Wettlaufsituation, bei der ein gemeinsam genutzter Zähler eine Addition verliert. Mischt sich ein anderer Thread während der drei Schritte von ++count ein, überschreibt das jeweils spätere Zurückschreiben das andere
Ein Deadlock ist ein Zustand, in dem zwei Threads jeweils auf einen Lock warten, den der andere hält, sodass keiner von beiden weiterkommt. Thread A hält Lock 1 und wartet auf Lock 2, Thread B hält Lock 2 und wartet auf Lock 1 — allein das genügt, damit beide für immer stehen bleiben.
flowchart LR
A["Thread A<br/>hält Lock 1"] -->|"wartet auf Freigabe von Lock 2"| B["Thread B<br/>hält Lock 2"]
B -->|"wartet auf Freigabe von Lock 1"| A
Abbildung 2: Die zirkuläre Wartesituation eines Deadlocks. Sobald die Wartepfeile einen Ring bilden, bleiben alle Threads in diesem Ring für immer stehen
Tückisch an beiden ist, dass sie zeitabhängig sind. Eine Ausführungsreihenfolge, die auf einer Entwicklungsmaschine nur bei einem von Zehntausenden Läufen zuschlägt, kann auf einer Kundenmaschine mit anderer Kernzahl und anderem Timing jeden Tag auftreten. Dass sich ein Fehler „mit angehängtem Debugger nicht reproduzieren lässt“ oder „nach Hinzufügen von Logging verschwindet“, liegt ebenfalls daran, dass die Beobachtung selbst das Timing verändert — ein typisches Verhalten von Wettlauf-Bugs. Genau deshalb zielen alle Prinzipien dieses Artikels in eine einzige Richtung: „die Stellen reduzieren, an denen synchronisiert werden muss“, noch bevor man sich um „richtig synchronisieren“ kümmert.
2.1. In C++ wird eine Datenrace unmittelbar zu undefiniertem Verhalten
Darüber hinaus gilt für C++ ein Umstand, der eine Ebene tiefer liegt als bei anderen Sprachen. Nach dem C++-Standard ist es, wenn mehrere Threads ohne Synchronisierung auf dieselbe Speicherstelle zugreifen und mindestens einer davon schreibt, eine Datenrace und damit undefiniertes Verhalten. Das Kapitel über Nebenläufigkeit der C++ Core Guidelines (CP.2, „Vermeiden Sie Datenraces“) stellt dies als allererste absolute Regel voran.1 Undefiniertes Verhalten ist nicht die harmlose Geschichte, dass man entweder den alten oder den neuen Wert lesen könnte. Der Compiler optimiert unter der Annahme, dass „keine Datenrace existiert“, sodass Verhalten, das sich aus dem Quellcode nicht vorhersehen lässt — eine Bedingungsprüfung, die aus einer Schleife verschwindet, Schreibzugriffe, die umsortiert oder zusammengelegt werden — vollkommen legitim auftreten kann. Der klassische Unfall, bei dem „ein volatile bool-Stoppflag nur im Release-Build nicht wirkt“, ist ein Paradebeispiel genau dafür.
2.2. RAII als Fundament
Eine weitere C++-spezifische Voraussetzung sind Ausnahmen und Ressourcenverwaltung. C++ kennt kein finally, dafür aber RAII (automatische Freigabe durch Destruktoren), und auch das Multithreading-Werkzeug ist auf der Annahme aufgebaut, dass Sie RAII verwenden. „Locks werden über die Lebensdauer eines Objekts verwaltet“, „auch das Joinen von Threads wird über die Lebensdauer eines Objekts garantiert“ — sich auf diese Konvention einzulassen, ist das Fundament, um in C++ sicher mit Multithreading zu arbeiten.
3. Threads starten — die Falle von thread und jthread
3.1. Der Destruktor von std::thread ist „auf Unfälle ausgelegt“
std::thread hat eine berühmte Falle. Läuft der Destruktor, während der Thread noch joinable ist (weder gejoint noch detacht), wird std::terminate aufgerufen, und der Prozess stirbt sofort.9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← Wenn hier eine Ausnahme fliegt …
worker.join(); // ← join wird nie erreicht; der Destruktor von worker ruft terminate
}
Um das ausnahmesicher zu machen, musste man den join per try/catch garantieren — ein verzerrter Zustand in einer RAII-Sprache, in der ausgerechnet Threads manuell verwaltet werden mussten. C++20s std::jthread löst das. Weil sein Destruktor automatisch einen Stoppantrag stellt und dann joint, wird der obige Code allein durch den Wechsel zu std::jthread ausnahmesicher.2 Unter MSVC stehen <stop_token> und jthread ab Visual Studio 2019 16.9 zur Verfügung.3
flowchart TB
T["Thread wurde gestartet"] --> Q{"Was passiert beim<br/>Verlassen des Scopes?"}
Q -->|"std::thread<br/>weder join noch detach erfolgt"| X["std::terminate<br/>Prozess stirbt sofort"]
Q -->|"std::thread<br/>bereits gejoint"| OK1["Sicheres Joinen"]
Q -->|"std::jthread (C++20)"| OK2["Automatisch request_stop + join<br/>sicher auch bei einer Ausnahme"]
Abbildung 3: Die Lebensdauer eines Thread-Objekts und wie sie endet. std::thread ist so spezifiziert, dass ein vergessener join sofort tödlich ist — machen Sie daher ab C++20 jthread zum Standard
detach() verwenden wir grundsätzlich nicht. Ein Thread, der jede Möglichkeit zum Joinen verloren hat, gerät beim Beenden des Prozesses in Konkurrenz zur Zerstörung statischer Variablen und des Heaps und wird so zur klassischen Ursache von Abstürzen beim Beenden.
3.2. Werkzeuge „oberhalb der Thread-Ebene“ — async, future und parallele Algorithmen
Das Prinzip der .NET-Edition, „keine eigenen Threads erzeugen“, entspricht in C++ den folgenden Werkzeugen.
std::async+std::future: für eine einmalige asynchrone Aufgabe und die Entgegennahme ihres Ergebnisses. Eine wichtige Eigenheit dabei: Dasfuture(oder das letzteshared_future), das an eine mitstd::asyncgestartete Aufgabe gebunden ist, blockiert bis zum Abschluss, wenn sein Destruktor läuft, während die Aufgabe noch nicht fertig ist.7 Bei Arbeit, die tatsächlich mitstd::launch::asyncgestartet wurde, entspricht das Verwerfen des zurückgegebenen future in diesem Moment einer synchronen Ausführung. Schlimmer noch: Geben Sie keine Startrichtlinie an, kann die Implementierung standardmäßigdeferred(verzögerte Ausführung) wählen — ruft dann niemandget()/wait()auf, wird die Arbeit gar nicht erst ausgeführt und verschwindet stillschweigend. Wenn Sie nebenläufige Ausführung garantieren wollen, geben Siestd::launch::asyncexplizit an, und lassen Sie die Lebensdauer des future vom Besitzer verwalten.concurrency::parallel_for/parallel_for_eachaus der PPL (Parallel Patterns Library): die parallele Anwendung auf alle Elemente einer Collection. Ist die Arbeit einer einzelnen Iteration jedoch zu klein, frisst der Fork/Join-Overhead den Gewinn auf — deshalb parallelisiert man im Prinzip die äußere Schleife.10- C++17s parallele Algorithmen (
std::execution::par): In MSVC sind die wichtigsten Algorithmen parallelisiert (nicht alle).11 Beachten Sie: Entweicht eine Ausnahme aus der Elementverarbeitung unter einer Ausführungsrichtlinie, wirdstd::terminateaufgerufen. Eine eigene Ausnahmegrenze (try/catch) innerhalb des Callbacks zu setzen, folgt demselben Gedanken wie die Thread-Grenze in Kapitel 6.
Auch die Abgrenzung „E/A-Wartezeit ist kein Grund, Threads zu vermehren“ gilt unverändert. Für nativen Windows-Code sind OVERLAPPED-E/A und IOCP das dafür vorgesehene Auffangbecken (zur Funktionsweise siehe „Die Tiefen von Windows-E/A, Teil 2“).
4. Gemeinsam genutzten veränderlichen Zustand reduzieren — Aufteilen, Wertübergabe, const und Queues
Konflikte entstehen nur, wenn „mehrere Threads“ und „gemeinsam genutzte veränderliche Daten“ zusammenkommen. Die Anzahl der Threads wird durch die Anforderungen bestimmt, also lässt sich im Design nur an der gemeinsamen Nutzung ansetzen. Die Mittel gliedern sich in drei Familien — Aufteilen, Unveränderlichmachen, Weitergeben —, und so schreibt man sie in C++.
Aufteilen. Bei Vorgängen wie einer parallelen Aggregation lassen Sie nicht jeden Thread in eine gemeinsame Summenvariable schreiben, sondern geben jedem Thread eine eigene lokale Zwischensumme und führen sie am Ende genau einmal zusammen. Schreibzugriffe auf den gemeinsamen Zustand sinken von „bei jeder Iteration“ auf „einmal pro Thread“, wodurch sowohl die Synchronisierungskosten als auch das Zeitfenster für Konflikte um Größenordnungen kleiner werden. Dieses eine Zusammenführen kann sowohl mit einem std::mutex als auch mit einem fetch_add auf ein std::atomic erfolgen.
Als Wert übergeben. Übergeben Sie die von einem Thread beim Start benötigten Daten per Kopie (oder Move), gehören diese Daten exklusiv dem Thread, und Synchronisierung ist überflüssig. Weil Lambda-Captures per Referenz ([&]) und der anschließende Zugriff auf eine Variable mit abgelaufener Lebensdauer ein häufiger Unfall sind, sollten an Threads übergebene Lambdas explizite Captures verwenden, im Prinzip per Kopie oder Move. Allerdings gilt „kopiert, also exklusiv“ nur, wenn der Wert ein tiefer Werte-Graph ohne Aliase wie Zeiger oder shared_ptr ist. Kopiert man eine Struktur mit einem rohen Zeiger, bleibt das, worauf er zeigt, weiterhin gemeinsam genutzt.
Als const teilen. Daten, die nur gelesen werden, sind sicher, egal von wie vielen Threads gleichzeitig gelesen wird. Konfigurationswerte, Stammdaten, Berechnungseingaben und Ähnliches lassen sich ohne Synchronisierung teilen, wenn Sie sie als const-Freigabe gestalten, die nach der Konstruktion nicht mehr verändert wird (etwa std::shared_ptr<const Config>). Zu beachten: shared_ptr<const T> verbietet nur Änderungen über genau dieses Handle. Bleibt irgendwo ein nicht-const Alias bestehen, oder wird ein mutable-Member verändert, bleibt der Konflikt bestehen — entwerfen Sie also mit, dass nach Abschluss der Konstruktion die nicht-const Referenz aufgegeben wird und danach niemand mehr schreibt. Allein die Entscheidung „wird eine Änderung nötig, erzeugen wir ein neues Objekt und tauschen es aus, statt zu überschreiben“ reduziert den zu schützenden veränderlichen Zustand um eins (zur Lebensdauerverwaltung des Austauschs selbst siehe den Hinweis in 5.2).
Über eine Queue weitergeben. Leiten Sie den Datenfluss zwischen Threads über eine Producer/Consumer-Queue statt über eine gemeinsame Variable. Da der C++-Standard keinen Channel-Typ kennt, ist das Schreiben einer kleinen Queue mit std::mutex + std::condition_variable das etablierte Vorgehen.
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
{
if (capacity == 0) // Kapazität 0 wäre eine Falle, in der jeder Push für immer wartet
throw std::invalid_argument("capacity must be positive");
}
// Wartet bei vollem Zustand, bis Platz frei wird (oder ein Stoppantrag kommt). 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 gegeben, hat Stopp Vorrang,
return false; // und nach Stoppbeginn werden keine neuen Einträge mehr angenommen
queue_.push(std::move(item));
}
not_empty_.notify_one(); // Benachrichtigung außerhalb des Locks
return true;
}
// Wartet auf einen Stoppantrag (stop_token) oder das Eintreffen eines Elements. 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 gegeben, hat Stopp Vorrang,
return std::nullopt; // und nach Stoppbeginn wird keine neue Arbeit mehr 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_;
};
Zwei Punkte sind hier entwurfsentscheidend. Erstens: Setzen Sie der Kapazität eine Obergrenze, und lassen Sie die Erzeugerseite warten, wenn sie voll ist. Eine Queue ohne Obergrenze wird in Konfigurationen, in denen die Produktion schneller ist als der Konsum, zur Zeitbombe: Sie „läuft zwar“, aber der Speicherverbrauch wächst unaufhörlich. Dass Push bei voller Queue blockiert, wirkt als natürlicher Gegendruck (Backpressure) und trägt Überlast mechanisch stromaufwärts weiter. Zweitens: Weil Bedingungsvariablen Spurious Wakeups kennen (das Aufwachen ohne Benachrichtigung), rufen Sie wait stets mit einem Prädikat auf. Die Prädikat-Form von wait führt intern die Schleife „bis die Bedingung wahr ist“ für Sie aus.6
5. Lock-Disziplin — RAII und scoped_lock
Selbst wenn Sie den gemeinsam genutzten veränderlichen Zustand reduzieren, lässt er sich oft nicht auf null bringen. Für das, was übrig bleibt, verwenden Sie Ausschlusssteuerung — aber Locks ohne Disziplin verbergen Konflikte nur.
Denken Sie die Einheit eines Locks zunächst nicht als „Codeabschnitt“, sondern als „Daten“. Ordnen Sie jeder Menge zu schützender veränderlicher Daten genau einen Mutex zu (als private-Member, der nicht nach außen sichtbar ist), und nehmen Sie an jeder Stelle, die diese Daten anfasst, denselben Mutex — ein zerbrochenes Exemplar dieser Zuordnungstabelle ist in der Praxis das, was Wettlauf-Bugs eigentlich sind. Und während Sie einen Lock halten, dürfen Sie ausschließlich die geschützten Daten lesen und schreiben. Datei-E/A, Netzwerkaufrufe und Callbacks (Aufrufe von externem Code) bei gehaltenem Lock verlängern nicht nur die Haltezeit, sondern öffnen auch einen Pfad, auf dem die aufgerufene Stelle einen anderen Lock zu nehmen versucht und so einen Deadlock erzeugt. Außerhalb des Locks vorbereiten, innerhalb des Locks nur austauschen ist die Grundform.
5.1. lock()/unlock() von Hand ist verboten
Code, der lock() / unlock() von std::mutex direkt aufruft, verpasst bei Ausnahmen oder frühen Returns die Freigabe. Überlassen Sie Erwerb und Freigabe des Locks stets einem RAII-Wrapper.
| Wrapper | Verwendung |
|---|---|
std::lock_guard |
hält einen einzelnen Mutex nur für die Dauer eines Scopes — die grundlegendste Form |
std::scoped_lock (C++17) |
erwirbt mehrere Mutexe gleichzeitig. Löst das Reihenfolgeproblem mit einem Deadlock-Vermeidungsalgorithmus4 |
std::unique_lock |
wenn Sie zwischendurch freigeben/erneut erwerben wollen oder ihn an condition_variable::wait übergeben müssen |
Gibt es zwei oder mehr Locks, ist das klassische Deadlock-Muster, dass sich die Erwerbsreihenfolge je nach Thread vertauscht (so entsteht die zirkuläre Wartesituation aus Abbildung 2). Die Abhilfe besteht darin, als Regel festzulegen, dass „alle Threads in derselben Reihenfolge erwerben“ — doch für Situationen, in denen Sie gleichzeitig erwerben, hat C++ eine bessere Antwort: Übergeben Sie std::scoped_lock mehrere Mutexe zusammen, und die Bibliothek garantiert eine deadlockfreie Erwerbsreihenfolge.4 In Situationen wie einer Überweisung zwischen zwei Objekten, bei denen Sie „beides sperren“ wollen, nehmen Sie die Locks nie einzeln, sondern immer zusammen.
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 kein Zierrat. Wird derselbe Account als from und to übergeben, geben Sie scoped_lock zweimal denselben, nicht rekursiven Mutex — das führt zu einem Hänger oder undefiniertem Verhalten. Versehen Sie jede Funktion, die „beides sperrt“, unbedingt mit einem Ausschluss desselben Objekts.
Für Daten, die „oft gelesen, aber selten geschrieben“ werden, können Sie mit std::shared_mutex (C++17) einen Lese-/Schreib-Lock einsetzen.12 recursive_mutex ist zudem ein Typ dafür, dass „derselbe Thread ihn erneut erwerben kann, ohne dass es kaputtgeht“ — aber ein Design, das rekursiven Erwerb benötigt, ist häufig ein Zeichen dafür, dass die Verantwortungsgrenze des Locks verschwommen ist. Ziehen Sie zuerst eine Überarbeitung der Struktur in Betracht.
5.2. Die richtige Rolle von atomic
std::atomic bietet sowohl unteilbare Operationen auf eine einzelne Variable als auch eine auf memory_order basierende Ordnung.5 Sein Einsatzgebiet entspricht dem von Interlocked in der .NET-Edition: das Aktualisieren einer einzelnen Variable wie eines Zählers oder Flags. Mehrere Variablen gemeinsam konsistent zu halten, kann es nicht — dafür greifen Sie wieder zu std::mutex.
Das Austauschen roher Zeiger (std::atomic<T*>) hat eine eigene Falle. Auch wenn der Austausch selbst unteilbar ist, schützt niemand die Lebensdauer des alten Objekts, nachdem es ersetzt wurde. Lädt ein Leser den alten Zeiger genau in dem Moment, in dem ein Schreiber ihn austauscht und delete aufruft, ist das ein Zugriff auf bereits freigegebenen Speicher. Wollen Sie in C++ ein Design umsetzen, bei dem ein unveränderliches Objekt ausgetauscht und geteilt wird, wählen Sie ein Mittel, das mit Lebensdauerverwaltung gekoppelt ist — etwa das Austauschen eines lockgeschützten std::shared_ptr<const T> oder C++20s std::atomic<std::shared_ptr<T>>.
Und noch einmal: volatile ist kein Werkzeug zur Thread-Synchronisierung. Lock-freie Programmierung, bei der Sie memory_order selbst festlegen, ist Expertenterrain — sie erfordert sowohl einen legitimen Grund, vom Standard (seq_cst) abzuweichen, als auch ein Mittel, das zu verifizieren. Verwenden Sie in Geschäftsanwendungen entweder den Standardwert, oder schreiben Sie es von vornherein mit mutex.
6. Das Stoppen entwerfen — stop_token und kooperatives Stoppen
Die erste Frage, die bei der Review eines Multithread-Designs zu stellen ist, lautet: „Wie stoppt das eigentlich?“ Und C++ kennt kein Mittel, einen Thread von außen sicher zu stoppen (wie gefährlich Win32s TerminateThread ist, wird ausführlich in der C-Edition behandelt). Das Stoppen muss also mit den Werkzeugen von C++ als kooperatives Stoppen aufgebaut werden — die stoppende Seite stellt nur einen Antrag; wann und wie der Thread endet, entscheidet er selbst, an einer Stelle mit sauberem Abschluss; und erst mit dem Abschluss des join gilt er als „gestoppt“.
In C++20 hat std::jthread den Stoppmechanismus eingebaut. Rufen Sie request_stop() auf, wird auf dem std::stop_token, das die Thread-Funktion erhalten hat, ein Stoppantrag gesetzt, und die Schleife pollt diesen. Das wait von condition_variable_any kann direkt ein stop_token entgegennehmen, sodass sich auch ein „auf Arbeit wartender Thread“ durch einen Stoppantrag sofort wecken lässt (BlockingQueue::Pop aus Kapitel 4 hat genau diese Form).
class Worker {
public:
void Start()
{
if (thread_.joinable()) // Verweigert einen doppelten Start, während er bereits läuft.
throw std::logic_error("already running"); // Würde man statt zu verweigern zuweisen, liefe der neue
// Thread bereits los, während auf das Stoppen des alten
// gewartet wird — und zwei Worker liefen parallel
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 Verarbeitung übergeben, die intern blockieren kann
} catch (...) {
ReportError(std::current_exception()); // Einzelner Fehlschlag wird protokolliert, dann geht es weiter
}
}
}
} catch (...) {
// Letzte Verteidigungslinie an der Thread-Grenze (fängt auch Fehlschläge von Pop oder einem Move ab).
// Entweicht hier eine Ausnahme, reißt std::terminate den ganzen Prozess mit,
// deshalb darf ReportError selbst niemals 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ätsobergrenze (Kapitel 4)
std::jthread thread_;
};
flowchart TB
OWNER["Stoppende Seite<br/>(Destruktor von jthread oder request_stop)"] -->|"Stoppantrag"| ST["stop_token"]
ST --> P["Berechnungsschleife:<br/>pollt stop_requested()"]
ST --> W["Wartender Thread:<br/>condition_variable_any::wait(lock, st, pred)<br/>wacht sofort auf"]
P --> E["Räumt auf und returnt selbst"]
W --> E
E --> J["join schließt das Zusammenführen ab<br/>erst jetzt gilt er als „gestoppt“"]
Abbildung 4: Kooperatives Stoppen in C++20. Die stoppende Seite stellt nur den Antrag, wie er endet, entscheidet der Thread selbst, und erst mit dem Abschluss des join gilt er als gestoppt
Ein weiterer Punkt: Das try/catch innerhalb des Workers lässt sich nicht weglassen. Was jthread ausnahmesicher macht, ist ausschließlich der join — entweicht eine Ausnahme aus der Thread-Funktion, stürzt der Prozess genau wie bei std::thread über std::terminate ab. Legen Sie an der Thread-Grenze explizit fest, wie der Fehlschlag einer einzelnen Aufgabe behandelt wird (protokollieren und weitermachen, oder über einen Fehlerkanal an den Besitzer melden).
Beachten Sie aus demselben Grund, dass auch Process das stop_token übergeben bekommt. Blockiert die Verarbeitung eines einzelnen Auftrags intern (Warten auf das Netzwerk, eine lange Berechnung usw.) und kann diese Stelle den Stoppantrag nicht beobachten, wartet der implizite join des Destruktors endlos auf den Abschluss dieses einen Auftrags. Kooperatives Stoppen funktioniert erst, wenn das Token an „jeder Wartestelle“ angekommen ist. Enthält die Arbeit einen nicht unterbrechbaren externen Aufruf, versehen Sie ihn mit einem Timeout und setzen Sie eine Obergrenze für die Laufzeit eines einzelnen Auftrags.
In Umgebungen vor C++17 bauen Sie dieselbe Struktur von Hand mit einem std::atomic<bool>-Stoppflag plus notify_all einer condition_variable. Der entscheidende Punkt dabei: Nehmen Sie die Prüfung des Stoppflags in das Prädikat der Bedingungsvariable auf (setzen Sie nur das Flag und vergessen die Benachrichtigung, wacht ein wartender Thread nie mehr auf).
7. Windows-spezifische Hinweise — die Grenze zur Win32-API
7.1. Wahl zwischen Standardbibliothek und Win32-Synchronisierungsobjekten
Microsofts Dokumentation empfiehlt für portabilitätsorientierten C++-Code std::mutex / std::shared_mutex und ordnet den Einsatz von Win32-Synchronisierungsobjekten den Fällen „eine Win32-Warte-API wird benötigt“ und „prozessübergreifende Synchronisierung“ zu.8
| 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, Mutex |
| Prozessübergreifender Ausschluss/Benachrichtigung | Benannte Mutexe, Events, Semaphoren |
| Prozessinterner Lock bei direkter Verwendung der Win32-API | SRW-Lock (CRITICAL_SECTION nur bei Rekursionsbedarf)8 |
Ein konkretes Design für den Ausschluss von gemeinsam genutztem Speicher zwischen Prozessen finden Sie in „Fallstricke bei gemeinsam genutztem Speicher und praktische Best Practices“.
7.2. In DllMain nicht mit Threads hantieren
Eine gravierende Einschränkung beim Schreiben einer DLL ist der Loader-Lock. DllMain wird aufgerufen, während der Loader-Lock gehalten wird, sodass Operationen darin wie die Synchronisierung mit einem anderen Thread, das Warten auf das Ende eines Threads oder der Aufruf von LoadLibrary zu Deadlocks oder undefiniertem Verhalten führen. Verlagern Sie jede Initialisierung, die Threads startet oder joint, aus DllMain heraus — in eine explizite Initialisierungsfunktion.13
7.3. UI-Thread und COM-Apartments
Windows-Desktopanwendungen unterliegen einer strengen, sprachunabhängigen Einschränkung: dem Gebot, dass Fenster und Steuerelemente nur von dem Thread angefasst werden dürfen, der sie erzeugt hat (dem UI-Thread). Windows liefert Fensternachrichten an die Nachrichtenwarteschlange des Threads aus, der das jeweilige Fenster erzeugt hat — Erzeugung und Bedienung der UI müssen deshalb auf diesem Thread gebündelt bleiben. Wollen Sie den Bildschirm von einem Worker-Thread aus aktualisieren, fassen Sie ihn nicht direkt an, sondern beauftragen Sie den UI-Thread über PostMessage (asynchron) und behandeln die Nachricht in der Fensterprozedur auf Seiten des UI-Threads. Die synchrone Form SendMessage führt, wenn sie in einer Situation aufgerufen wird, in der der UI-Thread gerade auf den Abschluss dieses Workers wartet, zu einem Deadlock, in dem beide aufeinander warten — machen Sie daher die asynchrone Form zum Standard für Benachrichtigungen aus einem Worker heraus. STA/MTA, soweit COM im Spiel ist, wird in „Grundlagen von COM STA/MTA“ erläutert. Beachten Sie außerdem, dass in C++/CLI-Code, der mit /clr kompiliert wird, die Standard-Thread-Header wie <thread> und <mutex> blockiert sind.14
8. Verifikation und Debugging — Vorbereitung unter der Annahme, dass es sich nicht reproduzieren lässt
Von Tests lässt sich nicht erwarten, dass sie Wettlauf-Bugs finden, denn ein gewöhnlicher Test zählt einen Lauf, bei dem „zufällig kein Konflikt auftrat“, als Erfolg. Denken Sie die Vorsorge in drei Schichten.
Die erste Verteidigungslinie sind genau die bisherigen Designprinzipien. Prüfen Sie in der Review anhand einer Tabelle: welche veränderlichen Daten gemeinsam genutzt werden, welcher Mutex jeweils schützt, ob die Erwerbsreihenfolge mehrerer Locks eindeutig ist (oder sie zusammen mit scoped_lock genommen werden) und wo der Stopp-Pfad verläuft. Ein Design, für das sich diese Tabelle nicht ausfüllen lässt, ist noch nicht fertig, auch wenn es gerade läuft.
Zweitens machen Sie Anomalien beobachtbar, statt sie zu verbergen. Versehen Sie Locks, die eigentlich nicht scheitern dürften, mit einem Timeout über try_lock_for von timed_mutex oder wait_for von condition_variable, und protokollieren Sie das Überschreiten als Anomalie — so verwandeln Sie einen ewigen Hänger in einen erkennbaren Fehlschlag. Protokollieren Sie Ausnahmen, die im try/catch an der Thread-Grenze (Kapitel 6) aufgefangen werden, unbedingt. Erfassen Sie bei einem Hänger oder Absturz vor Ort einen Dump, prüfen Sie die Stacks aller Threads und sehen Sie nach, ob sich gegenseitige Lock-Wartezeiten zu einem Kreis schließen. Aufbau und Pflege von Dumps und Logs behandeln wir in „Design für Logs und Dumps bei Abstürzen von Windows-Apps“.
Drittens schütteln Sie das System unter Last durch. Stresstests — längeres Laufenlassen mit mehr Parallelität als vorhandenen Kernen, das Randomisieren der Verarbeitungsreihenfolge, das Einfügen künstlicher Verzögerungen — sind ein praktisches Mittel, um auf der Entwicklungsmaschine leichter einen Konflikt „zu treffen“. Bugs, die im Debug-Build verschwinden, lassen sich in einem optimierten Release-Build unter hoher Last oft reproduzieren.
9. Zusammenfassung — Checkliste für C++
Zu den sprachübergreifenden Prinzipien (keine Threads direkt erzeugen, gemeinsam genutzten veränderlichen Zustand minimieren, eine 1:1-Zuordnung von Lock und Daten, kooperatives Stoppen) kommen die folgenden C++-spezifischen Prüfpunkte hinzu.
- Wird
std::threadungeschützt verwendet (könnte esjthreadsein? Ist der join auch auf dem Ausnahmepfad garantiert?) - Wird
detach()verwendet? - Sind Lambda-Captures explizit, und lebt jede per Referenz erfasste Variable länger als der Thread?
- Lässt sich mit Sicherheit ausschließen, dass es auch nur eine einzige unsynchronisierte gemeinsame veränderliche Zugriffsstelle (= undefiniertes Verhalten) gibt?
- Gibt es kein handgeschriebenes
lock()/unlock(), und werden mehrere Locks zusammen mitscoped_lockgenommen? - Ist jedes
condition_variable::waitmit einem Prädikat versehen? - Wird für ein gemeinsam genutztes Flag kein
volatileverwendet (steht dortstd::atomic)? - Ist der Stopp-Pfad um
stop_token(oder ein atomares Flag plus Benachrichtigung) entworfen, und bestätigt der join den Abschluss des Zusammenführens? - Wird das future von
std::asyncnicht verworfen? - Werden in
DllMainkeine Threads gestartet, synchronisiert oder gejoint?
Multithreading in C++ ist eine Arbeit, die dicht am Abgrund des „undefinierten Verhaltens“ entlangführt — umgekehrt bedeutet das aber auch: Schon der ehrliche Anschluss an RAII und die Konventionen der Standardbibliothek bringt erheblichen Abstand von diesem Abgrund. jthread, scoped_lock, das Prädikat-wait, atomic — die richtigen Standardeinstellungen dieser Werkzeuge zu wählen, ist in C++ die Praxis der Designprinzipien selbst.
Verwandte Artikel
- Praktische Multithreading-Best-Practices: .NET-Edition
- Praktische Multithreading-Best-Practices: C-Edition
- Praktische Multithreading-Best-Practices: Java-Edition
- Native DLLs mit C++/CLI umschließen — Praxis
- Fallstricke bei gemeinsam genutztem Speicher und praktische Best Practices
- Grundlagen von COM STA/MTA - Thread-Modelle und wie man Hänger vermeidet
- Die Tiefen von Windows-E/A (Teil 2) — Synchrone und asynchrone E/A: Was OVERLAPPED wirklich bedeutet
Verwandte Beratungsleistungen
Die KomuraSoft LLC übernimmt Multithreading-Design-Reviews für C++-Anwendungen und -DLLs, die Ursachenuntersuchung (Dump-Analyse) von wettlaufbedingten Fehlern wie „stürzt gelegentlich ab“ oder „verhält sich nur im Release-Build seltsam“, sowie die Beratung zur Migration von Legacy-Thread-Code nach modernem C++.
- Technische Beratung & Design-Review
- Fehleruntersuchung & Ursachenanalyse
- Windows-Anwendungsentwicklung
- Kontakt
Referenzlinks
-
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
-
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
-
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. ↩ ↩2 ↩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
-
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
-
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 Spurious Wakeups existieren — das Aufwachen ohne Benachrichtigung —, 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
-
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. ↩ ↩2
-
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
-
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 detacht) — also dazu, dass die Entscheidung für join oder detach vor der Zerstörung des Thread-Objekts ausnahmslos getroffen sein muss. ↩
-
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. ↩
-
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. ↩
-
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). ↩
-
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. ↩
-
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. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Praktische Multithreading-Best-Practices: C-Edition — Sicher schreiben nach Win32-API-Konvention
Bei Multithreading in C mit Win32 sind Thread-Erstellung über _beginthreadex, SRW-Locks und Bedingungsvariablen, Interlocked-Funktionen s...
Praktische Best Practices für Multithreading: .NET-Edition — Was Sie entscheiden sollten, bevor Sie weitere Threads hinzufügen
Eine praxisnahe Übersicht der Entwurfsregeln, die verhindern, dass Multithreading-Code in .NET/C# gelegentlich abstürzt oder hängen bleib...
Praktische Best Practices für Multithreading: Java-Edition — Konventionen für das Zeitalter der virtuellen Threads
In Java besteht die bewährte Praxis für Multithreading darin, niemals Threads direkt zu erstellen, sondern auf ExecutorService und virtue...
Volumeschattenkopie (VSS): Funktionsweise und Praxis ── Warum sich Dateien in Benutzung sichern lassen
Dateien in Benutzung lassen sich wegen Freigabekonflikten eigentlich nicht kopieren – wie schafft es Backup-Software trotzdem? Dieser Art...
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
Technische Beratung und Design-Review
Klärung von Änderungsstrategie, Entwurf und Umgang mit bestehenden Assets.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Wie sollte ich 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 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 müssen oder wenn Sie mit einem benannten Objekt 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 sperren muss. Einen Win32-Mutex für den prozessinternen Ausschluss zu verwenden, ist ein klassischer Fehler, weil dabei immer ein Kernelübergang stattfindet und das entsprechend langsam ist.
- Darf ich detach() von std::thread verwenden?
- Im Prinzip nicht. Ein detachter 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 detachter 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 eines Threads warten zu können, ist eine Grundanforderung des Thread-Designs. Verwenden Sie daher jthread (das automatisch joint), 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++ auch zur Synchronisierung zwischen Threads verwendet werden?
- Nein. Das volatile von C++ ist ein Qualifizierer für Lese-/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 separaten 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 garantieren müssen.
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.