1 points par GN⁺ 4 시간 전 | 1 commentaires | Partager sur WhatsApp
  • std::pin::Pin exprime 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 .await peuvent 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::poll exige Pin
  • Pin empêche de déplacer une valeur épinglée depuis du code sûr, mais n’interdit pas les modifications ordinaires ; si T: Unpin n’est pas satisfait, on ne peut pas extraire en toute sûreté un &mut T depuis un Pin
  • 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 PhantomPinned pour devenir !Unpin
  • En pratique, lorsqu’on appelle directement poll sur une future ou qu’on la passe à une API exigeant une future pinned, on utilise Box::pin ou std::pin::pin! ; lorsqu’on implémente directement Future ou des primitives async de bas niveau, il faut aussi gérer les invariants unsafe

Pourquoi Pin est nécessaire

  • std::pin::Pin est 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 SelfRef contient data: i32 et ptr: *const i32, et ptr pointe vers self.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 ptr continue de pointer vers l’ancien emplacement mémoire et devient un pointeur pendant
  • 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/await et Future sont les domaines typiques où Pin apparaît souvent
  • Les variables locales qui survivent au-delà d’un point .await deviennent 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::poll reçoit Pin au 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 T avec 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 Unpin ne 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 Unpin par défaut
    • Exemples : i32, String, Vec
  • Unpin est automatiquement implémenté pour tous les types, sauf si l’on crée explicitement un type !Unpin
  • std::marker::PhantomPinned est une structure marqueur explicitement !Unpin
    • Comme les auto traits se propagent automatiquement, une structure contenant un champ PhantomPinned devient elle aussi automatiquement !Unpin
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 à Unpin pour les structures auto-référentielles
    • Cela se fait généralement en incluant un champ PhantomPinned
  • Si un type auto-référentiel reste accidentellement Unpin, du code sûr peut extraire une référence mutable depuis Pin et 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

  • Pin ne 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 Unpin ne dépendent pas du pinning, les envelopper dans Pin est toujours sûr
    • Dans ce cas, la garantie de pinning est en pratique un no-op
  • 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 Pin pointant 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 !Unpin sur 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
  • Box::pin

    • Pour les types !Unpin, le constructeur le plus courant est Box::pin
    let pinned = Box::pin(SelfRef { ... });
    
    • pin! crée un Pin lié à une variable locale, tandis que Box::pin renvoie un Pin possédé par un Box
    • 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 Box lui-même ne déplace pas la valeur qu’il possède ; seul le pointeur à l’intérieur du Box est déplacé
    • L’allocation sur le tas reste à la même adresse
  • Pin::new_unchecked

    • Lorsque les constructeurs sûrs ne peuvent pas prouver que la valeur restera en place, on peut créer directement un Pin avec du code unsafe
    let pinned = unsafe { Pin::new_unchecked(ptr) };
    
    • L’appelant de Pin::new_unchecked promet que, pendant toute la durée de vie du Pin renvoyé, 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

Les cas où il faut vraiment s’en préoccuper

  • Pour la plupart des développeurs Rust, Pin et Unpin fonctionnent 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 poll sur une future ou la transmettre à une API qui exige une future pinned, vous l’épinglez sur le tas avec Box::pin(future) ou localement sur la pile avec std::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, et PhantomPinned ainsi que du code unsafe peuvent être nécessaires pour respecter les invariants de pinning
  • Pin est la solution zero-cost de Rust au problème des types sensibles à leur adresse
  • Elle permet à Rust d’utiliser async/await et d’autres abstractions auto-référentielles tout en conservant ses garanties de sûreté mémoire sans garbage collector

1 commentaires

 
GN⁺ 4 시간 전
Avis sur Lobste.rs
  • std::pin::Pin ressemble à 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 blog

    • Ces billets tombent souvent dans le sophisme du tutoriel sur les monades
    • Comme avec les monades, cela veut-il dire que ces billets de blog n’expliquent en fait rien correctement ?
  • Il serait utile de couvrir quelques points sur lesquels moi et d’autres avons buté en essayant de comprendre Pin
    Le nom Unpin n’est pas très bon. Des noms plus exacts, mais pas terribles non plus, auraient été MovableWhenPinned ou PinIsNoOp
    La double négation !Unpin de 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 automatique Unpin auquel les types peuvent se soustraire. Si on le lit comme !MovableWhenPinned, cela a plus de sens
    L’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 comme PhantomNotMovableWhenPinned
    Une 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

    • Tout à fait d’accord. Avant, !Unpin me donnait mal à la tête, mais quand j’ai commencé à lire Unpin comme SafeToUnpin, c’est devenu un peu plus facile
  • J’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, Pin venait 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ée
    Si 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 Pin un peu partout », ou s’il existe une raison stricte pour laquelle les références relatives ne constituent pas une alternative viable

    • Le problème ne se limite pas aux variables locales de l’état async qui référencent directement d’autres variables locales du même état. Dans ce cas, le compilateur connaît toutes les variables locales, donc il pourrait rendre les accès relatifs. Mais si une référence profondément enfouie dans un type pointe vers une valeur profondément enfouie dans un autre type, cela devient beaucoup plus délicat
      Si 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
    • Pin est 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 opaques
      D’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 SelfRef d’ê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 essentiel
    Le cœur du sujet arrive bien plus tard : « Pin n’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 Pin pour 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 compris Pin, mais en affinant un peu la manière de l’expliquer, les lecteurs se perdraient moins

    • Je vais reformuler l’article
      Ce 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 Pin fait réellement, donc il est juste de modifier l’article pour que ce point apparaisse clairement
  • Il vaudrait la peine d’indiquer quelque part dans l’article que !UnPin n’est exprimable que dans Rust nightly. C’est la principale raison d’être de PhantomPinned

  • On 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
    *const est 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::poll
    On dit que « le code sûr ne peut pas récupérer un &mut T ordinaire », 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

    • Les pointeurs bruts font partie des types primitifs de Rust. La documentation est ici et ici
      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::poll est 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 comme poll, définies dans le trait Future, pour faire avancer le travail
    • Je ne suis pas l’auteur de l’article original, et ce ne sont pas les seules raisons, mais les cas où j’ai dû manipuler des pointeurs en Rust étaient la FFI et des structures de données auto-référentielles comme des graphes
      La 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 ? »