3 Punkte von GN⁺ 2024-07-05 | 1 Kommentare | Auf WhatsApp teilen
  • Die Rust-Verbindungsbibliothek connlib von 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_timeout und poll_timeout auf
  • 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 Instant und Transmit prü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, boringtun und rustls, 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_transmit und handle_input tauchen auf mehreren Ebenen wiederholt auf
  • 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
    • quinn: unabhängige QUIC-Implementierung
    • quiche: QUIC-Implementierung von Cloudflare
    • str0m: sans-IO-WebRTC-Implementierung

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 .await nutzen 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 connlib nutzt 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 UdpSocket von tokio oder 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::UdpSocket aufgebaut ist, und blocking IO, wenn er auf std::net::UdpSocket aufgebaut ist
  • Sans-IO ruft nicht direkt UdpSocket::send auf, sondern erzeugt eine Abstraktion, die die Sendeabsicht ausdrückt
  • Transmit im Beispiel enthält folgende Informationen
    • Ziel-SocketAddr
    • zu sendendes payload
  • Protokollcode schreibt nicht direkt auf den Socket, sondern gibt ein Transmit aus
  • Der tatsächliche Aufruf von UdpSocket::send oder send_to ist Aufgabe der Event Loop
  • Sans-IO-Code muss von einer Event Loop angetrieben werden, ähnlich wie Rusts Future erst 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 Sent und Received modellieren
  • Der Zustand im Beispiel wird durch folgendes enum ausgedrückt
    • Sent
    • Received { address: SocketAddr }
  • StunBinding besitzt den aktuellen Zustand und eine Warteschlange von Transmits, die auf den Versand warten
  • Die wichtigsten APIs haben klare Rollen
    • handle_input: übergibt der Zustandsmaschine ein Paket, das als Ergebnis von UdpSocket::recv eingegangen ist
    • poll_transmit: die Event Loop holt ein Transmit, das die Zustandsmaschine ausgeben möchte
    • public_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_transmit welche liefert; andernfalls übergibt sie vom Socket gelesene Daten an handle_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 Duration relativ zu einem früheren Zeitpunkt
  • Rusts Instant legt die aktuelle Zeit nicht offen, ermöglicht aber das Messen der Duration zwischen zwei Instants
  • 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 soll
    • handle_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_input nimmt zusammen mit dem Paket den aktuellen Instant entgegen und speichert ihn als State::Received { address, at }
  • Die Event Loop verarbeitet Socket-Empfang und Timer-Ablauf gemeinsam und setzt den Timer anschließend anhand des Ergebnisses von poll_timeout neu

Komposition und API-Flexibilität

  • Die wichtigsten APIs von StunBindinghandle_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 TransactionId oder die Serveradresse genutzt werden
  • Firezones snownet kombiniert ICE und WireGuard und stellt Anwendungen einen IP-Tunnel bereit, der in unterschiedlichen Netzwerkumgebungen funktioniert
  • snownet baut auf der sans-IO-WebRTC-Bibliothek str0m und der nahezu sans-IO gehaltenen WireGuard-Implementierung boringtun auf
  • Firezone benötigt nicht den gesamten WebRTC-Stack, sondern nur den IceAgent, der RFC 8445 implementiert
  • Weil str0m sans-IO nutzt, lässt sich nur der IceAgent leicht übernehmen und mit der Zustandsmaschine des bestehenden Codes komponieren
  • Eine Connection in snownet enthält den IceAgent und 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
  • Bibliotheksautoren können sich auf die Implementierung von Protokollfunktionen konzentrieren, statt auf Debatten über async Runtimes oder APIs für Socket-Optionen
  • str0m betrachtet 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 Instant und prüft die Zustandsänderung
  • Firezone stellt ein echtes Beispiel bereit, das testet, ob snownet eine idle Connection nach 5 Minuten schließt
  • Auch Datenübertragung läuft ohne echte Sockets: Man nimmt ein Transmit der einen Seite und übergibt es an handle_input der Zustandsmaschine auf der anderen Seite
  • Firezone implementiert eine Referenz-Zustandsmaschine, die beschreibt, wie connlib sich verhalten soll
  • Diese Referenz-Zustandsmaschine dient als Grundlage der Tests
  • Mit State Machine Testing von proptest werden in jedem CI-Lauf deterministisch Tausende Szenarien gesampelt und ausgeführt; dabei wird der Referenzzustand mit dem tatsächlichen Zustand von connlib verglichen
  • 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 UdpSocket muss 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 self ausdrücken, und der Borrow Checker kann zur Soundness des Codes beitragen
  • Umgekehrt kann sich &mut in async Rust schwieriger handhaben lassen
  • Async-Funktionen in Rust werden zu Datenstrukturen kompiliert, die Future implementieren
  • Um ein Future in einer Runtime wie tokio zu spawnen, muss diese Datenstruktur 'static sein und kann daher keine Referenzen wie &mut enthalten
  • Um Zustand außerhalb des Future zu 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
  • 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 Mutex lockt

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_timeout einer 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 StunBinding sind 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
  • boringtun ruft intern Instant::now auf 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

 
