1 Punkte von GN⁺ 2024-07-22 | 1 Kommentare | Auf WhatsApp teilen
  • Rusts Pin ist ein grundlegendes Element, das eingeführt wurde, um selbstreferenzielle Zustände innerhalb von durch async/await erzeugten Futures sicher zu handhaben
  • Async-Futures speichern an jedem await-Punkt Zustand, sodass Felder in einem Objekt andere Felder desselben Objekts referenzieren können und dadurch selbstreferenzielle Typen entstehen
  • Die Ansätze Move-Constructor, Offset-Pointer und das Design ?Move wurden jeweils wegen Problemen mit Laufzeit-Tracking-Kosten, Kompilierbarkeit und Abwärtskompatibilität bestehender APIs nicht übernommen
  • Das endgültige Design ist Pin, das einen Pointer kapselt und das Ziel in den Zustand pinned typestate versetzt; dank des Auto-Traits Unpin bleiben die meisten Typen wie bisher beweglich
  • Die Schwierigkeit von Pin liegt weniger im Konzept der Unveränderlichkeit selbst als in den Grenzen eines Bibliothekstyps; Reborrowing, Pin::set, pinned projection und die Interaktion mit Drop verschlechtern die Benutzbarkeit deutlich

Das Problem, für das Pin nötig ist

  • In Rusts Async-Ökosystem sind Pin und Pinning eine zentrale Grundlage, aber für Menschen, die Async Rust lernen, bleibt das Gebiet weiterhin schwierig und voller Missverständnisse
  • Der Zweck von Pin ist nicht, dass Benutzer nur mit sicherem Rust selbst direkt selbstreferenzielle Typen erstellen
    • Es dient dazu, vom Compiler in Async-Funktionen erzeugte selbstreferenzielle Futures oder von Runtimes wie tokio per Unsafe-Code erzeugte selbstreferenzielle Typen sicher zu manipulieren
  • Im Beispiel async fn bar müssen am Punkt foo(&mut z).await sowohl z als auch das Foo-Future, das z referenziert, zusammen im selben Future-Zustand gespeichert werden
    • Dabei referenziert ein Feld innerhalb des Future-Objekts ein anderes Feld desselben Objekts
    • Ein solcher Future-Typ wird zu einem selbstreferenziellen Typ
  • Wenn ein Objekt verschoben wird, nachdem es in einen solchen Zustand gelangt ist, zeigen die internen Referenzen auf die frühere Speicheradresse, und diese Adresse kann bereits toter Speicher sein oder für einen anderen Wert wiederverwendet werden
  • Im Rust vor Pin konnte ein Objekt verschoben werden, sobald man Besitz oder eine mutable reference darauf hatte; daher brauchte man eine Möglichkeit auszudrücken, dass Verschieben ab einem bestimmten Zeitpunkt verboten ist

Ansätze, die keine Lösung wurden

  • Move-Constructor

    • Ein Move-Constructor würde beim Verschieben eines Werts ähnlich wie ein Destruktor Code ausführen, um selbstreferenzielle Pointer auf die neue Position umzubiegen
    • In Rust liegen solche Pointer aber nicht nur „im“ verschobenen Wert selbst, sondern können z. B. auch in einem Vektor von Pointern liegen, die auf den eigenen Zustand zeigen
    • Um all diese Pointer zu verfolgen, wäre letztlich ein Laufzeit-Speichermanagement ähnlich einer Garbage Collection nötig
    • Rust entschied sich früh dagegen, Move-Constructor zu haben, und viel Unsafe-Code beruht auf der Annahme, dass Werte durch bloßes Kopieren von Speicher verschoben werden können
    • Würde man Move-Constructor später hinzufügen, wäre das eine breaking change
  • Offset-Pointer

    • Bei Offset-Pointern würden Selbstreferenzen nicht als normale Referenzen, sondern als Offsets relativ zur Adresse des selbstreferenziellen Objekts kompiliert
    • Zur Compile-Zeit lässt sich nicht immer feststellen, welche Referenz selbstreferenziell ist
    • Je nach Verzweigung kann derselbe Wert nach innen auf das eigene Objekt oder nach außen auf etwas anderes zeigen
    • Um das zu behandeln, müsste man Referenzen in etwas wie ein Enum aus Offset und Referenz kompilieren; zur Zeit der Async/await-Arbeiten galt das als unrealistischer Ansatz

