Historia pytania.
Funkcja std::future oraz std::promise pojawiła się w C++11, aby sformalizować asynchroniczny transfer wyników między wątkami. Wcześniejsze podejścia opierały się na ad-hoc współdzielonej pamięci z ręczną synchronizacją, co niemal uniemożliwiało obsługę wyjątków przez granice wątków. Komitet standaryzacyjny wymagał mechanizmu, który mógłby uchwycić dowolny typ wyjątku zgłoszonego w wątku roboczym i wiernie odtworzyć go w wątku oczekującym, nie znając statycznego typu wyjątku w punkcie przechowywania.
Problem.
Obiekty wyjątków są polimorficzne i domyślnie przydzielane na stosie, ale muszą przetrwać zakres std::promise, który je wytworzył. Ponieważ std::future jest szablonowane tylko na typ wyniku, a nie na typ wyjątku, stan współdzielony nie może zawierać członu wyjątkowego o typie. Co więcej, wątek konsumenta może przeżyć wątek producenta, co wymaga, aby wyjątek przetrwał w pamięci przydzielonej na stercie z semantyką współdzielonej własności.
Rozwiązanie.
Standard nakazuje, aby std::promise używał std::exception_ptr do uchwycenia wyjątków za pomocą std::current_exception(), co dokonuje implicitnego usunięcia typu przez skopiowanie wyjątku na stertę i przechowywanie uchwytu pozbawionego typu. Stan współdzielony (blok kontrolny z licznikiem odniesień) przechowuje ten std::exception_ptr, co pozwala na wykrycie wyjątku przez std::future::get() oraz ponowne zgłoszenie go za pomocą std::rethrow_exception().
std::promise<int> prom; auto fut = prom.get_future(); std::thread([&prom]{ try { throw std::runtime_error("Worker failed"); } catch(...) { prom.set_exception(std::current_exception()); } }).detach(); try { int val = fut.get(); // Ponownie zgłasza runtime_error } catch(const std::exception& e) { // Obsługuje transportowany wyjątek }
Kontekst.
Framework obliczeniowy rozproszony wymagał, aby wątki robocze przetwarzały zadania segmentacji obrazu, które mogły nie powieść się z powodu wyjątków GPUOutOfMemory lub CorruptInputData. Główny wątek musiał otrzymać te konkretne wyjątki, aby uruchomić awaryjne przetwarzanie na CPU lub retransmisję danych.
Opis problemu.
Początkowe podejścia wykorzystały std::exception_ptr ręcznie, ale borykały się z problemami związanymi z czasem życia, w których wyjątki były niszczone, gdy były jeszcze referencjonowane przez kolejkę błędów głównego wątku. Programiści również mieli trudności z przechowywaniem heterogenicznych typów wyjątków w jednym kontenerze wyników bez łamania lub przekroju obiektów podczas polimorficznego przechowywania.
Rozwiązanie 1: Typowane kolejki wyjątków.
Zespół rozważył utrzymywanie oddzielnych kolejek dla każdego typu wyjątku z użyciem szablonów. To zapewniało bezpieczeństwo typu, ale wymagało użycia std::any do usunięcia typu w wspólnej kolejce, co wprowadzało znaczne obciążenie i złożoność. To również łamało możliwość naturalnego przechwytywania wyjątków z użyciem bloków try-catch w wątku konsumenta.
Rozwiązanie 2: Wirtualny uchwyt wyjątków.
Zaimplementowali abstrakcyjną klasę ExceptionBase z klasy szablonowymi pochodnymi przechowywanymi w std::unique_ptr<ExceptionBase>. Chociaż umożliwiło to polimorficzne przechowywanie, wymagało ręcznej logiki klonowania dla utrzymania współdzielonej własności między wątkami i wprowadziło narzut związany z wirtualnym wywołaniem podczas ponownego zgłaszania. Niestandardowe liczenie odniesień było podatne na błędy i trudne do zabezpieczenia samego w sobie.
Wybrane rozwiązanie i dlaczego.
Zespół przyjął std::packaged_task z std::future, które wewnętrznie używa mechanizmu std::promise/std::exception_ptr. To wyeliminowało niestandardowy kod usuwania typu, ponieważ biblioteka standardowa obsługiwała uchwycenie wyjątku i czas życia stanu współdzielonego automatycznie. Wybór był podyktowany potrzebą zerowej konserwacji bezpieczeństwa wyjątków oraz wymogiem wsparcia standardowych wzorców obsługi wyjątków bez niestandardowych klas bazowych.
Wynik.
System skutecznie propagował konkretne typy wyjątków przez granice wątków bez wycieków pamięci, nawet podczas agresywnego zmieniania rozmiaru puli wątków. Główny wątek mógł przechwycić GPUOutOfMemory specyficznie, stosując domyślnie std::exception dla nieznanych błędów, zachowując czystą separację między logiką obsługi błędów a synchronizacją wątków.
Pytanie: Dlaczego std::current_exception() kopiuje obiekt wyjątku, zamiast przechowywać wskaźnik do istniejącego wyjątku?
Odpowiedź.
Obiekt wyjątku w bloku catch jest zazwyczaj tymczasową kopią utworzoną przez środowisko uruchomieniowe podczas rozwiązywania stosu. Przechowywanie surowego wskaźnika utworzyłoby wiszące odniesienie po zakończeniu bloku catch, gdy rama stosu zostanie zniszczona. Poprzez skopiowanie wyjątku na stertę, std::current_exception() zapewnia, że obiekt przetrwa niezależnie od stosu wątku, który go zgłasza. Ta operacja kopiowania również umożliwia mechanizm usuwania typu, pozwalając std::exception_ptr zarządzać obiektem przez pozbawiony typu deleter, zachowując jednocześnie możliwość ponownego zgłoszenia dokładnie oryginalnego typu później.
Pytanie: Jak std::promise zapobiega warunkom wyścigu między set_value() a set_exception()?
Odpowiedź.
Stan współdzielony zawiera atomowy znacznik statusu śledzący, czy obietnica jest spełniona. Gdy wywoływana jest set_value() lub set_exception(), implementacja wykonuje atomową operację porównania i zamiany, aby przejść stan od "niespełnionej" do "gotowej". Jeśli stan jest już gotowy, operacja zgłasza std::future_error z promise_already_satisfied. Ta atomowa zmiana zapewnia, że wątek konsumenta obserwujący gotowy stan widzi w pełni skonstruowaną wartość lub wyjątek, zapobiegając częściowym odczytom lub zapisom podczas współbieżnego dostępu producenta i konsumenta.
Pytanie: Dlaczego std::exception_ptr może przeżyć zarówno std::promise, jak i std::future, które go utworzyły?
Odpowiedź.
std::exception_ptr używa inwazyjnego liczenia odniesień w samym obiekcie wyjątku, niezależnie od stanu współdzielonego std::future/std::promise. Ten projekt pozwala kodowi obsługi wyjątków na przechowywanie błędów w długotrwałych dziennikach lub menedżerach błędów po zakończeniu operacji asynchronicznej i zniszczeniu związanych obiektów future/promise. Liczenie odniesień zapewnia, że obiekt wyjątku jest niszczony tylko wtedy, gdy ostatni std::exception_ptr odwołujący się do niego zostanie zniszczony, wspierając takie przypadki użycia jak opóźnione zgłaszanie błędów czy agregacja wyjątków w wielu operacjach asynchronicznych.