GN⁺ 2024-07-05
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.

    • Genau diesen Punkt wollte ich am Ende des Artikels ansprechen. Wenn das, was man implementiert, größtenteils aus sequenziellen I/O-Operationen besteht, wird dieses Modell ziemlich schmerzhaft.
      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.
    • Ich glaube, diese Erklärung ist nicht ganz korrekt. Der wichtige Punkt hier ist, dass die Zustandsmaschine selbst kein I/O ausführt. Sie delegiert das immer an die Event-Loop, die sie hostet, und kann dadurch in mehreren Kontexten interpretiert werden.
      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.
    • In den 1980ern hat man I/O in Assembler so gemacht. Wie sonst sollte man eine interruptbasierte YMODEM-Implementierung schreiben?
    • Stimmt. Das Einzige, was mich an async stört, ist, dass man überall .await schreiben muss. Es wäre schön, wenn .await standardmäßig ausgeführt würde und man das nur mit spezieller Syntax umkehren könnte.
    • Es wurde nicht als revolutionär verpackt. Im Artikel steht auch: „Dieses Muster haben wir nicht erfunden! In der Python-Welt gibt es dafür sogar eine eigene Website.“
      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 zu parser::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...

    • Kannst du genauer erklären, was du damit meinst, dass du auf 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.
    • Ich bin ebenfalls aus der OOP-Welt zu Rust gewechselt, und das ist jetzt schon sechs Jahre her. In den ersten zwei bis drei Jahren habe ich furchtbaren Code geschrieben.
      Ü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 versenden

    • Punkt 4 lässt sich implementieren, indem man zusammen mit der Nachricht einen Channel übergibt, über den das Ergebnis zurückgeschickt wird. Wenn man möchte, kann man die sendende Seite sogar bis zur Aufrufstelle blockieren lassen
      sans-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
    • Wenn es in Ordnung ist, die Software in Richtung eines akteurähnlichen Designs zu bringen, funktionieren Channels gut
      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 eine tokio::Runtime mit 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_place ersetzt?
    [0]: https://okmij.org/ftp/Computation/free-monad.html

    • Die „gekapselte Funktion“ ist hier die 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 Loop
      Der Kernpunkt ist, dass StunBinding innerhalb 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 ist
      Die verlinkte Bibliothek snownet macht genau das. Die Domäne besteht darin, ICE + WireGuard ohne I/O zu kombinieren, und sie wird von der Bibliothek connlib genutzt, die darüber ACLs zusammensetzt
      Edit: Es gibt kein Busy Waiting. Stattdessen hat StunBinding eine Funktion, die über poll_timeout offenlegt, worauf sie wartet. Wie der Aufrufer, also die Event Loop, das realisiert, bleibt dem Aufrufer überlassen. Wenn handle_timeout mit dem entsprechenden Instant aufgerufen wird, passiert die passende Aktion
  • Oh, 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!

    • Danke!
      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 hier
  • Es 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ücken
    Hat 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 Pin sein

    • Rust 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::Coroutine exponiert 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 ist
      Bei 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 einem Vec braucht, wird man den Schmerz spüren

    • Wenn 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 transmit sagen, um Daten zu „schreiben“. Das heißt, jedes socket.write würde durch yield transmit ersetzt
      Beim 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 gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // Made up "suspend and resume with argument"-syntax.

let addr = parse_stun_response(res);

addr  
}
  • 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 als async-Funktion ausdrückt, wird die veränderbare Referenz in dem erzeugten Future-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 .await nicht 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 über Box — 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.

    • Bei einer schnellen Suche sehe ich, dass OpenSSL eine „async job“-API hat. Allerdings scheint diese I/O auszuführen. Es heißt sogar, dass das Erstellen eines Jobs sehr teuer ist und man Jobs wiederverwenden sollte.
      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 requests unterstü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.

    • Im Rust-Kontext sehe ich den großen Vorteil darin, dass es das Function-Color-Problem löst. Wie im Artikel beschrieben, werden auch Tests wirklich einfach.
      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.