- Die Rust-Verbindungsbibliothek
connlibvon Firezone verwaltet Netzwerkverbindungen und WireGuard-Tunnel und erreicht durch ein sans-IO-Design schnelle Tests und hohe Zuverlässigkeit im Verhalten - Protokolle werden nicht durch direkten Umgang mit Sockets implementiert, sondern als reine Zustandsmaschinen; die Event Loop ruft APIs wie
handle_input,poll_transmit,handle_timeoutundpoll_timeoutauf - Wenn IO-Entscheidungen aus der Zustandsmaschine herausgelöst werden, sinkt die Belastung durch Function Colouring in Rust async, und die Wahl zwischen blocking IO, non-blocking IO und einem bestimmten async Runtime bleibt der Anwendung überlassen
- Durch die Abstraktion von Sockets und Zeit lassen sich Zeitablauf, Paketverlust und ungewöhnliche Antworten nur mit
InstantundTransmitprüfen, ohne echte Ports oder Wartezeiten - Dafür muss man die Event Loop selbst verwalten, was subtile Bugs verursachen kann; sequentielle Workflows führen zu mehr Zustandsmaschinen-Code, und sans-IO-Bibliotheken im Rust-Ökosystem sind noch begrenzt
Die sans-IO-Architektur von Firezones connlib
- Firezone nutzt Rust, um skalierbaren, sicheren Remote-Zugriff für Android, macOS und Linux zu entwickeln
- Im Zentrum jeder App steht
connlib; diese Bibliothek verwaltet Netzwerkverbindungen und WireGuard-Tunnel, um Traffic zu schützen - Firezones Rust-Stack nutzt unter anderem
tokio,tungstenite,boringtunundrustls, doch die interne Architektur unterscheidet sich von typischem async-Rust-Code- Es gibt kaum Aufrufe von
tokio::spawn - Die gesamte Kommunikation wird über einen einzigen UDP-Socket multiplexed
- APIs wie
handle_timeout,poll_transmitundhandle_inputtauchen auf mehreren Ebenen wiederholt auf
- Es gibt kaum Aufrufe von
- Diese Merkmale sind Hinweise auf ein sans-IO-Design, bei dem Protokolllogik kein IO direkt ausführt, sondern Zustand und Ein-/Ausgabeabsichten ausdrückt
- Im Python-Ökosystem gibt es eine eigene Dokumentationsseite für sans-IO; in Rust nutzen die folgenden Bibliotheken dieses Muster
async Rust und die Belastung durch Function Colouring
- Async-Funktionen in Rust können nur innerhalb anderer async-Funktionen aufgerufen werden, wodurch die Einschränkung des Function Colouring entsteht: Die gesamte Aufrufkette wird async
- Diese Einschränkung erzwingt zur Compile-Zeit, dass die Möglichkeit, die Ausführung anzuhalten und später fortzusetzen, Teil des API-Vertrags einer Funktion ist
- Wegen einer einzigen async-Funktion tief im Stack können auch andere Funktionen auf dem Aufrufpfad async werden, um
.awaitnutzen zu können - Die eigentliche async-Arbeit findet meist ganz unten im Call Stack statt
- Schreiben auf einen Socket
- Lesen einer Datei
- Warten auf den Ablauf von Zeit
- Viele async-Funktionen führen selbst keine asynchrone Arbeit aus, sondern werden async, weil sie von anderen async-Funktionen abhängen
- Firezones
connlibnutzt für NAT Traversal ICE und findet mit STUN server-reflexive Candidates, also öffentliche Adressen - STUN Binding ist ein einfaches Protokoll: Ein UDP-Paket wird an einen Server gesendet, und man erhält eine UDP-Antwort mit der vom Server gesehenen IP und Portnummer
- Dasselbe STUN-Beispiel lässt sich mit dem async
UdpSocketvontokiooder mit blocking IO aus der Standardbibliothek nahezu identisch schreiben - Wenn eine Bibliothek STUN-Funktionalität anbieten soll, muss sie entweder eine async- oder eine blocking-Version wählen oder beide enthalten, was zu Duplizierung führt
- Der Beispielcode befindet sich unter firezone/sans-io-blog-example
Der Kern von sans-IO ist die Trennung von Policy und IO-Implementierung
- Der Kern von sans-IO ähnelt dem Dependency-Inversion-Prinzip aus der Objektorientierung
- Policy-Code, der entscheidet, „was zu tun ist“, sollte nicht von Implementierungsdetails abhängen, die ausführen, „wie es getan wird“
- Wenn Code, der entscheidet, eine Netzwerknachricht zu senden, direkt vom tatsächlichen Socket-Sendecode abhängt, wird auch der darüberliegende Code an die Wahl zwischen async und blocking IO gebunden
- Im STUN-Beispiel ist der Policy-Code identisch, wird aber async, wenn er auf
tokio::UdpSocketaufgebaut ist, und blocking IO, wenn er aufstd::net::UdpSocketaufgebaut ist - Sans-IO ruft nicht direkt
UdpSocket::sendauf, sondern erzeugt eine Abstraktion, die die Sendeabsicht ausdrückt Transmitim Beispiel enthält folgende Informationen- Ziel-
SocketAddr - zu sendendes
payload
- Ziel-
- Protokollcode schreibt nicht direkt auf den Socket, sondern gibt ein
Transmitaus - Der tatsächliche Aufruf von
UdpSocket::sendodersend_toist Aufgabe der Event Loop - Sans-IO-Code muss von einer Event Loop angetrieben werden, ähnlich wie Rusts
Futureerst vorankommt, wenn es von einer Runtime gepollt wird
STUN Binding in eine Zustandsmaschine überführen
- Eine STUN-Binding-Anfrage lässt sich als Zustandsmaschine mit den Zuständen
SentundReceivedmodellieren - Der Zustand im Beispiel wird durch folgendes enum ausgedrückt
SentReceived { address: SocketAddr }
StunBindingbesitzt den aktuellen Zustand und eine Warteschlange vonTransmits, die auf den Versand warten- Die wichtigsten APIs haben klare Rollen
handle_input: übergibt der Zustandsmaschine ein Paket, das als Ergebnis vonUdpSocket::recveingegangen istpoll_transmit: die Event Loop holt einTransmit, das die Zustandsmaschine ausgeben möchtepublic_address: fragt die empfangene öffentliche Adresse ab
- In dieser Struktur modelliert die Protokolllogik nur das Verhalten des Programms, ohne IO
- Die Event Loop sendet Pakete über den Socket, wenn
poll_transmitwelche liefert; andernfalls übergibt sie vom Socket gelesene Daten anhandle_input - Die Event Loop muss nicht wissen, dass STUN ein Request-Response-Protokoll ist
- UDP ist ein unzuverlässiges Protokoll, sodass Pakete verloren gehen können; STUN erfordert Retransmission-Timer, um das abzufedern
Auch Zeit abstrahieren
- In Netzwerkprotokollen wird die aktuelle Zeit meist benötigt, um zu prüfen, wie viel Zeit seit einem bestimmten Referenzzeitpunkt vergangen ist
- ob seit dem Senden einer Anfrage 5 Sekunden vergangen sind
- ob seit dem letzten Keep-alive 30 Sekunden vergangen sind
- In solchen Fällen wird keine tatsächliche Wall-Clock-Zeit benötigt, sondern nur die
Durationrelativ zu einem früheren Zeitpunkt - Rusts
Instantlegt die aktuelle Zeit nicht offen, ermöglicht aber das Messen derDurationzwischen zweiInstants - Eine sans-IO-Zustandsmaschine kann für zeitbasiertes Verhalten zwei APIs haben
poll_timeout: gibt zurück, für welchen Zeitpunkt die Event Loop den nächsten Wake-up-Timer planen sollhandle_timeout: teilt der Zustandsmaschine mit, dass der Timer abgelaufen ist
- Das Beispiel erweitert die Zustandsmaschine so, dass 5 Sekunden nach der letzten Antwort eine neue Binding Request gesendet wird
handle_inputnimmt zusammen mit dem Paket den aktuellenInstantentgegen und speichert ihn alsState::Received { address, at }- Die Event Loop verarbeitet Socket-Empfang und Timer-Ablauf gemeinsam und setzt den Timer anschließend anhand des Ergebnisses von
poll_timeoutneu
Komposition und API-Flexibilität
- Die wichtigsten APIs von
StunBinding–handle_timeout,handle_input,poll_transmit,poll_timeout– sind nicht spezifisch für STUN - Die meisten Netzwerkprotokolle lassen sich in dieser Form oder Varianten davon implementieren, wodurch Komposition von Zustandsmaschinen einfach wird
- Um die öffentliche IP über 5 STUN-Server zu ermitteln, kann man 5
StunBindings erstellen und nacheinander aufrufen- In diesem Fall muss das Multiplexing der STUN-Nachrichten passend implementiert werden; dafür können
TransactionIdoder die Serveradresse genutzt werden
- In diesem Fall muss das Multiplexing der STUN-Nachrichten passend implementiert werden; dafür können
- Firezones
snownetkombiniert ICE und WireGuard und stellt Anwendungen einen IP-Tunnel bereit, der in unterschiedlichen Netzwerkumgebungen funktioniert snownetbaut auf der sans-IO-WebRTC-Bibliothekstr0mund der nahezu sans-IO gehaltenen WireGuard-Implementierungboringtunauf- Firezone benötigt nicht den gesamten WebRTC-Stack, sondern nur den
IceAgent, der RFC 8445 implementiert - Weil
str0msans-IO nutzt, lässt sich nur derIceAgentleicht übernehmen und mit der Zustandsmaschine des bestehenden Codes komponieren - Eine Connection in
snownetenthält denIceAgentund den WireGuard-Tunnel und leitet eingehende Nachrichten an einen von beiden weiter
Vorteile einer selbst geschriebenen Event Loop
- Sans-IO-Code drückt nur Systemzustand aus und erzeugt keine Seiteneffekte; daher muss die Event Loop den Zustand abfragen, ausführen und neue Eingaben weiterreichen
- Diese Struktur kann wie Boilerplate wirken, ermöglicht aber feingranulare Kontrolle, weil man die Event Loop selbst schreiben kann
- Anwendungen können Ausführungsweisen wie die folgenden selbst wählen
- Reduzierung der Systemaufrufe beim Senden von Paketen mit
sendmmsg - Multiplexing mehrerer Protokolle über einen einzigen Socket
- Reduzierung der Systemaufrufe beim Senden von Paketen mit
- Bibliotheksautoren können sich auf die Implementierung von Protokollfunktionen konzentrieren, statt auf Debatten über async Runtimes oder APIs für Socket-Optionen
str0mbetrachtet die Aufzählung von Netzwerkschnittstellen als IO-Anliegen und überlässt sie der Anwendung- Stattdessen stellt es nur eine API bereit, um Socket-Adressen als ICE Candidates in den aktuellen Zustand einzufügen
- Firezone nutzt diese Struktur, um TURN Candidates bereits vor dem Aufbau einer Verbindung vorab zu sammeln und so die Verzögerung beim Verbindungsaufbau zu reduzieren
- Bei ICE sammeln beide Seiten Candidates, also Sockets, und testen anschließend die Konnektivität zwischen ihnen
Schnelle Tests und Prüfung von Edge Cases
- Sans-IO-Code ist von Natur aus frei von Seiteneffekten und eignet sich gut für Unit Tests
- Da Sockets und Zeit abstrahiert sind, müssen Tests keine echten Ports öffnen oder tatsächlich warten
- Um Verhalten nach 5 Minuten zu testen, übergibt man der Funktion einen veränderten
Instantund prüft die Zustandsänderung - Firezone stellt ein echtes Beispiel bereit, das testet, ob
snowneteine idle Connection nach 5 Minuten schließt - Auch Datenübertragung läuft ohne echte Sockets: Man nimmt ein
Transmitder einen Seite und übergibt es anhandle_inputder Zustandsmaschine auf der anderen Seite - Firezone implementiert eine Referenz-Zustandsmaschine, die beschreibt, wie
connlibsich verhalten soll - Diese Referenz-Zustandsmaschine dient als Grundlage der Tests
- Mit State Machine Testing von
proptestwerden in jedem CI-Lauf deterministisch Tausende Szenarien gesampelt und ausgeführt; dabei wird der Referenzzustand mit dem tatsächlichen Zustand vonconnlibverglichen - Ohne IO lassen sich auch folgende Fehler und ungewöhnliche Verhaltensweisen leicht testen
- Pakete gehen verloren, sodass keine Antwort eingeht
- Eine fehlerhafte Antwort wird empfangen
- Die RTT zum Server ist sehr hoch
- Es gibt keine funktionierende IPv6-Schnittstelle
- Es gibt nur IPv6-Schnittstellen
- Wenn Protokollimplementierung und tatsächliche IO-Seiteneffekte getrennt werden, werden Fehlererkennung und -behandlung Teil der Eingabeverarbeitung der Zustandsmaschine
Warum Rust und sans-IO gut zusammenpassen
- Rust erzwingt, dass explizit ist, welche Komponente oder Funktion einen Wert besitzt
- Beim Lesen aus einem
UdpSocketmuss man&mut [u8]als Speicherbereich bereitstellen, in dem die tatsächlichen Bytes landen - Nur der Besitzer eines Werts kann ihn mutable machen oder einer anderen Funktion vorübergehend eine mutable reference darauf übergeben
- Dieses explizite Modell von Ownership und Mutability ist die Grundlage von Rust-Features wie dem Borrow Checker
- Die Zustandsmaschinen-APIs im sans-IO-Design sind allesamt synchrone Funktionen und blockieren weder auf IO noch beim Warten auf Zeit
- Da die Zustandsmaschine nur eine Datenstruktur ist, lässt sich Zustandsänderung gut mit
&mut selfausdrücken, und der Borrow Checker kann zur Soundness des Codes beitragen - Umgekehrt kann sich
&mutin async Rust schwieriger handhaben lassen - Async-Funktionen in Rust werden zu Datenstrukturen kompiliert, die
Futureimplementieren - Um ein
Futurein einer Runtime wietokiozu spawnen, muss diese Datenstruktur'staticsein und kann daher keine Referenzen wie&mutenthalten - Um Zustand außerhalb des
Futurezu verändern, verwendet man üblicherweise eines der folgenden Muster- einen referenzgezählten Pointer und Mutex wie
Arc<Mutex<T>> - ein Actor-Modell, bei dem mehrere Tasks gespawnt und über Channels verbunden werden
- einen referenzgezählten Pointer und Mutex wie
- Beide Ansätze haben Runtime-Overhead
- Locks können Contention erzeugen
- Das Senden von Nachrichten über Channels erfordert Kopien
- Wenn mehrere Tasks innerhalb der Runtime in nichtdeterministischer Reihenfolge ausgeführt werden, kann das zu Race Conditions und Deadlocks führen
- Sans-IO-Protokollcode spawnt keine Tasks, daher reicht für Zustandsänderungen
&mut self - Ohne Tasks oder Threads braucht man keine Synchronisationsprimitive wie
Mutex, und ohne Channels muss auch weniger kopiert werden - Firezone ist der Ansicht, dass der Code nach der Umstellung auf sans-IO leichter verständlich wurde, weil weniger nachverfolgt werden muss, wo sich die andere Seite eines Channels befindet, ob ein Channel geschlossen ist oder welcher Code ein
Mutexlockt
Nachteile und Einsatzbereich
- Sans-IO ist keine Universallösung
- Eine selbst geschriebene Event Loop gibt starke Kontrolle, kann anfangs aber subtile Bugs hervorbringen, die schwer zu finden sind
- Wenn der Rückgabewert von
poll_timeouteiner Zustandsmaschine sich zum Beispiel nicht vorwärtsbewegt, kann die Event Loop in eine Busy Loop geraten - Sequentielle Workflows erfordern mehr Code
- Async-Funktionen in Rust werden zu Zustandsmaschinen kompiliert, bei denen jeder
.await-Punkt ein Übergang in einen anderen Zustand ist; dadurch können Entwickler non-blocking IO und sequentiellen Code leicht zusammen schreiben - Bei sans-IO muss man diese Schritte selbst als Zustandsmaschine modellieren
- Request-Response-Protokolle wie
StunBindingsind nicht schwer, aber das Ausdrücken größerer sequentieller Workflows kann mühsam werden - In der Rust-Community ist sans-IO-Design noch nicht weit verbreitet
- Die meisten Bibliotheken implementieren statt sans-IO entweder blocking IO oder non-blocking IO
boringtunruft internInstant::nowauf und hat dadurch einige nicht rein funktionale Stellen; dazu gibt es ein Issue unter cloudflare/boringtun#391
Fazit
- Sans-IO-Code wirkt zunächst ungewohnt, passt aber nach der Eingewöhnung gut zu Rusts Werkzeugen für die Modellierung von Zustandsmaschinen
- Die Struktur erzwingt, Fehler wie andere Eingaben zu behandeln, und passt daher gut zur Art, Networking-Code zu schreiben
- Es gibt auch andere Möglichkeiten, async Rust zu schreiben; Structured Concurrency liegt zwischen sans-IO und dem hier behandelten async-Rust-Ansatz
- Zu Structured Concurrency siehe withoutboats’ Let futures be futures
1 Kommentare
Hacker-News-Kommentare
Das wird zwar als Innovation und Fortschritt verpackt, aber tatsächlich hat man Asynchronität in
$lang, einschließlich Rust, vor der Einführung von async/await-Sprachunterstützung genau so behandelt.Der größte Produktivitätsschub bei der Entwicklung von Embedded-Firmware in Rust kam, als man aufhören konnte, zwischen I/O-Operationen jedes Mal Zustandsmaschinen von Hand zu implementieren und lokale Variablen in eigenen Zustand zu verschieben, und Rust das stattdessen mit der async/await-Syntax erledigen ließ.
async in Rust läuft letztlich auf eine automatische Zustandsmaschine hinaus, die Werte zwischen I/O-(
await-)Punkten speichert.Aber das ist nicht immer der Fall. Bei paketorientierten Anwendungsfällen wie QUIC, WebRTC oder IP ist das eigentliche I/O einfach: Man sendet und empfängt einzelne Pakete/Datagramme.
Es gibt nicht viele
.await-Punkte, also auch nicht viel, was der Compiler generieren müsste. Gleichzeitig müssen mehrere Aspekte parallel laufen und jeweils in eigene Futures/Tasks, wodurch die Zustandsverwaltung über diese Futures hinweg leicht zu Spaghetti-Code wird.Dadurch gibt es weniger Annahmen über die Laufzeitumgebung, was sie leichter testbar und leichter kombinierbar macht.
Theoretisch kann man dasselbe auch so erreichen, dass async/await die Zustandsmaschine erzeugt, praktisch ist das aber ziemlich mühsam, und der meiste async/await-Code ist nicht rein.
Experimentelle Sprachen wie Eff, Koka und Frank unterstützen diese Art zu programmieren sehr gut. Auch den I/O-Diskussionen in Haskell liegt eine tiefe Investition in Techniken wie Free Monads und deren Varianten zugrunde.
In letzter Zeit ist Unison eine interessante Sprache: Sie erkundet mehrere neue Konzepte, stellt aber im Kern ein erweiterbares Effektsystem bereit und unterstützt diese Art des Codings damit gut auf Sprachebene.
.awaitschreiben muss. Es wäre schön, wenn.awaitstandardmäßig ausgeführt würde und man das nur mit spezieller Syntax umkehren könnte.Trotzdem sieht man in der Praxis viel zu häufig, dass Protokollbibliotheken direkt I/O machen :-(
Ich habe diesen Problembereich schon länger im Kopf hin und her gewälzt, und dieser Ansatz passt sehr gut zu der Richtung, an die ich dachte. Allerdings gibt es, wie in Fußnote 3 des Artikels, noch Dinge, an denen man arbeiten muss.
Der Auslöser für diese Überlegung waren die Diskussion über Funktionsfarben und ein Zufallsfund. Beim Bau einer VT100-Bibliothek waren Unit-Tests extrem schwierig, weil ich im Grunde
parser::new(stdin())gemacht hatte. Beim dritten oder vierten Rewrite änderte ich den Parser eher gedankenlos zuparser::push(data), und da wurde mir klar, dass Rust ein Enterprise-OOP-Anti-Pattern bestrafte, das ich „Kapselungsbesessenheit“ nenne.Inzwischen sehe ich dieses Muster und den Schaden, den es anrichtet, nicht nur bei I/O, sondern überall.
Ironischerweise lernt man die Lösung schon in der voruniversitären Ausbildung und in den ersten Uni-Semestern. Die einfachste Beschreibung eines Computers ist: eine Maschine, die Eingaben entgegennimmt, Daten verarbeitet/transformiert und Ausgaben erzeugt. Der Bezug zur Diskussion über Funktionsfarben besteht darin, dass nur Eingaben und Ausgaben eine Farbe haben müssen; die Kernlogik ist meist Datentransformation.
Das ist offensichtlich, aber wenn man sich das Ausmaß der „Debatte“ um Funktionsfarben ansieht, sind offenbar viele Menschen darauf konditioniert, Probleme zuerst mit Kapselung zu lösen, und übersehen oder vergessen diese Tatsache. Leute aus der funktionalen Ecke dürften sich an dieser Stelle ziemlich bestätigt fühlen.
Rust war für mich weniger eine Lernreise als eine Reise des Verlernens und Neulernens. Es ist ein gutes Pattern, und ich habe vor, es künftig zu übernehmen.
Edit: zugehöriger Code: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
parser::push(data)umgestellt hast und dass Rust dieses Enterprise-OOP-Anti-Pattern bestraft hat?Als ziemlicher Rust-Anfänger sehe ich nicht klar, was das offensichtliche Problem dieses Patterns ist und wie Rust es bestraft.
Überall gab es Typparameter und Traits, und ich habe Structs als klassenähnliche Konstrukte missbraucht, die Funktionalität bereitstellen.
Rust passt besser, wenn man Typparameter und selbst definierte Traits nach Möglichkeit vermeidet.
Kapselung im Sinne der Wahrung von Invarianten ist gut. Ich muss an diesen Artikel „parse, don’t validate“ denken: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
Wie verhält sich dieses Design im Vergleich zu einem Ansatz, bei dem man Channels verwendet, um Daten an dedizierte Handler zu senden? Bei Channels gab es mehrere Probleme
(1) Es entsteht häufig spinnennetzartiger Code, dem man nur schwer folgen kann
(2) Man muss Nachrichtentypen selbst implementieren, die sich in über das Netzwerk sendbare Nachrichten umwandeln lassen
(3) Man muss Sender explizit an Entitäten weiterreichen, die interessiert oder berechtigt sind
(4) Man kann zwar feststellen, ob das Senden einer Channel-Nachricht fehlgeschlagen ist, aber nicht, ob diese Nachricht beim Netzwerkversand fehlgeschlagen ist
Trotzdem ist es ziemlich praktisch. Wenn es zum Beispiel einen
ws_handler-Channel gibt, muss man die Daten nur dorthin schicken, und ein dedizierter Handler irgendwo kann die Nachricht dann gegebenenfalls versendensans-IO kann man auch in Anwendungen verwenden, aber besonders nützlich fühlt es sich für Bibliotheken an. In Bibliotheken ist es deutlich wertvoller, weil man den Nutzern keine bestimmte I/O-Methode aufzwingt
In Rust gibt es bereits eine Ökosystemspaltung zwischen synchronem I/O und asynchronem I/O, dazu kommen noch verschiedene Async-Runtimes; deshalb ist dieser Punkt wichtig
Allerdings bringen sie, wie gesagt, Probleme mit sich. Zum Beispiel können Actors/Channels getrennt werden. Außerdem braucht man für Backpressure zwingend begrenzte Channels. Und weil Kopien nötig sind, kann es schwierig sein, hohen Durchsatz zu erreichen
Ebenfalls sehenswert: Monaden, insbesondere Free(r)-Monaden und Effekt-Systeme[0]
Die Idee, Logik und Ausführung zu trennen, ist im Haskell-Ökosystem bereits ein großes, ausführlich behandeltes Thema
Edit: Es wurde nicht erwähnt, wie die
tokio::select!-Aufrufe gekapselt wurden, die bei zeitbezogener Verarbeitung nötig werden. Trägt man einetokio::Runtimemit sich herum, um den Loop-Code async zu machen, ohne dass der äußere Code async sein muss?Edit 2: Vielleicht sollte auch gar nicht gezeigt werden, dass die gekapselte Bibliothek das so macht, sondern dass die äußere Anwendung die Bindings in einem Async-Kontext verwenden kann
Mich hätte eher interessiert, wie man im sans-IO-Stil eine gekapselte Funktion implementieren kann, die auf irgendeine Aktion oder einen Timer warten muss. Oder ist die erwartete Antwort Busy Waiting, beziehungsweise im Grunde eine mitgeführte eigene Async-Runtime-Instanz, die Busy Waiting durch etwas wie
block_in_placeersetzt?[0]: https://okmij.org/ftp/Computation/free-monad.html
StunBinding-Struct. Sie repräsentiert die Funktionalität eines STUN-Bindings. Es ist keine einzelne Funktion, die man einfach aufrufen kann, sondern benötigt eine Event LoopDer Kernpunkt ist, dass
StunBindinginnerhalb einer Bibliothek liegen kann und die Anwendungsseite es in die Zustandsmaschine ihres Programms einbauen kann. Natürlich unter der Voraussetzung, dass auch die Anwendung im sans-IO-Stil strukturiert istDie verlinkte Bibliothek
snownetmacht genau das. Die Domäne besteht darin, ICE + WireGuard ohne I/O zu kombinieren, und sie wird von der Bibliothekconnlibgenutzt, die darüber ACLs zusammensetztEdit: Es gibt kein Busy Waiting. Stattdessen hat
StunBindingeine Funktion, die überpoll_timeoutoffenlegt, worauf sie wartet. Wie der Aufrufer, also die Event Loop, das realisiert, bleibt dem Aufrufer überlassen. Wennhandle_timeoutmit dem entsprechendenInstantaufgerufen wird, passiert die passende AktionOh, thomaseizinger!
Ich habe schon einmal in rust-libp2p hineingeschaut, deshalb kam mir dieses Muster beim Lesen des Artikels sehr vertraut vor; offenbar war das kein Zufall
Firezone sieht cool aus. Verbindet alles!
Stimmt, es gibt Ähnlichkeiten mit rust-libp2p. Dort stecken die tatsächlichen Streams und Verbindungen allerdings weiterhin in einer
Future-ähnlichen Struktur, dadurch ist es stärker verflochten und nicht so strikt getrennt wie bei sans-IO hierEs gibt die Stelle, dass sequenzielle Workflows mehr Code erfordern. In Rust werden async-Funktionen zu Zustandsmaschinen kompiliert, und jeder
.await-Punkt steht für einen Übergang in einen anderen Zustand. Dadurch ist es für Entwickler leicht, sequenziellen Code mit nicht-blockierendem I/O zu kombinieren. Ohne async muss man die Zustandsmaschine selbst schreiben, um mehrere Schritte auszudrückenHat schon jemand async und sans-IO kombiniert? Zumindest konzeptionell müsste man eine async-Funktion schreiben können, die einen sans-IO-bewussten Helper awaited; das Ganze würde dann zu einer Zustandsmaschine innerhalb einer Struct mit guter sans-IO-Schnittstelle kompiliert, die sich auch aus nicht-async Code leicht aufrufen lässt
Ich habe es selbst noch nicht ausprobiert, aber die zu erwartenden Hauptprobleme dürften brauchbare Ergonomie und der Umgang mit
PinseinRust hat Generatoren/Coroutines, die den genannten Anwendungsfall bis zu einem gewissen Grad abdecken können, aber derzeit ist das ein sehr instabiles Feature
Leider haben Coroutines in ihrer aktuellen Form die lästige Einschränkung, dass sie nur über das Trait
std::ops::Coroutineexponiert werden. Deshalb kann man die vom Compiler erzeugte interne Zustandsmaschine nicht direkt allokieren. Und das, obwohl die Größe der Zustandsmaschine äußerlich betrachtet eine Compile-Time-Konstante istBei einer einzelnen Coroutine, die nur innerhalb einer Funktion mit definierten Lebensdauern bleibt, ist das kein Problem. Der Compiler kann das erkennen und die Zustandsmaschine auf dem Stack allokieren
Die nützlichste Anwendung von Coroutines sind aber wohl Queue-Elemente einer Event-Loop-Vorrichtung. Diese Implementierung ist unmöglich, ohne die Coroutines zu boxen.
Vec>ist keine cachefreundliche Datenstruktur, und wenn man bei extrem hochgradig nebenläufigem I/O eine Million Elemente in einemVecbraucht, wird man den Schmerz spürenWenn Rust irgendwann eine native Generator-Syntax bekommt, könnte das möglich werden. Dann könnte man im Kontext einer async-Aufgabe bleiben und
yield transmitsagen, um Daten zu „schreiben“. Das heißt, jedessocket.writewürde durchyield transmitersetztBeim Lesen von Daten würde der Generator pausieren (
.await) und darauf warten, mit den eingehenden Daten fortgesetzt zu werden. Ich weiß nicht, ob es auf nightly so eine Syntax gibt, aber ungefähr müsste es so aussehen:// Made up
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
Weiter oben im Stack passen die beiden ziemlich gut zusammen. Nicht blockierendes I/O, also async, macht es leicht, gleichzeitig auf Socket-I/O und Zeit zu warten. Mit blockierendem I/O geht das zwar auch, wenn man für den Socket ein Lese-Timeout setzt, aber mit async-Primitiven ist es etwas einfacher.
Ich habe selbst immer wieder darüber nachgedacht, wie man beides kombinieren könnte. Eines der Probleme, auf die ich gestoßen bin, ist, dass async-Funktionen zu opaken Typen kompiliert werden. Dadurch ist es schwierig oder unmöglich, die vom Compiler generierte Zustandsmaschine zu nutzen, denn nach ihrer Erzeugung kann man nicht mit ihr interagieren. In gewisser Weise bringt das auch den Borrow Checker durcheinander.
Angenommen zum Beispiel, es gibt eine async-Operation mit mehreren Schritten, also mehreren
await-Punkten, und nur ein Abschnitt davon braucht eine veränderbare Referenz auf eine gemeinsame Datenstruktur. Sobald man das alsasync-Funktion ausdrückt, wird die veränderbare Referenz in dem erzeugtenFuture-Typ gecaptured, und dieser Typ existiert über alle Schritte hinweg. Daher erlaubt Rust nicht, mehr als eine solche Operation gleichzeitig auszuführen.Der übliche Rat in solchen Situationen lautet: „Capture die veränderbare Referenz so kurz wie möglich“, aber bei async geht das nicht. Die async-Funktion in mehrere Funktionen aufzuteilen wird ebenfalls unsauber und untergräbt bis zu einem gewissen Grad den ursprünglichen Zweck, alles als eine einzige Funktion ausdrücken zu wollen.
Vor einiger Zeit habe ich versucht, das HTTP/1.1-Protokoll als Sans-IO-Zustandsmaschine mit
.await-Punkten für I/O zu kodieren, bin damit aber nicht weit gekommen. Dieses I/O registrierte jedoch keinen Waker bei einer async-Runtime, sondern gab die Kontrolle an den Benutzer zurück, damit dieser das I/O selbst ausführt. Man kann sich vorstellen, dass.awaitnicht nach „unten“, sondern nach „oben“ auflöst.Im HTTP/1.1-Kontext wurde async-Code zu einer Art Blaupause dafür, wie der Benutzer das Aufrufverhalten haben möchte. Damals wollte ich unbedingt, dass es in
no_std- und allocatorlosen Umgebungen funktioniert, und habe aufgegeben, weil ich keinen Weg fand, dynamischen Dispatch überBox— also den Teil, der einen Allocator braucht — zu vermeiden.https://news.ycombinator.com/item?id=40879547
Es gibt ein Beispiel in der Form
async fn stun. Der vollständig lauffähige Code ist hier: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...Gut gemacht! Wenn man den Zustand offenlegt, kann man jede async-Funktion rein machen. Der Benutzer muss die Zustandsmaschine nur in den nächsten Zustand schieben.
Ich habe früher versucht, OpenSSL an async Rust zu binden, und auch diese async API folgt einem ähnlichen Design.
Meinst du mit ähnlich, dass die Arbeit, die als Job geplant wird, selbst unabhängig davon ist, wie sie ausgeführt wird?
Wenn man sich dieses Beispiel [0] ansieht, wirkt diese async API viel eher wie Rusts Future.
Innerhalb eines Jobs kann man auf einen „wait context“ zugreifen, unter bestimmten Bedingungen anhalten und ein Aufwecken auslösen, damit die Ausführung fortgesetzt wird.
[0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
Das ist einfach gewöhnliches asynchrones I/O, das Callbacks statt Coroutinen verwendet.
Nachdem ich den Artikel und einige Kommentare gelesen habe, klingt das, als würde man hexagonale Architektur oder den Ports-und-Adapter-Architekturstil neu erfinden.
Ich bin mir nicht sicher, was man daraus mitnehmen soll. Alles, worüber hier gesprochen wird, ist bereits grundlegende Netzwerkprogrammierung.
Es wirkt so, als konzentriere man sich auf höher angesiedelte Klempnerarbeit und verliere sich zu sehr im Zustandsmanagement; das ist aber eine Geschmacksfrage und hat nichts mit Networking zu tun.
Das Interessanteste, was ich aus dem Artikel gelernt habe, ist, dass Cloudflare einen öffentlichen STUN-Server betreibt. Aber auch das hilft nicht besonders. Die „gute“ und „nützliche“ Version des STUN-Protokolls war nämlich die erste, die die Funktion
change requestsunterstützte, mit der sich NAT-Enumeration durchführen ließ. In späteren STUN-Versionen wurde diese Funktion dank der „hilfreichen Vorschläge“ der Cisco-Ingenieure, die an der Spezifikation mitarbeiteten, entfernt.Wenn man in Rust derzeit zum Beispiel eine WebRTC-Bibliothek implementiert, die die Tokio-async-Runtime verwendet, ist sie für Leute, die synchrones I/O nutzen, eine andere Runtime wie smol oder async-std verwenden oder iouring direkt einsetzen, sehr umständlich zu benutzen.
Mit diesem Ansatz zwingt man den Nutzern keine I/O-Wahl auf und kann die Bibliothek dadurch für mehr Menschen nützlich machen.