1 Punkte von GN⁺ 3 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • std::pin::Pin drückt eine Garantie auf Typebene aus, dass der Wert, auf den ein Pointer zeigt, nicht über diesen Pointer verschoben wird; das ist nötig für Werte, deren Adresse stabil bleiben muss, etwa bei Typen mit Selbstreferenzen
  • Bei async/await können lokale Variablen und Referenzen, die über .await hinaus weiterleben, zu Feldern der vom Compiler erzeugten State Machine werden; damit ein Future nach dem Polling nicht verschoben wird, verlangt Future::poll daher Pin
  • Pin verhindert, dass ein gepinnter Wert in sicherem Code verschoben wird, verbietet aber keine allgemeinen Änderungen; wenn T: Unpin nicht gilt, kann man aus Pin nicht sicher ein &mut T herausziehen
  • Die meisten Rust-Typen sind standardmäßig Unpin, daher müssen selbstreferenzielle Structs, die nicht verschoben werden dürfen, meist durch ein PhantomPinned-Feld zu !Unpin gemacht werden
  • In der Praxis verwendet man Box::pin oder std::pin::pin!, wenn man Futures direkt pollt oder sie an APIs übergibt, die ein gepinntes Future verlangen; wer Future oder Low-Level-Async-Primitiven selbst implementiert, muss sich außerdem mit unsafe-Invarianten befassen

Warum Pin nötig ist

  • std::pin::Pin ist ein Pointer-Wrapper, der garantiert, dass der Wert, auf den ein Pointer zeigt, nicht über diesen Pointer verschoben wird
  • Das Kernproblem entsteht bei selbstreferenziellen Typen
    • Das Beispiel-Struct SelfRef besitzt data: i32 und ptr: *const i32, wobei ptr auf self.data zeigt
    • Wenn eine Struct-Instanz in eine andere Variable verschoben oder aus einer Funktion zurückgegeben wird, kann sich ihre Speicheradresse ändern
    • Der rohe Pointer ptr zeigt dann weiter auf die frühere Speicheradresse und wird zu einem dangling pointer
  • Sobald eine Selbstreferenz eingerichtet ist, braucht man einen Mechanismus, der verhindert, dass der betreffende Wert erneut verschoben wird

Probleme bei async/await und Future

  • async/await und Future sind typische Bereiche, in denen Pin häufig auftaucht
  • Lokale Variablen, die über einen .await-Punkt hinaus weiterleben, werden zu Feldern der vom Compiler erzeugten State Machine
  • Wenn eine Referenz auf eine lokale Variable ebenfalls über denselben .await hinauslebt, kann das erzeugte Future selbstreferenziell sein
  • Nachdem das Polling begonnen hat, kann ein Future von Referenzen abhängen, die auf andere interne Felder zeigen
    • Wird das Future in diesem Zustand verschoben, werden diese Referenzen ungültig
  • Um das zu verhindern, nimmt Future::poll statt &mut self ein Pin entgegen
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
      { ... }
  }
  • Wenn ein Typ Unpin nicht implementiert, also !Unpin ist, kann man mit sicherem Code kein &mut T erhalten
  • In diesem Fall muss man unsafe-Methoden wie Pin::get_unchecked_mut verwenden, und der Code muss garantieren, dass der Wert nicht aus dieser Referenz heraus verschoben wird

Unpin und PhantomPinned

  • Typen, die Unpin implementieren, sind für ihre Speichersicherheit nicht auf Pinning angewiesen
// std::marker
pub auto trait Unpin {}
  • Die meisten Rust-Typen können problemlos verschoben werden und sind daher standardmäßig Unpin
    • Zum Beispiel: i32, String, Vec
  • Unpin wird automatisch für alle Typen implementiert, solange sie nicht ausdrücklich zu !Unpin gemacht werden
  • std::marker::PhantomPinned ist ein Marker-Struct, das ausdrücklich !Unpin ist
    • Da sich Auto-Traits automatisch fortpflanzen, wird auch ein Struct mit einem PhantomPinned-Feld automatisch zu !Unpin
use std::marker::PhantomPinned;

struct SelfRef {
    data: i32,
    ptr: *const i32,
    _phantom: PhantomPinned, // makes the entire struct !Unpin
}
  • Das ist die Standardmethode, um zu deklarieren, dass ein benutzerdefiniertes Struct nach dem Pinning nicht mehr sicher verschoben werden kann
  • Der Compiler kann Selbstreferenzen, die typischerweise mit unsafe und rohen Pointern gebaut werden, normalerweise nicht automatisch erkennen
  • Daher muss der Entwickler für selbstreferenzielle Structs explizit auf Unpin verzichten
    • Üblicherweise geschieht das durch ein PhantomPinned-Feld
  • Wenn ein selbstreferenzieller Typ versehentlich Unpin bleibt, kann sicherer Code aus Pin eine mutable Referenz holen und den Wert verschieben
    • Damit würden die Annahmen des unsafe-Codes, der die Selbstreferenz aufgebaut hat, verletzt

