Historique de la question.
Le dispositif std::future et std::promise est arrivé en C++11 pour formaliser le transfert asynchrone de résultats entre threads. Les approches précédentes reposaient sur une mémoire partagée ad hoc avec une synchronisation manuelle, ce qui rendait la gestion des exceptions presque impossible à travers les frontières des threads. Le comité de normalisation a exigé un mécanisme capable de capturer n'importe quel type d'exception lancé dans un thread de travail et de le reproduire fidèlement dans le thread d'attente sans connaître le type statique de l'exception au moment du stockage.
Le problème.
Les objets d'exception sont polymorphes et alloués sur la pile par défaut, mais ils doivent survivre à la portée du std::promise qui les a produits. Puisque std::future est seulement paramétré sur le type de résultat, et non pas sur le type d'exception, l'état partagé ne peut pas contenir un membre d'exception typé. De plus, le thread consommateur peut survivre au thread producteur, nécessitant que l'exception persiste dans un stockage alloué sur le tas avec des sémantiques de propriété partagée.
La solution.
La norme exige que std::promise utilise std::exception_ptr pour capturer les exceptions via std::current_exception(), qui effectue un effacement de type implicite en copiant l'exception dans le tas et en stockant un handle effacé de type. L'état partagé (un bloc de contrôle avec comptage de référence) conserve ce std::exception_ptr, permettant à std::future::get() de détecter l'exception et de la relancer en utilisant std::rethrow_exception().
std::promise<int> prom; auto fut = prom.get_future(); std::thread([&prom]{ try { throw std::runtime_error("Le travailleur a échoué"); } catch(...) { prom.set_exception(std::current_exception()); } }).detach(); try { int val = fut.get(); // Relance runtime_error } catch(const std::exception& e) { // Gère l'exception transportée }
Contexte.
Un cadre de calcul distribué nécessitait des threads de travail pour traiter des tâches de segmentation d'images qui pouvaient échouer en raison d'exceptions GPUOutOfMemory ou CorruptInputData. Le thread principal devait recevoir ces exceptions spécifiques pour déclencher un traitement par défaut sur le CPU ou une retransmission de données.
Description du problème.
Les premières tentatives ont utilisé manuellement std::exception_ptr mais ont souffert de bogues de durée de vie où les exceptions étaient détruites tout en étant encore référencées par la file d'erreur du thread principal. Les développeurs ont également eu du mal à stocker des types d'exception hétérogènes dans un seul conteneur de résultats sans découpage ou découpe d'objet pendant le stockage polymorphe.
Solution 1 : Files d'attente d'exception typées.
L'équipe a envisagé de maintenir des files d'attente séparées pour chaque type d'exception à l'aide de templates. Cela offrait une sécurité de type mais nécessitait std::any pour l'effacement de type dans la file commune, ajoutant une surcharge et une complexité significatives. Cela rompait également la capacité de capturer les exceptions naturellement avec des blocs try-catch dans le thread consommateur.
Solution 2 : Conteneur d'exception virtuel.
Ils ont mis en œuvre une classe abstraite ExceptionBase avec des classes dérivées templées stockées dans std::unique_ptr<ExceptionBase>. Bien que cela ait permis un stockage polymorphe, il nécessitait une logique de clonage manuelle pour maintenir la propriété partagée à travers les threads et introduisait une surcharge de dispatch virtuel lors de la relance. Le comptage de référence personnalisé était sujet à des erreurs et difficile à rendre sûr pour les exceptions.
Solution choisie et pourquoi.
L'équipe a adopté std::packaged_task avec std::future, qui utilise en interne le mécanisme std::promise/std::exception_ptr. Cela a éliminé le code d'effacement de type personnalisé car la bibliothèque standard gérait la capture d'exception et la durée de vie de l'état partagé automatiquement. Le choix était dicté par le besoin de sécurité des exceptions sans maintenance et la nécessité de soutenir les modèles de gestion des exceptions standard sans classes de base personnalisées.
Résultat.
Le système a réussi à propager des types d'exception spécifiques à travers les frontières de threads sans fuites de mémoire, même lors d'un redimensionnement agressif de la pool de threads. Le thread principal pouvait capturer GPUOutOfMemory spécifiquement tout en se défaussant sur std::exception pour les erreurs inconnues, maintenant une séparation claire entre la logique de gestion des erreurs et la synchronisation des threads.
Question : Pourquoi std::current_exception() copie-t-il l'objet d'exception plutôt que de stocker un pointeur vers l'exception existante ?
Réponse.
L'objet d'exception dans un bloc catch est généralement une copie temporaire créée par l'exécution lors du déroulement de la pile. Stocker un pointeur brut créerait une référence pendante une fois que le bloc catch est sorti et que le cadre de pile est détruit. En copiant l'exception dans le tas, std::current_exception() garantit que l'objet persiste indépendamment de la pile du thread qui lève l'exception. Cette opération de copie permet également le mécanisme d'effacement de type, permettant à std::exception_ptr de gérer l'objet via un destructeur effacé de type tout en conservant la possibilité de relancer le type original exact plus tard.
Question : Comment std::promise empêche-t-il les conditions de compétition entre set_value() et set_exception() ?
Réponse.
L'état partagé contient un drapeau d'état atomique suivant si la promesse est satisfaite. Lorsque set_value() ou set_exception() est appelé, l'implémentation effectue une opération de comparaison et d'échange atomique pour faire passer l'état de "non satisfait" à "prêt". Si l'état est déjà prêt, l'opération lance std::future_error avec promise_already_satisfied. Cette transition atomique garantit que le thread consommateur observant l'état prêt voit une valeur ou une exception entièrement construite, empêchant les lectures ou les écritures partielles lors d'un accès simultané par le producteur et le consommateur.
Question : Pourquoi std::exception_ptr peut-il survivre à la fois à std::promise et std::future qui l'ont créé ?
Réponse.
std::exception_ptr utilise le comptage de référence intrusif sur l'objet d'exception lui-même, indépendamment de l'état partagé std::future/std::promise. Ce design permet au code de gestion des exceptions de stocker les erreurs dans des journaux ou des gestionnaires d'erreurs à long terme après que l'opération asynchrone a été exécutée et que ses objets future/promise associés ont été détruits. Le comptage de référence garantit que l'objet d'exception est détruit uniquement lorsque le dernier std::exception_ptr qui le référence est détruit, soutenant des cas d'utilisation comme le rapport d'erreur différé ou l'agrégation d'exceptions à travers plusieurs opérations asynchrones.