Anforderungen an pinned typestate

  • Ein selbstreferenzielles Future muss nicht von Anfang an immer unbeweglich sein, sondern darf sich bis zu einem gewissen Punkt im Lebenszyklus frei bewegen und danach nicht mehr
    • Beim Kombinieren eines Futures mit anderen Futures muss es beweglich bleiben
    • Nachdem es an dem Ort platziert wurde, an dem es gepollt wird, darf es nicht mehr verschoben werden
  • Ralf Jungs Modell ergänzt zu den bisherigen Typestates „owned“ und „shared“ einen dritten Zustand für selbstreferenzielle Futures: pinned typestate
  • Wenn ein Objekt in pinned typestate übergeht, darf es nie wieder verschoben werden
    • Genauer gesagt darf der Speicher des Objekts nicht ungültig gemacht werden, ohne vorher den Destruktor auszuführen
    • Praktisch lässt sich das als Anforderung verstehen, das Objekt nicht an eine neue Position zu versetzen
  • Die meisten Typen können keine Selbstreferenzen enthalten, daher hat pinned typestate für sie keine besondere Bedeutung
    • Für solche Typen ist es wünschenswert, dass sie sich wieder frei bewegen können und nicht unter den Einschränkungen des Pinning leiden
  • Das detaillierte formale Modell von pinned typestate ist in Ralf Jungs A Formal Look at Pinning beschrieben

Warum das Design ?Move scheiterte

  • Vor Pin wurde ein neues traitbasiertes Design namens Move versucht
    • Die meisten Typen implementieren Move
    • Typen, die Selbstreferenzen enthalten können, implementieren Move nicht
    • Wenn man eine Referenz auf einen Wert eines Typs erzeugt, der Move nicht implementiert, wechselt dieser Wert in pinned typestate und kann danach nicht mehr verschoben werden
  • Dieser Ansatz war intuitiv, weil er das Erzeugen einer Referenz mit dem Übergang zum Pinning verknüpft und so Sicherheit garantiert
    • Er wurde sogar in einem Compiler-Branch implementiert
  • Die grundlegende Grenze war, dass es Fälle gibt, in denen man einen Wert, der später selbstreferenziell wird, kurz referenzieren möchte, ohne ihn schon zu pinnen
    • Zum Beispiel möchte man ihn vielleicht kurz in Option speichern und später mit Option::take wieder herausnehmen
  • Das größere Problem war jedoch die Abwärtskompatibilität
    • Move ließ sich nicht als Auto-Trait definieren
    • Denn es gab bereits stabile APIs wie mem::swap, die voraussetzen, dass sich Werte hinter einer mutable reference immer verschieben lassen
  • Auch die nachträgliche Ergänzung als ?Move war wegen associated types nicht abwärtskompatibel
    • Die Stelle, an der man einem associated type ein Bound ?Trait hinzufügen würde, ist die Trait-Definition selbst
    • Lockert man das Bound eines vorhandenen associated type, kann Code brechen, der sich auf dieses Bound verlassen hat
    • Viele Grundoperationen hängen an associated types: etwa der zu IntoFuture gehörende Future-Typ, DerefMut-Target, Funktionsrückgabetypen, Iterator-Items, Rückgabewerte des Index-Operators und der arithmetischen Operatoren
  • Auch über Editions ließ sich das nicht einfach lösen
    • Denn Crates aus unterschiedlichen Editions müssen miteinander kombinierbar bleiben, was identische Trait-Schnittstellen voraussetzt

Das Design von Pin

  • Das endgültige Design drückt pinned typestate nicht als Eigenschaft des Objekttyps selbst aus, sondern als Zustand, der durch einen speziellen Pointer entsteht
  • Pin ist ein Wrapper-Typ um einen Pointer
    • Er kann auch eingebaute Referenztypen kapseln
    • Ebenso bibliotheksdefinierte Smart Pointer wie Box
  • Pin versetzt das Ziel, auf das dieser Pointer zeigt, in pinned typestate, und dieses Ziel darf danach nicht mehr verschoben werden
  • Um Änderungen so klein wie möglich zu halten, wurde dieses Design nicht als Compiler-Feature, sondern als Bibliotheks-API umgesetzt
    • Code, der ein gepinntes Objekt tatsächlich verändert, muss über Unsafe-APIs zugreifen
    • Dabei muss gewährleistet werden, dass das Objekt nicht über eine normale mutable reference verschoben wird
  • Da es für die meisten Typen keinen bedeutsamen Unterschied zwischen gepinntem und normalem Zustand gibt, wurde das Auto-Trait Unpin ergänzt
    • Wenn ein Typ nicht selbstreferenziell sein kann, lässt sich aus einem gepinnten Pointer ohne Unsafe eine mutable reference gewinnen
    • Ein Objekt, das Unpin implementiert, kann sicher auch wieder aus Pin heraus bewegt werden
  • Weil Pinning nur für gepinnte Pointer gilt, funktionieren normale ungepinnte Referenzen weiterhin auch mit Typen, die nicht Unpin sind
  • Weitere Erläuterungen stehen in der Standarddokumentation zum Typ Pin und zum Modul pin
  • Der größte Vorteil dieses Designs ist, dass es sich ergänzen ließ, ohne bestehenden Code zu brechen
    • APIs wie swap, die referenzierte Daten verschieben können, benötigen eine mutable reference
    • Wenn ein Objekt mit Pin gepinnt ist, lassen sich solche APIs darauf nicht mehr aufrufen
    • Weil pinned typestate nur für spezielle gepinnte Referenzen gilt, werden die Garantien zur Abwärtskompatibilität der gesamten Sprache Rust nicht verletzt