Wie man Pin erzeugt

  • Pin selbst pinnt keinen Wert

  • Ein Pin zu erzeugen bedeutet, nachzuweisen, dass der betreffende pointee während der Lebensdauer des Pin an einer stabilen Speicheradresse bleibt

  • Pin::new

    • Die einfachste Erzeugung ist Pin::new
    let mut value = 42;
    let pinned = Pin::new(&mut value);
    
    • Dieser Konstruktor kann nur verwendet werden, wenn T: Unpin gilt
    • Unpin-Typen hängen nicht von Pinning ab, daher ist das Einhüllen in Pin immer sicher
    • In diesem Fall ist die Pinning-Garantie faktisch ein no-op
  • std::pin::pin!

    • Wenn ein Wert lokal ohne Heap-Allokation gepinnt werden soll, kann das Makro pin! verwendet werden
    use std::pin::pin;
    
    let future = pin!(async {
        println!("Hello");
    });
    
    • Dieses Makro legt eine lokale Variable an und gibt ein Pin zurück, das auf diese Variable zeigt
    • Der Compiler garantiert, dass diese lokale Variable für ihre restliche Lebensdauer nicht verschoben wird; dadurch lassen sich !Unpin-Werte sicher auf dem Stack pinnen
    • Anders als der Name vermuten lässt, pinnt pin! nicht den Stack-Speicher selbst
    • Es erzeugt nur eine gepinnte Referenz auf eine lokale Variable; verlässt die Variable den Scope, endet auch die Pinning-Garantie
  • Box::pin

    • Für !Unpin-Typen ist Box::pin der häufigste Konstruktor
    let pinned = Box::pin(SelfRef { ... });
    
    • Während pin! ein an eine lokale Variable gebundenes Pin erzeugt, liefert Box::pin ein Pin, das einer Box gehört
    • Da sich die Heap-Allokation selbst nicht bewegt, hat der pointee während der Lebensdauer der Box eine stabile Speicheradresse
    • Selbst wenn die Box selbst verschoben wird, wird der enthaltene Wert nicht verschoben; nur der Pointer in der Box bewegt sich
    • Die Heap-Allokation bleibt an derselben Adresse
  • Pin::new_unchecked

    • Wenn ein sicherer Konstruktor nicht beweisen kann, dass ein Wert an Ort und Stelle bleibt, kann Pin direkt mit unsafe erzeugt werden
    let pinned = unsafe { Pin::new_unchecked(ptr) };
    
    • Wer Pin::new_unchecked aufruft, verspricht, dass der pointee während der Lebensdauer des zurückgegebenen Pin über keinen Pointer mehr verschoben wird
    • Wird dieses Versprechen gebrochen, kann in Code, der auf die Pinning-Garantie vertraut, undefiniertes Verhalten entstehen
    • Deshalb wird diese Funktion in der Regel nur beim Implementieren von Low-Level-Abstraktionen verwendet, die diese Invariante tatsächlich einhalten können

Wann man sich tatsächlich darum kümmern muss

  • Für die meisten Rust-Entwickler arbeiten Pin und Unpin unauffällig im Hintergrund
  • Wirklich relevant wird es meist in zwei Fällen
    • Async-Code verwenden: Wenn man ein Future direkt pollt oder an eine API übergibt, die ein gepinntes Future verlangt, pinnt man es mit Box::pin(future) auf dem Heap oder mit std::pin::pin!(future) lokal auf dem Stack
    • Future selbst implementieren: Wer eigene State Machines oder Low-Level-Async-Primitiven schreibt, muss mit Pin arbeiten und braucht möglicherweise PhantomPinned sowie unsafe-Code, um die Pinning-Invarianten einzuhalten
  • Pin ist Rusts zero-cost-Lösung für adresssensitive Typen
  • Damit kann Rust async/await und andere selbstreferenzielle Abstraktionen nutzen und zugleich Speichersicherheit ohne Garbage Collector garantieren

