Historique de la question
Le trait UnwindSafe a été introduit dans Rust 1.9 avec std::panic::catch_unwind pour traiter les préoccupations de sécurité des exceptions héritées du C++ et d'autres langages avec gestion des exceptions. En Rust, les panics déclenchent un dépliage de la pile qui garantit que les implémentations de Drop s'exécutent, mais cela ne garantit pas automatiquement que les structures de données restent dans des états cohérents si un panic interrompt une opération logique. Le trait a été conçu pour marquer les types qui tolèrent d'être dans un état actif à travers une frontière catch_unwind sans risquer un comportement indéfini ou des erreurs logiques.
Le problème
Quand une référence mutable (&mut T) traverse une frontière catch_unwind, et que T contient une mutabilité intérieure (comme RefCell ou Cell), un panic peut laisser T dans un état logiquement incohérent. Par exemple, si un panic se produit entre RefCell::borrow_mut et le drop implicite du guard RefMut résultant, le compteur de prêt interne de RefCell reste incrémenté. Après que catch_unwind capte le panic et que l'exécution reprend, le RefCell semble prêt à être muté, mais le guard qui décrémenterait le compteur a été abandonné lors du dépliage. Cet état "empoisonné" constitue une violation de la sécurité des exceptions car les opérations suivantes sur le RefCell provoqueront un panic ou se comporteront incorrectement, corrompant effectivement l'état du programme d'une manière que le code sécurisé ne peut pas détecter ou récupérer.
La solution
UnwindSafe sert de marqueur conservateur : il est automatiquement implémenté pour la plupart des types mais opté explicitement pour &mut T et tout agrégat qui le contient. En interdisant à &mut T d'implémenter UnwindSafe, le système de types empêche le passage de références mutables dans catch_unwind à moins que le programmeur ne les enveloppe explicitement dans AssertUnwindSafe. Ce wrapper est un contrat dangereux où le programmeur affirme que le type enveloppé soit dépourvu de mutabilité intérieure ou qu'il a manuellement vérifié la sécurité des exceptions. Ce choix architectural force une opt-in explicite à un modèle potentiellement dangereux, garantissant que l'exposition accidentelle d'un état mutable, mutable-interne à travers des frontières de panic soit interceptée au moment de la compilation.
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::cell::RefCell; fn main() { let shared = RefCell::new(vec![1, 2, 3]); // Cela échoue à la compilation car &mut RefCell n'est pas UnwindSafe : // let _ = catch_unwind(|| { // let mut borrow = shared.borrow_mut(); // borrow.push(4); // panic!("interrompu"); // }); // Opt-in explicite avec reconnaissance unsafe : let result = catch_unwind(AssertUnwindSafe(|| { let mut borrow = shared.borrow_mut(); borrow.push(4); panic!("interrompu"); })); // Après le panic, shared pourrait être dans un état de prêt invalide, // mais nous avons explicitement reconnu ce risque avec AssertUnwindSafe. println!("Récupéré : {:?}", result.is_err()); }
Description du problème
Un serveur HTTP haute performance construit avec hyper doit isoler les panics dans les gestionnaires de requêtes définis par l'utilisateur pour empêcher qu'une seule requête malformée ne termine tout le processus. Le serveur maintient un pool de connexions utilisant RefCell (pour des performances monocœurs) pour suivre les connexions de base de données actives par thread. L'architecture enveloppe chaque gestionnaire de requête dans catch_unwind pour capturer les panics et les consigner correctement. Lors des tests de charge, le serveur rencontre un panic dans un gestionnaire qui détient un prêt mutable du RefCell du pool de connexions. Lorsque catch_unwind capture le panic, le drapeau de prêt interne du pool reste défini sur "mutablement emprunté" parce que le guard RefMut a été abandonné lors du dépliage sans exécuter sa logique de décrémentation. Les demandes suivantes sur le même thread tentent d'emprunter le pool, déclenchant un panic d'exécution en raison de l'état déjà emprunté, ce qui fait effectivement planter le thread et perdre l'état du pool.
Solution 1 : Éliminer catch_unwind et autoriser la terminaison du processus
Cette approche supprime entièrement le problème de sécurité des exceptions en permettant au processus de se bloquer sur tout panic, en acceptant que la disponibilité soit secondaire à la correction dans ce contexte spécifique.
Avantages : Élimine complètement les préoccupations de sécurité des exceptions ; aucun risque de corruption d'état ; simple à mettre en œuvre.
Inconvénients : Inacceptable pour la disponibilité de production ; une requête malveillante ou boguée termine tout le service ; viole les exigences de fiabilité.
Solution 2 : Remplacer RefCell par Mutex et utiliser le poisonnement
Remplacer le pool basé sur RefCell par Mutex<Pool> et tirer parti de la détection de poisonnement du mutex de Rust.
Avantages : Mutex détecte les panics dans les threads détenant le lock et se marque comme empoisonné, permettant aux tentatives de lock suivantes de détecter la corruption via PoisonError ; la bibliothèque standard fournit une sécurité intégrée.
Inconvénients : Le Mutex introduit une surcharge de synchronisation inutile pour les exécutants async monocœurs ; nécessite de restructurer le pool de connexions pour être Send ; le poisonnement nécessite une logique de gestion explicite pour réinitialiser le pool.
Solution 3 : Envelopper les gestionnaires dans AssertUnwindSafe avec validation d'état
Conserver RefCell pour la performance, mais envelopper le gestionnaire dans AssertUnwindSafe et mettre en œuvre un guard de drop personnalisé qui réinitialise l'état du RefCell si un panic se produit.
Avantages : Retient les avantages de performance de RefCell ; permet l'isolement des panics ; possible d'implémenter une logique de récupération.
Inconvénients : Nécessite du code unsafe pour interagir avec AssertUnwindSafe ; extrêmement difficile de garantir la sécurité des exceptions pour tous les chemins de code ; facile de manquer des cas extrêmes où l'état reste corrompu.
Solution choisie et raisonnement
L'équipe a sélectionné Solution 2 (Mutex avec poisonnement) pour le pool de connexions partagé, tout en utilisant Solution 3 uniquement pour des tampons temporaires spécifiques à la requête qui peuvent être triviellement réinitialisés. Le mécanisme de poisonnement explicite de Mutex fournit un moyen fiable et standardisé de détecter la corruption sans exiger l'audit unsafe de chaque point de panic possible. La légère surcharge de performance a été acceptée en échange de la garantie de sécurité.
Résultat
Le serveur isole avec succès les panics dans les gestionnaires de requêtes sans risquer la corruption de l'état. Lorsqu'un gestionnaire panic en tenant le lock du pool, le mutex est empoisonné, et le serveur détecte cela à l'accès suivant, écartant le pool corrompu local au thread et en faisant éclore un nouveau. Cela garantit qu'aucun comportement indéfini ne se produit et que le service reste disponible même sous des entrées adversariales.
Pourquoi catch_unwind nécessite-t-il UnwindSafe même si Rust exécute les destructeurs pendant les panics ?
De nombreux candidats supposent que parce que les implémentations de Drop s'exécutent lors du dépliage, la sécurité des exceptions est garantie. Cependant, UnwindSafe aborde l'état logique des données, pas seulement les fuites de ressources. Un panic peut interrompre une séquence d'opérations (comme la mise à jour d'un champ de longueur avant les données correspondantes), laissant un objet dans un état temporairement incohérent. Le destructeur s'exécute sur cet état cassé, propulsant potentiellement la corruption. UnwindSafe garantit que soit le type ne peut pas être cassé par une interruption (données immuables), soit que le programmeur reconnaît le risque. Il empêche la reprise de l'exécution avec des objets qui violent leurs propres invariants.
Quelle est la différence entre UnwindSafe et les auto-traits Send/Sync ?
Alors que Send et Sync sont également des auto-traits, ils utilisent un raisonnement positif : &T est Send si T est Sync, et &mut T est Send si T est Send. UnwindSafe utilise un raisonnement négatif : &mut T est jamais UnwindSafe, peu importe T. De plus, AssertUnwindSafe agit comme un moyen d'échappement à niveau valeur (similaire à unsafe impl, mais pour des valeurs spécifiques), tandis que les violations Send/Sync nécessitent généralement une unsafe impl au niveau type. UnwindSafe s'associe également à RefUnwindSafe pour les références partagées, créant un système à double trait similaire mais distinct de Send/Sync.
Comment le drapeau de prêt de RefCell crée-t-il une insécurité avec les panics, et pourquoi Mutex n'a-t-il pas les mêmes problèmes UnwindSafe ?
RefCell repose sur un drapeau de prêt d'exécution. Si un panic se produit entre borrow_mut() et le Drop du guard, le drapeau reste défini, mais le guard est parti. Lorsque l'exécution reprend, le RefCell semble emprunté, mais aucun emprunt n'existe réellement. Cela constitue une erreur logique qui provoque des panics erronés lors des futurs emprunts. Mutex évite cela en mettant en œuvre le empoisonnement : si un panic se produit pendant qu'un lock est détenu, le Mutex s'auto-marque comme empoisonné. Les appels suivants à lock() retournent une erreur indiquant que le thread précédent a paniqué. Cela rend la corruption explicite et détectable, tandis que la corruption de RefCell est silencieuse. Par conséquent, le MutexGuard est en fait !UnwindSafe, mais le mécanisme de poisonnement fournit un chemin de récupération sûr que RefCell n'a pas.