Qu’est-ce que std::pin::Pin en Rust ?
(vrong.me)std::pin::Pinexprime une garantie au niveau du type selon laquelle la valeur pointée par un pointeur ne sera pas déplacée via ce pointeur ; il est nécessaire pour les valeurs dont l’adresse doit rester stable, comme les types qui se référencent eux-mêmes en interne- Avec
async/await, les variables locales et les références qui survivent au-delà d’un.awaitpeuvent devenir des champs de la machine à états générée par le compilateur ; pour empêcher le déplacement d’une future après son polling,Future::pollexigePin Pinempêche de déplacer une valeur épinglée depuis du code sûr, mais n’interdit pas les modifications ordinaires ; siT: Unpinn’est pas satisfait, on ne peut pas extraire en toute sûreté un&mut Tdepuis unPin- La plupart des types Rust sont Unpin par défaut ; une structure auto-référentielle qui ne doit pas être déplacée doit donc généralement inclure un champ
PhantomPinnedpour devenir!Unpin - En pratique, lorsqu’on appelle directement
pollsur une future ou qu’on la passe à une API exigeant une future pinned, on utiliseBox::pinoustd::pin::pin!; lorsqu’on implémente directementFutureou des primitives async de bas niveau, il faut aussi gérer les invariantsunsafe
Pourquoi Pin est nécessaire
std::pin::Pinest un wrapper de pointeur qui représente la garantie que la valeur pointée par ce pointeur ne sera pas déplacée via celui-ci- Le problème central apparaît avec les types auto-référentiels
- L’exemple de structure
SelfRefcontientdata: i32etptr: *const i32, etptrpointe versself.data - Si l’instance de la structure est déplacée vers une autre variable ou renvoyée depuis une fonction, son adresse mémoire peut changer
- Le pointeur brut
ptrcontinue de pointer vers l’ancien emplacement mémoire et devient un pointeur pendant
- L’exemple de structure
- Une fois l’auto-référence mise en place, il faut un mécanisme empêchant la valeur d’être à nouveau déplacée
Le problème avec async/await et Future
async/awaitet Future sont les domaines typiques oùPinapparaît souvent- Les variables locales qui survivent au-delà d’un point
.awaitdeviennent des champs de la machine à états générée par le compilateur - Si une référence vers une variable locale survit elle aussi au même
.await, la future générée peut être auto-référentielle - Après le début du polling, la future peut dépendre de références pointant vers d’autres champs internes
- Si la future est déplacée dans cet état, ces références deviennent invalides
- Pour l’éviter,
Future::pollreçoitPinau lieu de&mut self
pub trait Future {
type Output;
fn poll(self: Pin, cx: &mut Context Pin {
pub const fn get_mut(self) -> &'a mut T
where
T: Unpin
{ ... }
}
- Si le type n’implémente pas
Unpin, c’est-à-dire s’il est!Unpin, il est impossible d’obtenir un&mut Tavec du code sûr uniquement - Dans ce cas, il faut utiliser une méthode unsafe comme
Pin::get_unchecked_mut, et le code doit respecter la promesse que la valeur ne sera pas déplacée hors de cette référence
Unpin et PhantomPinned
- Les types qui implémentent
Unpinne dépendent pas du pinning pour la sûreté mémoire
// std::marker
pub auto trait Unpin {}
- La plupart des types Rust peuvent être déplacés sans problème et sont donc
Unpinpar défaut- Exemples :
i32,String,Vec
- Exemples :
Unpinest automatiquement implémenté pour tous les types, sauf si l’on crée explicitement un type!Unpinstd::marker::PhantomPinnedest une structure marqueur explicitement!Unpin- Comme les auto traits se propagent automatiquement, une structure contenant un champ
PhantomPinneddevient elle aussi automatiquement!Unpin
- Comme les auto traits se propagent automatiquement, une structure contenant un champ
use std::marker::PhantomPinned;
struct SelfRef {
data: i32,
ptr: *const i32,
_phantom: PhantomPinned, // makes the entire struct !Unpin
}
- C’est la manière standard de déclarer qu’une structure définie par l’utilisateur devient non sûre si elle est déplacée après avoir été épinglée
- Le compilateur ne peut généralement pas détecter automatiquement les auto-références créées avec des pointeurs bruts unsafe
- Le développeur doit donc renoncer explicitement à
Unpinpour les structures auto-référentielles- Cela se fait généralement en incluant un champ
PhantomPinned
- Cela se fait généralement en incluant un champ
- Si un type auto-référentiel reste accidentellement
Unpin, du code sûr peut extraire une référence mutable depuisPinet déplacer la valeur- Les hypothèses du code unsafe qui a créé l’auto-référence sont alors rompues
Comment créer un Pin
-
Pinne fige pas une valeur par lui-même -
Créer un
Pin, c’est prouver que le pointee restera à un emplacement mémoire stable pendant toute la durée de vie du pin -
Pin::new- La méthode de création la plus simple est
Pin::new
let mut value = 42; let pinned = Pin::new(&mut value);- Ce constructeur n’est utilisable que lorsque
T: Unpin - Comme les types
Unpinne dépendent pas du pinning, les envelopper dansPinest toujours sûr - Dans ce cas, la garantie de pinning est en pratique un no-op
- La méthode de création la plus simple est
-
std::pin::pin!- Quand il faut épingler localement une valeur sans allocation sur le tas, on peut utiliser la macro
pin!
use std::pin::pin; let future = pin!(async { println!("Hello"); });- Cette macro crée une variable locale et renvoie un
Pinpointant vers celle-ci - Le compilateur garantit que cette variable locale ne sera pas déplacée pendant le reste de sa durée de vie, ce qui permet d’épingler en toute sûreté une valeur
!Unpinsur la pile - Malgré son nom,
pin!n’épingle pas la mémoire de la pile elle-même - Elle ne fait que créer une référence épinglée liée à une variable locale ; quand la variable sort de son scope, la garantie de pinning prend fin
- Quand il faut épingler localement une valeur sans allocation sur le tas, on peut utiliser la macro
-
Box::pin- Pour les types
!Unpin, le constructeur le plus courant estBox::pin
let pinned = Box::pin(SelfRef { ... });pin!crée unPinlié à une variable locale, tandis queBox::pinrenvoie unPinpossédé par unBox- Comme l’allocation sur le tas elle-même ne se déplace pas, le pointee conserve un emplacement mémoire stable pendant toute la durée de vie du
Box - Déplacer le
Boxlui-même ne déplace pas la valeur qu’il possède ; seul le pointeur à l’intérieur duBoxest déplacé - L’allocation sur le tas reste à la même adresse
- Pour les types
-
Pin::new_unchecked- Lorsque les constructeurs sûrs ne peuvent pas prouver que la valeur restera en place, on peut créer directement un
Pinavec du code unsafe
let pinned = unsafe { Pin::new_unchecked(ptr) };- L’appelant de
Pin::new_uncheckedpromet que, pendant toute la durée de vie duPinrenvoyé, le pointee ne sera plus déplacé via aucun pointeur - Si cette promesse est rompue, du comportement indéfini peut se produire dans le code qui dépend de la garantie de pinning
- Cette méthode est donc généralement utilisée uniquement lors de l’implémentation d’abstractions de bas niveau capables de maintenir cet invariant
- Lorsque les constructeurs sûrs ne peuvent pas prouver que la valeur restera en place, on peut créer directement un
Les cas où il faut vraiment s’en préoccuper
- Pour la plupart des développeurs Rust,
PinetUnpinfonctionnent discrètement en arrière-plan - Les cas où il faut s’en occuper directement sont surtout au nombre de deux
- Consommation de code async : si vous devez appeler directement
pollsur une future ou la transmettre à une API qui exige une future pinned, vous l’épinglez sur le tas avecBox::pin(future)ou localement sur la pile avecstd::pin::pin!(future) - Implémentation directe de Future : lorsque vous écrivez une machine à états personnalisée ou une primitive async de bas niveau, vous devez manipuler
Pin, etPhantomPinnedainsi que du code unsafe peuvent être nécessaires pour respecter les invariants de pinning
- Consommation de code async : si vous devez appeler directement
Pinest la solution zero-cost de Rust au problème des types sensibles à leur adresse- Elle permet à Rust d’utiliser
async/awaitet d’autres abstractions auto-référentielles tout en conservant ses garanties de sûreté mémoire sans garbage collector
1 commentaires
Avis sur Lobste.rs
std::pin::Pinressemble à une monade dans l’univers Rust. Une fois qu’on l’a comprise, on ne peut pas s’empêcher d’écrire un billet de blogIl serait utile de couvrir quelques points sur lesquels moi et d’autres avons buté en essayant de comprendre
PinLe nom
Unpinn’est pas très bon. Des noms plus exacts, mais pas terribles non plus, auraient étéMovableWhenPinnedouPinIsNoOpLa double négation
!Unpinde nightly paraît bizarre, mais c’est ainsi parce que, pour conserver les types existants comme cas par défaut dans 99 % des cas, il fallait ajouter le trait automatiqueUnpinauquel les types peuvent se soustraire. Si on le lit comme!MovableWhenPinned, cela a plus de sensL’alternative stable,
PhantomPinned, n’a pas non plus un très bon nom, car l’état pinned est un état temporaire dû à l’existence d’une référence pinned, pas une propriété du type. Un nom alternatif aurait pu être quelque chose commePhantomNotMovableWhenPinnedUne fois que j’ai commencé à traduire mentalement les choses de cette façon, c’est devenu beaucoup plus clair. Bien sûr, cela reste déroutant, donc j’ai peut-être juste eu de la chance
!Unpinme donnait mal à la tête, mais quand j’ai commencé à lireUnpincommeSafeToUnpin, c’est devenu un peu plus facileJ’avais posé cette question il y a quelque temps et quelqu’un y avait répondu de façon réfléchie, mais je ne m’en souviens plus. Dans ma compréhension,
Pinvenait de l’async, et le problème était que les références vers des variables locales devenaient auto-référentielles à l’intérieur du bloc de données représentant la machine à états d’une fonction donnéeSi l’état async est déplacé, ces références de variables locales pointent alors vers d’anciens emplacements invalides
Mais n’est-ce pas uniquement parce qu’une référence est un vrai pointeur avec une adresse absolue complète ? Je me demande pourquoi la solution a consisté à supprimer la capacité de déplacement, plutôt qu’à rendre les références relatives
Je me demande si la réponse est en gros : « des millions d’années-ingénieurs ont été investis pour que les compilateurs, les CPU et les OS manipulent très bien les pointeurs, donc les pointeurs sont meilleurs à bien des égards, et il vaut mieux utiliser
Pinun peu partout », ou s’il existe une raison stricte pour laquelle les références relatives ne constituent pas une alternative viableSi les références étaient relatives, ces types devraient avoir une représentation mémoire différente selon qu’ils sont utilisés ou non dans un état async, et il faudrait aussi une notion de pointeur de base à transmettre avec elles pour reconstituer le vrai pointeur à partir d’une référence relative
Les objets imbriqués à l’intérieur de références pinned peuvent encore être déplacés librement même si l’objet racine est pinned, donc on ne peut pas non plus dire que toutes ces références relatives hypothétiques seraient relatives au même pointeur de base
Au bout du compte, il faut des pointeurs absolus, et les références relatives ne s’y prêtent pas bien. Alors, puisque le compilateur Rust connaît ici tous les types, pourquoi ne pas rendre l’objet déplaçable en suivant tout le graphe d’objets et en corrigeant vers leur nouvel emplacement les références qui pointent vers les objets déplacés ? On aurait alors, en pratique, créé un garbage collector par traçage
En plus, le compilateur Rust ne connaît pas tous les types du graphe d’objets. Les références peuvent être transmises via FFI et une bibliothèque externe peut les conserver. Corriger des références déplacées à travers une frontière FFI est, en pratique, un problème difficilement traitable
C’est donc vraiment compliqué. Il est aussi important de noter que le déplacement d’objets lui-même est une technique relativement récente. Dans la plupart des programmes C/C++, on peut considérer que tous les objets sont implicitement pinned. Si on parle moins de pinning de ce côté-là, c’est parce que les objets ne se déplacent tout simplement pas, ou bien que c’est au programmeur de veiller à ce qu’aucune référence pendante ne subsiste s’ils le font
Pinest aussi nécessaire pour l’interopérabilité avec d’autres langages où Rust ne peut pas déplacer librement la mémoire comme des paquets de bits opaquesD’après ce que j’ai compris, l’un des problèmes de l’interopérabilité avec C++ est que les objets ne sont pas de simples paquets de bits que l’on peut déplacer librement ; au final, pas mal de types ont besoin de pinning, ce qui rend l’utilisation moins agréable
Cela dit, cela se base sur des discussions que j’ai eues il y a au moins six mois avec des personnes qui travaillaient dessus, donc je ne sais pas dans quelle mesure la situation s’est améliorée depuis
Globalement, je trouve que c’est une bonne explication à lire en complément de la documentation officielle de Rust. L’entrée dans le problème est un peu plus progressive
Cela dit, je pense que commencer par les structures auto-référentielles rend les choses plus confuses que si on l’omettait. En particulier, la phrase d’introduction « il faut donc un moyen d’empêcher
SelfRefd’être déplacé après la création de cette auto-référence » m’a fait penser au « problème d’empêcher complètement le déplacement » plutôt qu’au point essentielLe cœur du sujet arrive bien plus tard : «
Pinn’empêche pas physiquement le déplacement d’une valeur. C’est plutôt une garantie au niveau du type que la valeur ne sera pas déplacée via ce pointeur »Comme on ne peut pas empêcher le déplacement lui-même, on utilise
Pinpour n’exposer les données auto-référentielles, dans une API sûre, que derrière des références exclusives. J’ai peut-être déjà trop bien comprisPin, mais en affinant un peu la manière de l’expliquer, les lecteurs se perdraient moinsCe texte vient de mes notes sur le pinning, et au début je le comprenais aussi ainsi. Je trouvais beau que l’on puisse résoudre un problème du type « empêcher le déplacement » par une garantie au niveau du type
Bien sûr, ce n’est pas ce que
Pinfait réellement, donc il est juste de modifier l’article pour que ce point apparaisse clairementIl vaudrait la peine d’indiquer quelque part dans l’article que
!UnPinn’est exprimable que dans Rust nightly. C’est la principale raison d’être dePhantomPinnedOn parle de « wrapper de pointeur », mais même en Rust on a rarement affaire aux pointeurs. Je ne vois pas pourquoi il faudrait l’utiliser
*constest difficile à trouver dans la documentation Rust via Google ; je me demande s’il est documentéFaut-il aussi savoir que « cela devient un champ de la machine à états générée par le compilateur » ? Ou bien est-ce qu’un message d’erreur absurde du compilateur essaie en fait de dire que c’est ce qui s’est passé ?
« Le future généré devient auto-référentiel », est-ce aussi quelque chose qui se produit implicitement quand on utilise des futures ?
Je ne crois pas avoir déjà écrit directement
Future::pollOn dit que « le code sûr ne peut pas récupérer un
&mut Tordinaire », tout en disant que « les modifications ordinaires sont autorisées » ; alors comment ça marche ?Ce genre de choses m’a fait arrêter de creuser davantage Rust
Cela dit, c’est vrai qu’on n’a presque jamais besoin de les utiliser à moins de descendre bas niveau. Moi aussi, je ne les ai vraiment découverts qu’au moment d’appeler des bibliothèques C
Future::pollest la base du code asynchrone en Rust. On ne l’appelle pas directement : c’est l’exécuteur qui l’appelle. Rust n’a pas d’exécuteur par défaut, donc il faut ajouter quelque chose comme Tokio, smol ou pollster, qui utilisent des méthodes commepoll, définies dans le traitFuture, pour faire avancer le travailLa documentation se trouve à plusieurs endroits, notamment ici
Attendre des autres qu’ils n’expliquent que ce dont on a soi-même besoin, c’est un peu excessif
Je ne sais pas très bien ce que tu demandes avec « alors comment ? »