1 Kommentare

 
GN⁺ 3 시간 전
Meinungen auf Lobste.rs
  • std::pin::Pin ist so etwas wie die Monad der Rust-Welt. Hat man es einmal verstanden, kann man kaum anders, als einen Blogpost darüber zu schreiben

    • Solche Beiträge laufen meist Gefahr, der monad tutorial fallacy zu erliegen
    • Heißt das, wie bei Monads, dass solche Blogposts in Wirklichkeit gar nichts richtig erklären?
  • Es wäre gut, ein paar Dinge anzusprechen, über die ich und andere beim Versuch, Pin zu verstehen, gestolpert sind
    Der Name Unpin ist nicht besonders gut. Genauere, aber ebenfalls unschöne Namen wären MovableWhenPinned oder PinIsNoOp gewesen
    Die doppelte Verneinung !Unpin in nightly wirkt seltsam, aber um bestehende Typen als 99%-Standardfall beizubehalten, musste ein Auto-Trait Unpin hinzugefügt werden, aus dem ein Typ aussteigen kann. Wenn man es als !MovableWhenPinned liest, ergibt es mehr Sinn
    Auch die stabile Alternative PhantomPinned ist nicht gut benannt, denn der pinned-Zustand ist ein temporärer Zustand, der durch eine pinned Reference entsteht, und keine Eigenschaft des Typs. Ein alternativer Name wäre etwa PhantomNotMovableWhenPinned gewesen
    Als ich begann, das im Kopf so zu übersetzen, wurde es viel verständlicher. Natürlich ist es immer noch verwirrend; vielleicht hatte ich einfach Glück

    • Stimme völlig zu. Früher hat mir !Unpin Kopfschmerzen bereitet, aber seit ich Unpin als SafeToUnpin lese, ist es etwas angenehmer geworden
  • Ich habe diese Frage früher schon einmal gestellt, und ich glaube, jemand hat sie sehr durchdacht beantwortet, aber ich erinnere mich nicht mehr. So wie ich Pin verstanden habe, entstand es aus async: Das Problem war, dass Referenzen auf lokale Variablen innerhalb eines Datenblocks, der die Zustandsmaschine einer bestimmten Funktion darstellt, selbstreferenziell werden
    Wenn der async-Zustand verschoben wird, zeigen diese Referenzen auf lokale Variablen danach auf die alte, falsche Position
    Aber ist das nicht nur deshalb so, weil Referenzen echte Pointer mit vollständigen absoluten Adressen sind? Ich frage mich, warum die Lösung darin bestand, die Beweglichkeit zu entfernen, statt Referenzen relativ zu machen
    Ich frage mich, ob die Antwort im Wesentlichen lautet: „Weil Millionen Engineering-Jahre in Compiler, CPUs und Betriebssysteme geflossen sind, damit sie sehr gut mit Pointern umgehen können, sind Pointer in vielerlei Hinsicht besser, und deshalb ist es besser, überall Pin zu verwenden“ – oder ob es harte Gründe gibt, warum relative Referenzen als Alternative tatsächlich nicht funktionieren

    • Es geht nicht nur darum, dass eine lokale Variable im async-Zustand direkt auf eine andere lokale Variable im selben Zustand verweist. In diesem Fall kennt der Compiler alle lokalen Variablen und könnte Zugriffe relativ machen. Aber wenn eine Referenz tief in einem Typ auf einen Wert tief in einem anderen Typ zeigt, wird es viel schwieriger
      Wenn Referenzen relativ wären, müssten diese Typen je nachdem, ob sie in einem async-Zustand verwendet werden oder nicht, eine andere Speicherrepräsentation haben, und man bräuchte außerdem ein Konzept eines Basispointers, der mitgeführt werden muss, um aus der relativen Referenz wieder einen echten Pointer zu rekonstruieren
      Verschachtelte Objekte innerhalb einer pinned Reference können weiterhin frei verschoben werden, selbst wenn das Root-Objekt pinned ist; daher kann man auch nicht sagen, dass all diese hypothetischen relativen Referenzen relativ zum selben Basispointer sind
      Am Ende braucht man absolute Pointer, und relative Referenzen passen nicht gut. Was wäre dann damit, dass der Rust-Compiler ja alle Typen hier kennt und daher den gesamten Objektgraphen verfolgt, um Referenzen auf verschobene Objekte auf deren neue Position zu aktualisieren und die Objekte so beweglich zu machen? Dann hätte man im Grunde einen Tracing-Garbage-Collector gebaut
      Außerdem kennt der Rust-Compiler nicht alle Typen im Objektgraphen. Referenzen können über FFI weitergegeben werden, und eine externe Bibliothek kann diese Referenz speichern. Bewegte Referenzen über FFI-Grenzen hinweg zu reparieren, ist praktisch ein kaum handhabbares Problem
      Deshalb ist das wirklich knifflig. Wichtig ist auch, dass das Verschieben von Objekten selbst eine vergleichsweise neue Technik ist. In den meisten C/C++-Programmen kann man davon ausgehen, dass alle Objekte implizit pinned sind. Dass pinning dort weniger diskutiert wird, liegt daran, dass Objekte einfach nicht verschoben werden oder dass es bei Verschiebungen in der Verantwortung der Programmierer liegt, keine dangling References zurückzulassen
    • Pin ist auch für die Interoperabilität mit anderen Sprachen nötig, in denen Rust Speicher nicht einfach wie einen opaken Haufen Bits beliebig verschieben kann
      Nach meinem Verständnis ist eines der Probleme bei der C++-Interoperabilität, dass Objekte keine einfachen Bitblöcke sind, die sich frei verschieben lassen; am Ende brauchen ziemlich viele Typen pinning, was die Benutzung umständlich macht
      Allerdings basiert das auf Gesprächen mit Leuten, die vor mindestens etwa sechs Monaten daran gearbeitet haben; ich weiß nicht, wie sehr sich die Lage seitdem verbessert hat
  • Insgesamt ist das, zusätzlich zur offiziellen Rust-Dokumentation, eine gut lesbare Erklärung. Der Einstieg in das Problem ist etwas sanfter
    Allerdings finde ich, dass der Einstieg über selbstreferenzielle Structs eher mehr verwirrt, als wenn man ihn wegließe. Besonders der Satz in der Einleitung „Daher brauchen wir, nachdem eine solche Selbstreferenz erzeugt wurde, eine Möglichkeit, das Verschieben von SelfRef zu verhindern“ ließ mich eher an das Problem denken, „Bewegung vollständig zu verhindern“, statt an den eigentlichen Kern
    Der eigentliche Kern steht viel später: „Pin verhindert nicht, dass ein Wert physisch verschoben wird. Stattdessen ist es eine Garantie auf Typebene, dass der Wert nicht über diesen Pointer verschoben wird“
    Da man das Verschieben selbst nicht verhindern kann, verwendet man Pin, um selbstreferenzielle Daten in sicheren APIs nur hinter exklusiven Referenzen offenzulegen. Vielleicht verstehe ich Pin schon zu gut, aber mit etwas Feinschliff an der Erklärung würden Leser vermutlich weniger herumirren

    • Ich werde den Artikel entsprechend umformulieren
      Er stammt aus meinen Notizen zu pinning, und anfangs habe ich es selbst so verstanden. Dass man ein Problem wie „Verschieben verhindern“ mit einer Garantie auf Typebene lösen kann, fand ich schön
      Natürlich ist das nicht das, was Pin tatsächlich tut, also ist es richtig, den Text so zu ändern, dass das deutlich wird
  • Irgendwo in diesem Artikel sollte erwähnt werden, dass !UnPin nur in nightly Rust ausdrückbar ist. Das ist der Hauptgrund, warum PhantomPinned existiert

  • Es heißt „Pointer-Wrapper“, aber selbst in Rust hat man kaum je mit Pointern zu tun. Ich weiß nicht, warum man das verwenden sollte
    Zu *const findet man über Google nur schwer Rust-Dokumentation; ich frage mich, ob es dokumentiert ist
    Muss man auch wissen, dass etwas „zu einem Feld der vom Compiler erzeugten Zustandsmaschine wird“? Oder will ein absurder Compilerfehler einem sagen, dass genau das passiert ist?
    Passiert „das erzeugte Future wird selbstreferenziell“ auch implizit, wenn man Futures verwendet?
    Future::poll habe ich, glaube ich, nie direkt benutzt
    Einerseits heißt es: „Sicherer Code kann kein normales &mut T wiederherstellen“, andererseits „gewöhnliche Änderungen sind erlaubt“ – wie soll das dann funktionieren?
    Wegen solcher Dinge habe ich aufgehört, mich weiter in Rust zu vertiefen

    • Raw Pointer gehören zu den primitiven Typen in Rust. Die Dokumentation ist hier und hier
      Es stimmt aber, dass man sie kaum braucht, wenn man nicht auf Low-Level-Ebene arbeitet. Ich selbst bin erst darauf gestoßen, als ich eine C-Bibliothek aufrufen wollte
      Future::poll ist die Grundlage von asynchronem Rust-Code. Man ruft es nicht direkt auf; der Executor ruft es auf. Rust hat keinen Standard-Executor, daher muss man etwas wie Tokio, smol oder pollster hinzufügen, und diese verwenden Methoden wie poll, die im Future-Trait definiert sind, um Arbeit zu erledigen
    • Ich bin nicht der Autor des Originalartikels, und das sind auch nicht die einzigen Gründe, aber die Gründe, aus denen ich in Rust mit Pointern arbeiten musste, waren FFI und selbstreferenzielle Datenstrukturen wie Graphen
      Dokumentation gibt es an mehreren Stellen, unter anderem hier
      Zu erwarten, dass andere ausschließlich das erklären, was man selbst gebraucht hat, ist ein bisschen viel verlangt
      Ich bin mir nicht sicher, was mit „wie soll das dann funktionieren?“ genau gefragt ist