Historie der Frage.
Die Funktionalität std::future und std::promise wurde mit C++11 eingeführt, um die asynchrone Ergebnisübertragung zwischen Threads zu formalisieren. Frühere Ansätze basierten auf Ad-hoc-Shared-Memory mit manueller Synchronisation, was die Ausnahmebehandlung über Thread-Grenzen nahezu unmöglich machte. Das Standardisierungskomitee verlangte einen Mechanismus, der jeden Ausnahme-Typ erfassen kann, der in einem Arbeits-Thread ausgelöst wurde, und ihn im wartenden Thread wiederherzustellen, ohne den statischen Typ der Ausnahme an dem Punkt der Speicherung zu kennen.
Das Problem.
Ausnahmeobjekte sind polymorph und standardmäßig stack-zugewiesen, müssen jedoch den Gültigkeitsbereich von std::promise, das sie erzeugt hat, überstehen. Da std::future nur auf den Ergebnistyp, nicht auf den Ausnahmetyp, templatisiert ist, kann der gemeinsame Zustand kein getyptes Ausnahmemitglied enthalten. Darüber hinaus kann der Verbraucher-Thread länger leben als der Produzenten-Thread, was erfordert, dass die Ausnahme im heap-zugewiesenen Speicher mit gemeinschaftlichen Besitzsemantiken erhalten bleibt.
Die Lösung.
Der Standard schreibt vor, dass std::promise std::exception_ptr verwendet, um Ausnahmen über std::current_exception() zu erfassen, wodurch eine implizite Typauslöschung erfolgt, indem die Ausnahme in den Heap kopiert und ein typausgelöschter Handle gespeichert wird. Der gemeinsame Zustand (ein referenzgezählter Steuerblock) behält dieses std::exception_ptr, wodurch std::future::get() die Ausnahme erkennen und sie mithilfe von std::rethrow_exception() erneut auslösen kann.
std::promise<int> prom; auto fut = prom.get_future(); std::thread([&prom]{ try { throw std::runtime_error("Arbeiter fehlgeschlagen"); } catch(...) { prom.set_exception(std::current_exception()); } }).detach(); try { int val = fut.get(); // Löst runtime_error erneut aus } catch(const std::exception& e) { // Behandelt die übertragene Ausnahme }
Kontext.
Ein verteiltes Rechnungsframework erforderte, dass Arbeits-Threads Bildsegmentierungsaufgaben verarbeiten, die aufgrund von GPUOutOfMemory oder CorruptInputData Ausnahmen fehlschlagen konnten. Der Haupt-Thread musste diese spezifischen Ausnahmen empfangen, um eine Fallback-CPU-Verarbeitung oder Datenübertragung auszulösen.
Problembeschreibung.
Ursprüngliche Versuche verwendeten std::exception_ptr manuell, litten jedoch unter Lebensdauerfehlern, bei denen Ausnahmen zerstört wurden, während sie noch von der Fehlerwarteschlange des Haupt-Threads referenziert wurden. Entwickler hatten auch Schwierigkeiten, heterogene Ausnahmetypen in einem einzigen Ergebnisträger zu speichern, ohne Scheibchenbildung oder Objekt-Slicing während der polymorphen Speicherung.
Lösung 1: Getypte Ausnahmewarteschlangen.
Das Team erwog, separate Warteschlangen für jeden Ausnahmetyp unter Verwendung von Templates zu führen. Dies gewährte Typsicherheit, erforderte jedoch std::any für die Typauslöschung in der gemeinsamen Warteschlange, was erhebliche Overhead und Komplexität hinzufügte. Es unterbrach auch die Möglichkeit, Ausnahmen auf natürliche Weise mit try-catch-Blöcken im Verbraucher-Thread zu erfassen.
Lösung 2: Virtueller Ausnahmehalter.
Sie implementierten eine abstrakte ExceptionBase-Klasse mit templatisierten abgeleiteten Klassen, die in std::unique_ptr<ExceptionBase> gespeichert wurden. Während dies die polymorphe Speicherung ermöglichte, erforderte es manuelle Klonlogik, um den gemeinsamen Besitz über Threads aufrechtzuerhalten, und führte zu Überkopflasten durch virtuelle Dispatch während der erneuten Auslösung. Die benutzerdefinierte Referenzzählung war fehleranfällig und schwierig, prophylaktisch gegen Ausnahmen selbst zu gestalten.
Gewählte Lösung und warum.
Das Team entschied sich für std::packaged_task mit std::future, das intern den Mechanismus std::promise/std::exception_ptr verwendet. Dies beseitigte den benutzerdefinierten Typauslöschungscode, da die Standardbibliothek die Ausnahmeauffangung und die Lebensdauer des gemeinsamen Zustands automatisch handhabte. Die Entscheidung wurde von der Notwendigkeit einer wartungsfreien Ausnahmesicherheit und dem Erfordernis getragen, standardisierte Ausnahmemuster ohne benutzerdefinierte Basisklassen zu unterstützen.
Ergebnis.
Das System konnte erfolgreich spezifische Ausnahmetypen über Thread-Grenzen propagieren, ohne dass es zu Speicherlecks kam, selbst während aggressiver Thread-Pool-Vergrößerungen. Der Haupt-Thread konnte GPUOutOfMemory spezifisch abfangen, während er bei unbekannten Fehlern standardmäßig auf std::exception zurückfiel, und gewährte so eine saubere Trennung zwischen Fehlerbehandlungslogik und Thread-Synchronisation.
Frage: Warum kopiert std::current_exception() das Ausnahmeobjekt, anstatt einen Zeiger auf die bestehende Ausnahme zu speichern?
Antwort.
Das Ausnahmeobjekt in einem catch-Block ist typischerweise eine temporäre Kopie, die zur Laufzeit während der Stapelauflösung erstellt wird. Das Speichern eines rohen Zeigers würde eine hängende Referenz erzeugen, sobald der Catch-Block verlässt und der Stapelrahmen zerstört wird. Durch das Kopieren der Ausnahme in den Heap stellt std::current_exception() sicher, dass das Objekt unabhängig vom Stapel des werfenden Threads bestehen bleibt. Dieser Kopiervorgang ermöglicht auch den Typauslöschmechanismus, sodass std::exception_ptr das Objekt über einen typausgelöschten Löscher verwalten kann, während die Möglichkeit, den ursprünglichen Typ später erneut auszulösen, beibehalten wird.
Frage: Wie verhindert std::promise Wettlaufbedingungen zwischen set_value() und set_exception()?
Antwort.
Der gemeinsame Zustand enthält eine atomare Statusmarkierung, die verfolgt, ob das Versprechen erfüllt ist. Wenn entweder set_value() oder set_exception() aufgerufen wird, führt die Implementierung eine atomare Vergleich-und-Tausch-Operation durch, um den Zustand von "nicht erfüllt" auf "bereits" zu ändern. Wenn der Zustand bereits bereit ist, wirft die Operation std::future_error mit promise_already_satisfied. Diese atomare Übergang stellt sicher, dass der Verbraucher-Thread, der den bereitgestellten Zustand beobachtet, einen vollständig konstruierten Wert oder eine Ausnahme sieht und verhindert partielle Lese- oder Schreibvorgänge während des gleichzeitigen Zugriffs durch den Produzenten und Verbraucher.
Frage: Warum kann std::exception_ptr sowohl über das std::promise als auch über das std::future, das es erstellt hat, hinaus leben?
Antwort.
std::exception_ptr verwendet intrusives Referenzzählen auf dem Ausnahmeobjekt selbst, unabhängig vom std::future/std::promise gemeinsamen Zustand. Dieses Design ermöglicht es dem Ausnahmen-Handling-Code, Fehler in langlebigen Protokollen oder Fehlerbehandlern zu speichern, nachdem die asynchrone Operation abgeschlossen und ihre zugehörigen Future/Pledge-Objekte zerstört wurden. Die Referenzzählung stellt sicher, dass das Ausnahmeobjekt nur zerstört wird, wenn der letzte std::exception_ptr, der auf es verweist, zerstört wird, und unterstützt Anwendungsfälle wie verzögerte Fehlerberichterstattung oder Ausnahmeaggregation über mehrere asynchrone Operationen.