Benutzbarkeitsprobleme von Pin

  • Pin erfüllt die Anforderungen auf abwärtskompatible Weise, aber sobald Benutzer es direkt anfassen, entsteht eine Komplexitätsklippe
  • Eine Erklärung dafür ist, dass zum Verändern gepinnter Objekte Unsafe-Code nötig sei
    • Dieses Problem sollte man allerdings nicht übertreiben
    • Mit Pin::set kann man einem gepinnten Objekt sicher einen neuen Wert zuweisen
    • Code, der gepinnte Objekte tatsächlich verändern muss, wird meist vom Compiler erzeugt, wenn er Async-Funktionen in Futures absenkt; dass Benutzer ihn direkt schreiben, ist selten
  • Auch die Erklärung, Pin sei schwierig, weil es bedingt anders funktioniert, trifft den Kern nicht
    • Rust hat Funktionen, die je nach Bedingung unterschiedlich arbeiten und trotzdem gut verständlich bleiben
    • Non-lexical lifetimes sind ein Beispiel dafür, dass Lifetimes je nach Verzweigung an unterschiedlichen Stellen enden können
  • Das Kernproblem ist, dass Pin ein reiner Bibliothekstyp ist, während normale Referenztypen in die Sprache eingebaut sind und vielfältige Syntaxunterstützung sowie Sugar erhalten
    • Dinge, die mit normalen Referenzen natürlich funktionieren, fallen bei gepinnten Referenzen weg
    • Das mentale Modell, das Benutzer aus dem vom Compiler akzeptierten Verhalten normaler Referenzen bilden, bricht bei gepinnten Referenzen auseinander

Reborrowing und Pin::as_mut

  • Eine normale mutable reference &mut T implementiert zwar nicht Copy, kann aber mehrfach an dasselbe Argument übergeben werden
    • Der Compiler führt implizit Reborrowing aus, als hätte er anstelle von x jeweils &mut *x eingesetzt
  • Pin<&mut T> ist hingegen ein gewöhnlicher Bibliothekstyp und implementiert Copy nicht, daher fehlt diese Bequemlichkeit
    • Verwendet man Pin<&mut T> mehr als einmal, kann ein Fehler wegen Nutzung nach Move auftreten oder ein schwerer verständlicher Lifetime-Fehler
    • Man muss explizit Pin::as_mut aufrufen, um erneut zu borgen
  • Bei normalen mutable references kann man über Dereferenzierung und den Zuweisungsoperator direkt zuweisen; bei Pin muss man stattdessen die Methode set lernen
    • Solche Spezial-APIs häufen sich, weil Pin ein Bibliothekstyp ohne sprachliche Syntaxunterstützung ist

Pinned Projection und Drop

  • Pinned Projection ist das Problem, aus einer gepinnten Referenz auf ein Objekt eine gepinnte Referenz auf ein Feld dieses Objekts zu erhalten
    • Projection bedeutet hier den Zugriff vom Objekt auf eines seiner Felder
  • Das ist deutlich schwieriger als Feldzugriff über normale Referenzen, weshalb Third-Party-Crates wie pin-project-lite verwendet werden
    • Solche Crates bringen komplexe neue APIs mit sich, oft inklusive Makros, die man zusätzlich lernen muss
  • Die problematischste Interaktion entsteht zwischen pinned projection und dem Trait Drop
    • Drop::drop erhält eine normale mutable reference
    • Wenn ein Typ ein selbstreferenzielles Feld hat, man auf dieses Feld pin-projiziert und es pollt, es aber dann im Destruktor verschiebt, kann das die Pinning-Garantie verletzen
    • Wenn man zum Beispiel dieses Future im Destruktor auf dem Stack pinnt und pollt, wird die bestehende Pinning-Garantie verletzt
  • Crates wie pin-project-lite gehen damit um, indem sie die Möglichkeiten zur Definition von Destruktoren einschränken
    • Praktisch funktioniert das, erhöht aber die Komplexität, die man beim Erklären der Pinning-Garantien dokumentieren muss
    • Drop war schon vor Pin stable, daher war eine Umgehung nötig

