- Rusts
Pinist 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
?Movewurden 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-TraitsUnpinbleiben die meisten Typen wie bisher beweglich - Die Schwierigkeit von
Pinliegt weniger im Konzept der Unveränderlichkeit selbst als in den Grenzen eines Bibliothekstyps; Reborrowing,Pin::set, pinned projection und die Interaktion mitDropverschlechtern die Benutzbarkeit deutlich
Das Problem, für das Pin nötig ist
- In Rusts Async-Ökosystem sind
Pinund Pinning eine zentrale Grundlage, aber für Menschen, die Async Rust lernen, bleibt das Gebiet weiterhin schwierig und voller Missverständnisse - Der Zweck von
Pinist 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 barmüssen am Punktfoo(&mut z).awaitsowohlzals auch dasFoo-Future, daszreferenziert, 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
Pinkonnte 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
Pinwurde ein neues traitbasiertes Design namensMoveversucht- Die meisten Typen implementieren
Move - Typen, die Selbstreferenzen enthalten können, implementieren
Movenicht - Wenn man eine Referenz auf einen Wert eines Typs erzeugt, der
Movenicht implementiert, wechselt dieser Wert in pinned typestate und kann danach nicht mehr verschoben werden
- Die meisten Typen implementieren
- 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
Optionspeichern und später mitOption::takewieder herausnehmen
- Zum Beispiel möchte man ihn vielleicht kurz in
- Das größere Problem war jedoch die Abwärtskompatibilität
Moveließ 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
?Movewar wegen associated types nicht abwärtskompatibel- Die Stelle, an der man einem associated type ein Bound
?Traithinzufü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
IntoFuturegehörende Future-Typ,DerefMut-Target, Funktionsrückgabetypen, Iterator-Items, Rückgabewerte des Index-Operators und der arithmetischen Operatoren
- Die Stelle, an der man einem associated type ein Bound
- 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
Pinist ein Wrapper-Typ um einen Pointer- Er kann auch eingebaute Referenztypen kapseln
- Ebenso bibliotheksdefinierte Smart Pointer wie
Box
Pinversetzt 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
Unpinergänzt- Wenn ein Typ nicht selbstreferenziell sein kann, lässt sich aus einem gepinnten Pointer ohne Unsafe eine mutable reference gewinnen
- Ein Objekt, das
Unpinimplementiert, kann sicher auch wieder ausPinheraus bewegt werden
- Weil Pinning nur für gepinnte Pointer gilt, funktionieren normale ungepinnte Referenzen weiterhin auch mit Typen, die nicht
Unpinsind - Weitere Erläuterungen stehen in der Standarddokumentation zum Typ
Pinund zum Modulpin - 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
Pingepinnt 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
- APIs wie
Benutzbarkeitsprobleme von Pin
Pinerfü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::setkann 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,
Pinsei 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
Pinein 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 Timplementiert zwar nichtCopy, kann aber mehrfach an dasselbe Argument übergeben werden- Der Compiler führt implizit Reborrowing aus, als hätte er anstelle von
xjeweils&mut *xeingesetzt
- Der Compiler führt implizit Reborrowing aus, als hätte er anstelle von
Pin<&mut T>ist hingegen ein gewöhnlicher Bibliothekstyp und implementiertCopynicht, 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_mutaufrufen, um erneut zu borgen
- Verwendet man
- Bei normalen mutable references kann man über Dereferenzierung und den Zuweisungsoperator direkt zuweisen; bei
Pinmuss man stattdessen die Methodesetlernen- Solche Spezial-APIs häufen sich, weil
Pinein Bibliothekstyp ohne sprachliche Syntaxunterstützung ist
- Solche Spezial-APIs häufen sich, weil
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
DropDrop::droperhä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-litegehen 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
Dropwar schon vorPinstable, daher war eine Umgehung nötig
Aktuelle Einordnung und die nächste Richtung für Verbesserungen
Pinhat 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
Pinvollständig abwärtskompatibel zu bestehendem Rust ergänzt Pinist 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
Pinerzeugt tatsächlich eine Komplexitätsklippe - Das zentrale Konzept der nächsten Verbesserungsrichtung sind pinned places
1 Kommentare
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
Unpinist, und die meisten gewöhnlichen Objekte sindUnpin, 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.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
InnerTypealsUnpindeklariert 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
InnerTypeergänzt internunsafee 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 einerBox, 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.“
Unpinnicht 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.
unsafevermeiden 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 vonPinnicht 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.
Unpinbedeutet 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 + !Unpineher wie Papier, das man nur mit einer Heftklammer befestigen kann, undT: Pin + Unpineher 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
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 wiemem::swap/replaceverschieben 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
swapundreplaceunsafegewesen wären.Ich wünschte, jemand würde diesen Designraum einmal erkunden.
&mutsei zu mächtig.Wenn
&mutnicht 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
&mutheraus verschieben kann, aber wenn man nicht an frühere Entscheidungen gebunden wäre, wäre ganz klar ein wesentlich saubereres Design möglich.mem::swapist nur eine von vielen Möglichkeiten, Werte über veränderliche Referenzen zu verschieben, und es gibt noch sehr viele andere.Option::takeist ein Beispiel, das ich ziemlich häufig benutze; wenn dasunsafewäre, wäre das wirklich seltsam.Ich finde diese Hintergrundgeschichte gut. WithoutBoats hat bereits viele sehr zeitnahe Diskussionen zu asynchronen Iteratoren,
pollundpingefü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.
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
Pinunnötig werden könnte.Dass es Move-Konstruktoren gibt, bedeutet nämlich, dass Verschieben konzeptionell Zerstörung und anschließende Neuerzeugung ist.
Pinist 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.
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.
stdein auf Sprachebene eingebautesMove-Trait hinzufügen, ungefähr auf derselben Ebene wieCopy.Movewürde eine Funktion definieren, die einen Wert von einer Speicheradresse an eine andere verschiebt, und Strukturen ohneimpl Movewä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
CopyundClone.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.
Pinist einfach zu schwer zu verstehen, deshalb wäreMovewü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 beiunsafe-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
Moveauf Rust mitMovewäre unbequem.Man müsste bei fast jeder bisher geschriebenen Struktur
#[derive(Move)]ergänzen, und fürstdgenauso.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
Pintatsächlich verwendet wird, abgesehen von benutzerdefiniertenFuture-Implementierungen.Vom Ausdruck her sieht es so aus, als könnte man es innerhalb von FFI verwenden.
Wenn zum Beispiel eine
extern-Funktion ein*mut Tzurückgibt und man also einen Pointer erhält, scheint es, als könnte man ihm durch Umhüllen mitPin<&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.
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
stdmeines Wissens etwas Entsprechendes zuPinverwendet.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
Pinnicht 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
nullin Sprachen wie Java ein „Fehler mit Kosten in Milliardenhöhe“ warEine einzige Designentscheidung — oder hier das Fehlen eines Designs — erzeugt enorme Komplexität
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/awaitja der syntaktische Zucker seinDie 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, dieContextnicht korrekt beachtenDas ist nur geringfügig besser als das Problem nicht-asynchroner Funktionen in asynchronem Code
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“?
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
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