La stabilisation de async/await dans Rust 1.39, aux côtés du type Pin introduit dans la version 1.33, a permis des structs auto-référentiels sûrs cruciaux pour les machines d'état asynchrones. Ces structures contiennent souvent des pointeurs internes référencant des données possédées par la structure elle-même, comme des tampons et des vues actives sur ces tampons. Lors de l'implémentation de futurs manuels ou de structures de données intrusives complexes, les développeurs doivent accéder à des champs individuels via Pin<&mut Self>, ce qui crée le besoin de mécanismes de projection sûrs qui préservent les garanties de localisation en mémoire.
Lorsqu'une structure est pinée via Pin, le compilateur garantit que son adresse mémoire reste constante pendant la durée du pin, à condition que le type n'implémente pas Unpin. Si la structure détient des pointeurs auto-référentiels, tels qu'un pointeur brut dans un vecteur interne, déplacer la structure invaliderait ces pointeurs, créant ainsi des références pendantes. Une approche de projection naïve qui se contente de déréférencer Pin<&mut Self> pour obtenir &mut Self expose des champs à un code Rust sûr, qui pourrait légalement invoquer mem::swap ou mem::replace sur ces champs, déplaçant ainsi les données hors de leurs emplacements mémoire pinés et violant le contrat fondamental de pinning.
La projection sûre nécessite une conversion non sécurisée qui préserve l'invariant de pinning : si la structure parente est !Unpin, la projection du champ doit retourner Pin<&mut Field> plutôt que &mut Field pour empêcher les déplacements. L'implémentation doit garantir que le champ est structurellement piné, c'est-à-dire que son statut de pinning est lié au statut de pinning de la structure parente, généralement atteint par des opérations arithmétiques sur les pointeurs ou Pin::map_unchecked_mut. Pour les champs qui implémentent Unpin, la projection peut retourner en toute sécurité &mut Field car ces types sont autorisés à se déplacer même lorsqu'ils sont imbriqués dans des données pinées, bien que des précautions doivent être prises pour que de tels mouvements n'invalides pas d'autres champs auto-référentiels.
use std::pin::Pin; use std::marker::PhantomPinned; struct Buffer { data: [u8; 1024], cursor: *const u8, _pin: PhantomPinned, } impl Buffer { // Projection sûre vers le champ de données (Unpin) fn data_mut(self: Pin<&mut Self>) -> &mut [u8; 1024] { unsafe { &mut self.get_unchecked_mut().data } } // Projection vers le champ du curseur fn cursor(self: Pin<&mut Self>) -> *const u8 { unsafe { self.get_unchecked_mut().cursor } } }
Contexte
Nous construisions un parseur à haute performance, sans copie, pour un protocole financier où les messages pouvaient référencer des sous-plages d'un tampon interne réutilisable. L'état du parseur devait être maintenu à travers des opérations I/O asynchrones, ce qui signifiait que la structure devait être Pin pour permettre des pointeurs auto-référentiels dans le tampon.
Description du problème
La structure Parser contenait un tampon Vec<u8> et une tranche &[u8] pointant dans ce tampon représentant le message courant. Lors de l'implémentation de Stream pour ce parseur, la méthode poll_next reçoit Pin<&mut Self>. Nous devions muter le tampon (pour lire davantage de données) tout en maintenant la validité de la référence de tranche, nécessitant une projection de champ soigneuse.
Solutions envisagées
Solution A : Adressage basé sur des indices
Au lieu de stocker une tranche &[u8], nous avons stocké des indices (usize, usize) dans le vecteur. Avantages : Sûr à 100 %, pas de complexité Pin, facile à mettre en œuvre. Inconvénients : Surcharge de vérification de limites à l'exécution, API moins ergonomique nécessitant un découpage manuel à chaque accès, potentiel de bugs de désynchronisation d'index.
Solution B : Projection Pin non sécurisée avec des pointeurs bruts
Nous avons stocké le message comme un pointeur brut *const u8 et sa longueur, mettant en œuvre des méthodes de projection manuelles utilisant Pin::map_unchecked_mut pour accéder au tampon tout en maintenant le champ de pointeur piné. Avantages : Abstraction à coût zéro, maintient l'auto-référentialité, permet une arithmétique de pointeurs directe. Inconvénients : Nécessite des blocs de code unsafe, risque de comportement indéfini si les invariants de Pin sont violés (par exemple, en implémentant Unpin incorrectement).
Solution C : Utilisation de la crate pin-project
Exploitation des macros procédurales pour générer automatiquement du code de projection sûr. Avantages : Ergonomique, invariants de sécurité bien testés, réduit le boilerplate. Inconvénients : Dépendance supplémentaire, code généré par les macros plus difficile à déboguer, léger coût en temps de compilation.
Solution choisie et résultat
Nous avons choisi la Solution B pour éviter des dépendances externes dans notre contexte de systèmes embarqués et pour maintenir un contrôle explicite sur la disposition de la mémoire. Nous avons soigneusement veillé à ce que la structure n'implémente pas Unpin en ajoutant PhantomPinned et avons écrit des tests exhaustifs avec Miri pour valider les invariants de pinning. Le résultat était un parseur atteignant des sémantiques sans copie sans allocation par message, maintenant un débit de 10Gbps sans saturation du CPU.
Pourquoi est-il dangereux d'implémenter Unpin pour une structure contenant des pointeurs auto-référentiels ?
Unpin signale spécifiquement qu'un type est sûr à déplacer même lorsqu'il est enveloppé dans Pin, permettant à un code sûr d'obtenir &mut T à partir de Pin<&mut T> via des méthodes comme Pin::into_inner. Pour une structure auto-référentielle, déplacer la structure change l'adresse mémoire de son contenu, invalidant tous les pointeurs internes référencant ces contenus. L'implémentation de Unpin permettrait au code sûr de déplacer la structure tout en étant pinée, violant la garantie de sécurité que Pin fournit aux runtimes asynchrones et conduisant à des vulnérabilités d'utilisation après libération. Par conséquent, de telles structures doivent utiliser PhantomPinned pour opter explicitement hors de Unpin et prévenir des implémentations automatiques accidentelles.
Comment la projection diffère-t-elle pour les variants d'énumérations par rapport aux champs de structure ?
De nombreux candidats supposent que les mécanismes de projection sont identiques pour les énumérations et les structures, mais les énumérations présentent des défis uniques car le discriminant détermine quel variant est actif. Projeter Pin<&mut Enum> vers un variant spécifique nécessite de garantir que le variant reste piné tout en empêchant le discriminant de changer, car le changement de variant déplacerait les données sous-jacentes. Rust ne dispose pas de support stable intégré pour la projection de variant parce que le discriminant et les données de variant partagent des considérations de disposition mémoire ; la projection sûre nécessite du code non sécurisé qui affirme le variant actif et garantit qu'aucun échange de variant ne se produit pendant que l'énumération reste pinée.
Quel est le rôle de PhantomPinned dans la prévention des implémentations automatiques de traits ?
Les débutants souvent ne réalisent pas que Rust implémente automatiquement Unpin pour la plupart des types à moins qu'ils ne contiennent explicitement des champs !Unpin, ce qui rendrait le type contenant par défaut !Unpin. PhantomPinned est un type marqueur de taille nulle défini explicitement comme !Unpin, servant de limite d'implémentation négative lorsqu'il est inclus dans une structure. Sans ce marqueur, même si les développeurs écrivent du code de projection non sécurisé en supposant que la structure est immobile, le compilateur pourrait implémenter automatiquement Unpin, permettant au code sûr d'extraire et de déplacer la structure via Pin::into_inner_unchecked, ce qui violerait les invariants non sécurisés et invoquerait un comportement indéfini.