Aktuelle Einordnung und die nächste Richtung für Verbesserungen

  • Pin hat es ermöglicht, Async-Funktionen mit beliebigen Referenzen in sichere selbstreferenzielle Objekte zu kompilieren
    • Referenzen sind ein wesentlicher Teil der grundlegenden Art, wie Rust-Benutzer Code schreiben; ohne das wäre die Benutzbarkeit von async/await deutlich schlechter gewesen
  • Gleichzeitig wurde Pin vollständig abwärtskompatibel zu bestehendem Rust ergänzt
  • Pin ist zu einem grundlegenden Baustein des Ökosystems geworden, das performante Netzwerkdienste und andere Anwendungsfälle asynchroner Programmierung trägt
  • Gleichzeitig ist der Umgang mit gepinnten Referenzen deutlich schwieriger als mit gewöhnlichen Referenzen, und Pin erzeugt tatsächlich eine Komplexitätsklippe
  • Das zentrale Konzept der nächsten Verbesserungsrichtung sind pinned places

1 Kommentare

 
GN⁺ 2024-07-22
Hacker-News-Kommentare
  • Ich fand Pin immer schwer zu verstehen, weil es in der offiziellen Dokumentation nicht klar erklärt wird.
    Vor allem gibt es viele Erklärungen wie „Pin garantiert, dass ein Objekt niemals bewegt wird“, aber das stimmt so nicht.
    Das gilt nur, wenn das Objekt nicht Unpin ist, und die meisten gewöhnlichen Objekte sind Unpin, sodass Pin normalerweise gar nichts bewirkt.
    Es hat bei mir sehr lange gedauert, das zu verstehen, und die Menge der Typen T, für die Pin tatsächlich Bedeutung hat, ist ziemlich speziell und seltsam; ich finde nicht, dass die Dokumentation das ausreichend betont.

    • Gutes Feedback, und es wäre schön, wenn die Dokumentation diesen Punkt klarer machen würde.
      Natürlich sind Futures und Streams, also die Typen, die man tatsächlich häufig in gepinntem Zustand behandelt, viel eher solche speziellen Objekte.
      Trotzdem finde ich, dass die Dokumentation in den letzten Jahren deutlich besser geworden ist.
      Als ich das für diesen Artikel überprüft habe, war ich überrascht, wie gut sie sich auf die richtigen Punkte konzentriert, und ich erinnere mich, dass sie um 2019 viel stärker auf Vertragsspezifikationen ausgerichtet war, die eher in die Rust-Referenz als in die std-API-Dokumentation gepasst hätten.
  • Ich glaube, der Grund, warum Nutzer Pin schwierig finden, ist, dass Pin für sich allein keine Bedeutung hat.
    Das unterscheidet es von anderen Wrappern der Sprache; eine Ausnahme wäre höchstens AssertUnwindSafe, das in seinem ursprünglichen Zweck fast niemand benutzt.
    Wenn man Pin<&mut InnerType> hat, gibt es in der Sprache oder in der Standardbibliothek selbst nichts an Pin, das einem sagt, was man damit tun kann oder nicht.
    Nur wenn InnerType als Unpin deklariert ist, bedeutet das, dass man alles tun kann, was mit einem normalen Pointer möglich wäre.
    Stattdessen funktioniert Pin so, dass es sich seine „Bedeutung“ von außen holt: Der Anbieter von InnerType ergänzt intern unsafee Methoden und APIs, um gepinnte Objekte sicher manipulieren zu können.
    Der eigentliche Zweck von Pin besteht darin, einen Pointer mit weniger inhärenten Fähigkeiten bereitzustellen, etwa ohne &mut-Ersetzung oder das Herausnehmen und Bewegen aus einer Box, damit der innere Typ darauf sicher zusätzliche Fähigkeiten erlauben kann.
    Ich denke, diese Unschärfe der Bedeutung verwirrt die Leute am meisten, und bei mir hat es auch ziemlich lange gedauert, das zu verstehen.
    Die Konzepte struktureller und nichtstruktureller Felder sind nur ein Mechanismus, um übliche Zugriffsmuster zu ermöglichen wie: „Dieses Feld ist gewöhnliche Daten, aber jenes Feld enthält ein Objekt, das selbst gepinnt sein will.“

    • Pin hat durchaus eine Bedeutung. Solange der Zieltyp Unpin nicht implementiert, bedeutet es, dass das Ziel dieses Pointers nie wieder bewegt werden kann.
      Genauer gesagt bedeutet es, dass das Ziel nicht invalidiert werden kann, ohne seinen Destruktor auszuführen, und genau deshalb ist Bewegung problematisch.
      Wenn man bestimmte Rechte aufgibt, erhält man dafür andere Rechte, etwa das Recht, selbstreferenzielle Werte zu speichern.
      Verträge zwischen Komponenten funktionieren meist genau so.
      Genauso kann man, wenn man das Recht auf Mutation durch eine Referenz aufgibt, diese Referenz gleichzeitig aliassierbar machen.
      Wenn ich darüber nachdenke, fällt mir immer eine Zeile aus dem Film Lincoln ein — auch wenn das ein ganz anderes und viel schwereres Thema ist: „If we submit to the law, Alex, to freedom’s loss as to a thing we hate — even as to the loss of a freedom to oppress — then may we discover freedoms that we had not known existed.“
      Ich stimme aber zu, dass es didaktisch ein Problem ist, dass man solche Rechte in sicherem Code nicht direkt nutzen kann.
      Denn es ist schwer, einfach zu zeigen, was man mit einer gepinnten Referenz tun kann, außer zu sagen: „Rufe die vom Compiler erzeugte poll-Methode auf.“
  • Ich entwickle seit einigen Jahren beruflich mit Rust, aber ehrlich gesagt verstehe ich Pin nicht besonders gut.
    Die Theorie kenne ich, aber ich habe wenig Intuition dafür, wann man es verwenden sollte.
    Die Benutzung von Pin läuft faktisch eher auf „Ich habe etwas ausprobiert, dann hat der Compiler gemeckert, und nachdem ich dies und das gepinnt hatte, kompiliert es“ hinaus.
    In meinem täglichen Coding war das bisher noch nie ein Hindernis, bei dem ich mich wirklich hinsetzen und es tiefgehend verstehen musste.

    • Bei mir genauso. Das ist einer der häufigsten Fälle von: „Einfach unsafe vermeiden und dankbar sein, dass die klugen Compiler-Leute das schon gelöst haben.“
      In C++ dagegen bin ich oft am Ufer der „Dinge, die man nicht versteht, aber unbedingt benutzen muss“ entlanggelaufen und wurde dann von Krokodilen gefressen.
  • Beim Unterrichten wäre es wohl gut, reale Analogien für Dinge zu verwenden, die von einem Werkzeug zum Festhalten an Ort und Stelle nicht beeinflusst werden, um klarzumachen, dass Unpin-Elemente von Pin nicht betroffen sind.
    Klettverschluss-Haken haften nicht auf glatten Oberflächen: Pin → Klettverschluss, Unpin → glatte Oberfläche.
    Magnete wirken nicht auf nichtmagnetische Materialien: Pin → Magnet, Unpin → nichtmagnetisch/Glas/Messing.
    Klebstoff haftet nicht auf Antihaft-Oberflächen: Pin → Klebstoff, Unpin → Antihaft.
    So wird klar, dass „Klettverschluss“ Dinge an Ort und Stelle hält, ein Gegenstand aber, wenn er „glatt“ ist, vom Klett-Mechanismus nicht betroffen ist.
    Wenn man an die Namensgebung im Rust-Ökosystem denkt, wäre es schön gewesen, den Trait eher in Richtung Magnet und Nichtmagnetisch zu benennen.

    • Aber glatte Objekte kann man nicht mit Klett befestigen, und Holz kann keinen Magneten festhalten.
      Unpin bedeutet doch eher, dass ein Objekt jederzeit bereit ist, gepinnt zu werden, oder?
      Ich habe den Artikel letzte Nacht gelesen, aber schon wieder vergessen, ob beim Pinnen noch ein zusätzlicher Schritt zur Herstellung nötig ist.
      Deshalb sehe ich T: Pin + !Unpin eher wie Papier, das man nur mit einer Heftklammer befestigen kann, und T: Pin + Unpin eher wie ein Bild mit Haken, das man an einen Nagel hängen und wieder abnehmen kann, ohne den Haken zu beschädigen.
  • Der Begriff „Wertidentität“ wird in diesem Artikel nirgends definiert, und ich konnte ihn auch in der Mojo-Dokumentation nicht finden; deshalb ist unklar, worauf Modular seine Aussage stützt, dass Mojo das Problem löst, das Pin lösen soll.
    Ich behaupte auch nicht, die Antwort zu kennen, aber ich musste an einen hervorragenden Vortrag von Dave Abrahams denken, der zusammen mit Chris Lattner an der Wertsemantik von Swift gearbeitet hat.
    Der Vortrag trägt den Titel „Value Semantics: Safety, Independence, Projection, & Future of Programming“.
    [0] https://www.youtube.com/watch?v=QthAU-t3PQ4

    • Es ist klar, dass Mojo in gewisser Weise das Konzept der Wertsemantik von Swift geerbt hat, aber Rust hat ebenfalls Wertsemantik in demselben Sinn.
      Rust hat zudem Referenzen als Typen erster Klasse, während Swift und, soweit ich es sehe, auch Mojo Referenzen nur als Art der Parameterübergabe zulassen.
      Es scheint, als habe Mojo Swifts inout-Parameter erweitert und zusätzlich eine Form der Übergabe per unveränderlicher Referenz eingeführt.
      Wenn man es unmöglich macht, Referenzen in Objekten zu speichern, kann man keinen Code mehr ausdrücken, wie Rust ihn kompiliert; damit wäre das Problem der „selbstreferenziellen Structs“ zwar gelöst.
      Aber der zitierte Absatz über Mojo sagt überhaupt nichts in dieser Richtung, daher bin ich ziemlich verwirrt, was damit gemeint ist.
  • Meiner Ansicht nach besteht das Problem darin, dass man, wenn man eine &mut-Referenz auf einen Wert hat, diesen Wert mit Dingen wie mem::swap/replace verschieben kann.
    In der Praxis muss man das allerdings nur selten tun.
    Wäre das nicht erlaubt, dann wäre es vermutlich völlig sicher, auch für selbstreferenzielle Werte eine &mut-Referenz zu haben.
    Vielleicht hätte es eine Möglichkeit geben können, Verschieben über Referenzen nur dann explizit zu wählen, wenn man es wirklich braucht, und vielleicht hätte sich das ganze Problem vermeiden lassen, wenn swap und replace unsafe gewesen wären.
    Ich wünschte, jemand würde diesen Designraum einmal erkunden.

    • Stimmt. Aaron Turon sagte damals, als an diesem Problem gearbeitet wurde, &mut sei zu mächtig.
      Wenn &mut nicht die Befugnis gäbe, den darin enthaltenen Wert zu verschieben, wäre das gesamte Design viel einfacher gewesen.
      Im nächsten Artikel werde ich darauf eingehen.
      Rust muss abwärtskompatibel bleiben und hat bereits entschieden, dass man Werte aus &mut heraus verschieben kann, aber wenn man nicht an frühere Entscheidungen gebunden wäre, wäre ganz klar ein wesentlich saubereres Design möglich.
    • Das stimmt, ist aber nicht skalierbar. Es hätte von Anfang an kaum funktionieren können, weil es zu viel bestehenden Code kaputt gemacht hätte.
      mem::swap ist nur eine von vielen Möglichkeiten, Werte über veränderliche Referenzen zu verschieben, und es gibt noch sehr viele andere.
      Option::take ist ein Beispiel, das ich ziemlich häufig benutze; wenn das unsafe wäre, wäre das wirklich seltsam.
  • Ich finde diese Hintergrundgeschichte gut. WithoutBoats hat bereits viele sehr zeitnahe Diskussionen zu asynchronen Iteratoren, poll und pin geführt.
    https://news.ycombinator.com/from?site=without.boats
    Es scheint nicht viele Communities zu geben, die so tief und öffentlich in die feinen Interna einer Sprache eintauchen, und das macht Spaß zu verfolgen.

    • Es ist zwar beeindruckend, bedeutet aber auch, dass sich die Sprachentwicklung sehr langsam bewegt.
      Asynchronität ist noch immer halbgar und sehr komplex.
      Das sage ich als jemand, der in den letzten drei Jahren 40 Stunden pro Woche Rust-Code geschrieben hat.
  • Man kann sich eine Rust-ähnliche Sprache mit Move-Konstruktoren vorstellen, in der alle erzeugten Future-Untertypen opak sind und automatisch auf dem Heap allokiert werden.
    Dann gäbe es für den Benutzer keine Möglichkeit, sie zu zerstören, und da sie opak sind und irgendwo anders auf dem Heap liegen, auch keine Möglichkeit, sie zu verschieben, sodass Pin unnötig werden könnte.
    Dass es Move-Konstruktoren gibt, bedeutet nämlich, dass Verschieben konzeptionell Zerstörung und anschließende Neuerzeugung ist.

    • Pin ist eher ein Zustand als eine Eigenschaft der Daten selbst.
      Das hat den angenehmen Effekt, dass man Futures vor der Ausführung zusammenführen und inlinen kann.
      Das ist auch ähnlich wie die Unveränderlichkeit in Rust: Es gibt keinen unveränderlichen Speicher, sondern nur unveränderliche Referenzen.
    • Wenn alle Futures auf dem Heap allokiert würden, bräuchte man keine Move-Konstruktoren.
      Dann entstünde aber bei jedem Aufruf einer asynchronen Funktion eine separate Allokation, und das ist sehr schlecht für die Speicherlokalität.
      Eine Art virtueller Stack wäre deutlich besser, aber wenn man den Stack standardmäßig auf geringe Größe optimieren will, braucht man am Ende doch einen Garbage Collector.
    • Ich kann mir vorstellen, wie einschneidend die Änderung wäre, aber ich wünschte wirklich, Rust würde das frontal angehen und in std ein auf Sprachebene eingebautes Move-Trait hinzufügen, ungefähr auf derselben Ebene wie Copy.
      Move würde eine Funktion definieren, die einen Wert von einer Speicheradresse an eine andere verschiebt, und Strukturen ohne impl Move wären dann nicht verschiebbar.
      Fast alle Typen würden einfach #[derive(Move)] bekommen, was nur eine einfache Verschiebefunktion implementiert, die die Bytes kopiert.
      Damit würde sich aber der Weg für selbstreferenzielle Typen, Futures und viele andere Dinge öffnen, die komplexeres Verschiebeverhalten brauchen.
      Eigentlich wäre es vielleicht sinnvoller, das in zwei Traits aufzuteilen, analog zum Unterschied zwischen Copy und Clone.
      Eines wäre ein Marker-Trait, das dem Compiler sagt, dass man die Bytes einfach verschieben kann, und das andere würde benutzerdefinierte Implementierungen von „Move-Konstruktoren“ erlauben.
      Pin ist einfach zu schwer zu verstehen, deshalb wäre Move wünschenswert.
      Das ist ein komplexes Konzept, eingewickelt in doppelte, manchmal sogar dreifache Verneinungen. Wenn man etwas wie fn(...) sieht, denkt man sich nur: „Was ist das?“; und spätestens bei unsafe-Pin-Projektion steigt man aus.
      Ich weiß nicht, wann etwas sicher ist und wann nicht, und lasse dann einfach die Finger davon.
      Der Umstieg von Rust ohne Move auf Rust mit Move wäre unbequem.
      Man müsste bei fast jeder bisher geschriebenen Struktur #[derive(Move)] ergänzen, und für std genauso.
      Für alle in bestehenden Editions nicht gepinnten Typen müsste der Compiler die Implementierung des Move-Traits inferieren.
      Mechanisch wäre das wohl möglich, es wäre einfach nur viel Arbeit.
      Async Rust ist furchtbar. Besonders wenn man es mit Futures/Promises in fast allen anderen Sprachen vergleicht.
      Irgendwann wird jemand das Modell für Speichersicherheit von Rust verbessern und eine neue Rust-ähnliche Systemsprache mit Move-Trait und besseren Futures bauen.
      Persönlich hätte ich außerdem lieber Compile-Time Execution statt des Makrosystems von Rust.
      Ich mag Rust, und ich mag auch all die Arbeit, die das Team über Jahre geleistet hat.
      Aber die Sprache, auf die ich mich wirklich freue, ist die Sprache nach Rust.
      Dieselben Ideen, aber eine Sprache, die aus den Fehlern von Rust gelernt hat, und es wird immer klarer, wie so eine bessere Rust-artige Sprache aussehen könnte.
      Ich kann es kaum erwarten.
  • Noch ein hervorragender Artikel von WithoutBoats.
    Ehrlich gesagt ist das einer der Punkte, bei denen ich froh bin, dass das in Rust innerhalb des Async-Runtime-Inneren abstrahiert und vergraben ist.
    Trotzdem frage ich mich, wo Pin tatsächlich verwendet wird, abgesehen von benutzerdefinierten Future-Implementierungen.

    • Das frage ich mich auch.
      Vom Ausdruck her sieht es so aus, als könnte man es innerhalb von FFI verwenden.
      Wenn zum Beispiel eine extern-Funktion ein *mut T zurückgibt und man also einen Pointer erhält, scheint es, als könnte man ihm durch Umhüllen mit Pin<&mut T> eine bessere Semantik geben.
      Im Artikel heißt es aber: „Eine weitere Tatsache über den Zustand eines gepinnten Typs ist, dass er für die meisten Typen völlig irrelevant ist. Wenn der Wert eines Typs niemals Selbstreferenzen enthalten kann, ist es nutzlos, ihn zu pinnen.“
      Ich bin bei FFI noch ganz am Anfang und würde gern verstehen, wie man es am besten in sicheres Rust verpackt.
    • Bei FFI gibt es Fälle, in denen eine C-API Objekte nicht als Referenz, sondern als Pointer exponiert und sie dann nicht verschoben werden dürfen.
      Das gilt auch bei der Interaktion mit Systemtypen, die von ihrer Adresse abhängen.
      Bei Mutexen/Futexen mancher Betriebssysteme steht in der Kernel-Dokumentation beispielsweise, dass User-Space-Lock-Objekte nach der Initialisierung ihre Adresse nicht mehr ändern dürfen, weshalb std meines Wissens etwas Entsprechendes zu Pin verwendet.
      Ungewöhnlich ist, dass sich die Adresse selbst dann nicht ändern darf, wenn das Objekt nicht gesperrt ist.
      Normalerweise gilt diese Bedingung nur während es gesperrt ist; in diesem Fall kann man das Ziel einer bestehenden Referenz ohnehin nicht verschieben, sodass Pin nicht nötig ist.
  • Es wirkt, als würde enormer Aufwand betrieben, nur um das eigentliche Problem der Ineffizienz von Threads nicht beheben zu müssen
    Sämtlicher asynchroner Code ist ausnahmslos ein Hack, der leichtgewichtige Threads mit viel syntaktischem Zucker zur Zustandsverwaltung implementiert
    In Sprachen wie Rust fügt das eine riesige Menge an Komplexität hinzu, die eigentlich nicht nötig wäre
    Wenn man die Effizienz- und Skalierbarkeitsprobleme von Threads beheben würde, würde all das verschwinden
    Einfach puff, weg
    Ähnlich wie null in Sprachen wie Java ein „Fehler mit Kosten in Milliardenhöhe“ war
    Eine einzige Designentscheidung — oder hier das Fehlen eines Designs — erzeugt enorme Komplexität

    • Threads unterstützen keine vernünftige Form von Abbruch
      Abbruch ist für Netzwerkapplikationen und GUIs sehr nützlich
      Threads machen es schwer, sowohl CPU als auch Netzwerk gut auszulasten, ohne eines von beiden übermäßig zu belegen
      Sobald man beginnt, Arbeit zwischen Thread-Pools zu verschieben, ist man bereits auf dem Weg, Futures neu zu implementieren
      Oder man arbeitet mit Callbacks/Ereignissen, wodurch der Code zerstückelt wird, und genau dafür sollte async/await ja der syntaktische Zucker sein
      Die Alternative zu Abbruch und Timeouts ist, wie in Go, ein Context-Objekt durch den gesamten Code zu verweben; dann entsteht aber das Problem, dass endständiger Code naiv Funktionen aufruft, die Context nicht korrekt beachten
      Das ist nur geringfügig besser als das Problem nicht-asynchroner Funktionen in asynchronem Code
    • Ich bezweifle, dass es möglich ist, den Linux-Kernel neu zu schreiben, um „die Effizienz- und Skalierbarkeitsprobleme von Threads zu beheben“, und selbst wenn es möglich wäre, wäre die Menge an Kernel-Experten, die so etwas tun könnten, vermutlich nicht dieselbe wie die Menge an Rust-Experten, die Pin zum Funktionieren gebracht haben
      Deshalb frage ich mich, was man konkret hätte tun sollen
      Hätte man einfach die Hände heben und sagen sollen: „Irgendwann wird vielleicht jemand Linux reparieren und Threads auf magische Weise schnell machen, also fügen wir unserer Sprache kein Async hinzu“?
    • Funktionen zu kennzeichnen und zu unterscheiden, je nachdem ob sie mit gleichzeitig laufenden Prozessen synchronisieren oder nicht, ist tatsächlich etwas Gutes
    • Leider kostet das Überschreiten der User-Space-Grenze selbst dann etwas, wenn ein „Thread“ noch so leichtgewichtig ist
      Wenn man außerdem das Betriebssystem zum Scheduler aller asynchronen Arbeit macht, müssen alle Runtimes den Scheduler des Betriebssystems verwenden, wodurch unterschiedliche Scheduler-Designs unmöglich werden
    • Die schädlichen Entscheidungen, die Rust getroffen hat, zeigen, wie tief eine Kultur verankert ist, die frühere Fehler um jeden Preis weiterträgt
      Es wirkt, als würde das Kosten-Nutzen-Verhältnis nicht neu bewertet, selbst nachdem sich gezeigt hat, dass der „bevorzugte Ansatz“ nicht praktikabel ist
      „Wir wollen Funktion X, die Folgen sind uns egal“ ist beim Sprachdesign nur selten ein Gewinner