Neues asynchrones I/O in Zig
(kristoff.it)- Mit der Einführung der neuen asynchronen I/O-Schnittstelle von Zig können Aufrufer die Implementierungsweise von I/O nun selbst auswählen und injizieren
- Die neu entworfene Io-Schnittstelle unterstützt gleichzeitig Asynchronität und Parallelität und legt den Fokus auf Code-Wiederverwendung und Optimierung
- Es sollen verschiedene Implementierungen in der Standardbibliothek bereitgestellt werden, darunter Blocking I/O, Event Loop, Thread-Pool, Green Threads und stacklose Coroutines
- Über die neue API werden Future-Canceling und Ressourcenverwaltung sowie Buffering und granulareres Ein-/Ausgabeverhalten ermöglicht
- Das bisherige Problem des Function Coloring wird gelöst, sodass sich mit einer einzigen Bibliothek sowohl synchroner als auch asynchroner Betrieb optimieren lässt
Überblick
Zig entwickelt sich derzeit in eine Richtung weiter, in der mit dem Entwurf einer neuen asynchronen I/O-Schnittstelle vor allem Flexibilität bei I/O-Operationen und Unterstützung für Parallelität im Mittelpunkt stehen. Diese Änderung trennt sich vom bisherigen async/await-Paradigma, sodass Programmierer in der Praxis deutlich vielfältigere I/O-Strategien einsetzen können.
Die neue I/O-Schnittstelle
Bisher wurden I/O-bezogene Objekte direkt im Code erzeugt und verwendet, nun wird die Io-Schnittstelle vom Aufrufer injiziert.
- Dieser Ansatz ähnelt dem Allocator-Muster: Die aufrufende Seite wählt eine konkrete I/O-Implementierung aus und injiziert sie
- So lässt sich auch in Code externer Pakete eine I/O-Strategie konsistent anwenden
Wesentliche Änderungen
- Die Io-Schnittstelle übernimmt jetzt auch Konkurrenzoperationen (concurrency)
- Wenn Code Konkurrenz korrekt ausdrückt, kann je nach Implementierung von Io auch Parallelität (parallelism) bereitgestellt werden
Codebeispiele
- Es werden zwei Varianten verglichen: Code ohne Konkurrenz (seriell) und Code, in dem mit io.async und await die Möglichkeit zur Parallelisierung ausgedrückt wird
- Serieller Code: Speichert nacheinander in zwei Dateien, ohne Chancen auf Parallelität zu nutzen
- Paralleler Code: Nutzt Futures zum Speichern von Dateien und arbeitet in einer asynchronen Event Loop effizienter
Kombination von await und try
- Bei gemeinsamer Verwendung von await und try besteht das Problem, dass bei einem Fehler in einem Future die Ressourcen eines anderen Future nicht freigegeben werden
- Mit defer und future.cancel lässt sich korrektes Canceling und Aufräumen explizit ausdrücken
Future.cancel API
- Future.cancel() und Future.await() sind idempotent (mehrfache Aufrufe haben keine Nebenwirkungen)
- Wird cancel bei einem bereits abgeschlossenen Future aufgerufen, werden nur Ressourcen freigegeben; noch nicht abgeschlossene Aufgaben geben error.Canceled zurück
I/O-Implementierungen der Standardbibliothek
Die Io-Schnittstelle ist eine auf Runtime-Polymorphie basierende Schnittstelle, die direkt implementiert oder über Implementierungen aus Drittanbieterpaketen genutzt werden kann. Zigs Standardbibliothek soll verschiedene Typen von I/O-Implementierungen bereitstellen.
- Blocking I/O: Verwendet schlicht das bestehende Blocking-I/O im C-Stil, ohne zusätzlichen Overhead
- Thread-Pool: Verteilt Blocking-I/O auf einen OS-Thread-Pool und führt etwas Parallelität ein. Für Dinge wie Netzwerk-Clients sind weitere Optimierungen nötig
- Green Threads: Nutzt asynchrone Systemaufrufe wie Linux
io_uring, um mehrere Green Threads (leichtgewichtige Threads) auf OS-Threads zu verarbeiten. Benötigt Plattformunterstützung (zunächst x86_64 Linux) - Stacklose Coroutines: Coroutines auf Basis einer Zustandsmaschine ohne explizit benötigten Stack. Gedacht für Kompatibilität mit bestimmten Plattformen wie WASM. Erfordert die Wiedereinführung einer Coroutine-Konvention im Zig-Compiler
Designziele
Code-Wiederverwendung
Das größte Problem bei asynchronem I/O ist die Code-Wiederverwendung; in anderen Sprachen existieren Blocking- und Async-Funktionen getrennt, wodurch Code auseinanderfällt. Zigs Ansatz bedeutet:
- Eine einzige Bibliothek unterstützt sowohl synchronen als auch asynchronen Modus effektiv
- async/await beseitigt das Phänomen des „Function Coloring“, und über das Io-System bleibt man auch zur Laufzeit nicht an ein bestimmtes Ausführungsmodell gebunden
Damit wird das Problem des Function Coloring letztlich vollständig gelöst
Optimierung
- Die neue Io-Schnittstelle wird nicht generisch, sondern über virtuelle Aufrufe auf Basis einer vtable implementiert
- Virtuelle Aufrufe reduzieren Code-Bloat, verursachen zur Laufzeit aber einen kleinen Overhead. In optimierten Builds ist bei nur einer Io-Implementierung Devirtualisierung möglich
- Wenn mehrere Io-Implementierungen verwendet werden, bleiben die virtuellen Aufrufe erhalten (um Codeduplikation zu vermeiden)
Buffering-Strategie
- Bisher war Buffering Aufgabe jeder Implementierung (reader/writer), nun erfolgt es auf Ebene der Reader- und Writer-Schnittstellen
- Außer beim Flush des Buffers muss der Pfad nicht über virtuelle Aufrufe gehen, was Optimierungen erleichtert
Semantische I/O-Operationen
Die Writer-Schnittstelle bietet zwei neue Primitive für gezielte Optimierungsoperationen.
- sendFile: Inspiriert von POSIX
sendfile; der Datentransfer zwischen File Descriptors erfolgt im Kernel, wodurch Speicherkopien minimiert werden - drain: Unterstützt vectorized write + splatting. Mehrere Datensegmente können gesammelt übertragen und in einen
writev-Systemaufruf umgewandelt werden. Über den splat-Parameter lässt sich das letzte Element wiederholt verwenden (nützlich z. B. in komprimierten Streams)
Roadmap
Ein Teil dieser Änderungen wird ab Zig 0.15.0 eingeführt, für die vollständige Einführung ist jedoch eine umfassende Umgestaltung der Bibliothek nötig, sodass auf ein späteres Release gewartet werden muss. Wichtige Module wie SSL/TLS sowie HTTP-Server/-Clients sollen ebenfalls auf Basis des neuen Io-Systems neu entworfen werden.
FAQ
F: Zig ist doch eine Low-Level-Sprache – warum ist async wichtig?
- Zig zielt auf Robustheit, Optimierung und Wiederverwendbarkeit
- Durch die Standardisierung von Non-Blocking-I/O können auch andere Bibliotheken und Drittanbieter-Code auf die gesamte I/O-Strategie abgestimmt werden, was Anpassbarkeit und Wiederverwendbarkeit erhöht
F: Müssen Paketautoren async jetzt in ihrem gesamten Code verwenden?
- Nein. Nicht jeder Code muss Konkurrenz ausdrücken
- Auch gewöhnlicher sequenzieller Code funktioniert entsprechend der vom Nutzer gewählten I/O-Strategie
F: Funktioniert jeder beliebige Ausführungsmodus automatisch korrekt, solange man ihn nur einsteckt?
- Meistens ja
- Allerdings führen Programmierfehler im Code (z. B. wenn Anforderungen an gleichzeitige Aufgaben nicht erfüllt werden) dazu, dass es nicht korrekt funktioniert
Anhand von Ausführungsbeispielen wird außerdem auf den Unterschied zwischen Asynchronität und Parallelität sowie auf die Notwendigkeit einer sauberen Gestaltung des Kontrollflusses hingewiesen.
Fazit
Mit der Einführung der neuen Io-Schnittstelle steigert Zig die Flexibilität bei der Wahl von Ein-/Ausgabestrategien, die Code-Wiederverwendbarkeit und das Optimierungspotenzial deutlich. Dadurch können Entwickler Strukturen für Konkurrenz und Parallelität klarer ausdrücken und zugleich effektiv auf verschiedene Plattformen und Ausführungsmodelle reagieren, ohne durch die Einschränkungen synchroner oder asynchroner Funktionsschreibweisen gebunden zu sein.
1 Kommentare
Hacker-News-Kommentare
Ich möchte noch einmal auf diesen Punkt hinweisen. Im Artikel wird sogar gesagt, Zig habe das Problem des Function Coloring vollständig gelöst, aber ich stimme dem nicht zu. Wenn man die fünf Regeln aus dem bekannten Text "What color is your function?" noch einmal betrachtet, gibt es in Zig zwar keine Unterscheidung wie async/sync oder rot/blau, aber letztlich existieren dennoch nur zwei Fälle: IO-Funktionen und Nicht-IO-Funktionen. Technisch wurde das Problem gelöst, dass sich die Art des Funktionsaufrufs je nach Farbe unterscheidet, aber Funktionen, die IO benötigen, müssen weiterhin IO als Argument übergeben bekommen, und Funktionen, die es nicht brauchen, eben nicht. Im Kern fühlt es sich also unverändert an. IO-Funktionen können nur aus IO-Funktionen heraus aufgerufen werden, und auch das entkommt dem Coloring-Problem nicht wirklich. Natürlich kann man auch einen neuen Executor weiterreichen, aber ob man das wirklich will, ist fraglich. In Rust geht Ähnliches ebenfalls. Auch dass farbige Funktionsaufrufe umständlich sind, bleibt gleich. Der Punkt, dass einige zentrale Bibliotheksfunktionen colored sind, trifft weder auf Zig noch auf Rust zu. Der Kern des Coloring-Problems ist, dass Funktionen, die Kontext benötigen, also etwa einen async-Executor, Auth oder einen Allocator, diesen Kontext beim Aufruf zwingend bekommen müssen. Dass Zig genau diesen Punkt wirklich gelöst hat, halte ich für schwer zu behaupten. Allerdings ist Zigs Abstraktion hier sehr gut, während Rust in diesem Bereich Schwächen hat. Das Problem des Function Coloring selbst bleibt aber bestehen
Der zentrale Unterschied zum typischen async Function Coloring ist, dass Zigs
Ionicht einfach ein spezieller Wert für asynchrone Verarbeitung ist, sondern ein notwendiger Wert für jede Form von IO, etwa Datei lesen, schlafen oder Zeit abfragen.Ioist keine Eigenschaft einer Funktion, sondern ein gewöhnlicher Wert, der überall liegen kann. In der Praxis wirkt das Coloring-Problem dadurch tatsächlich gelöst. In den meisten Codebasen existiert IO ohnehin schon irgendwo im Scope, sodass nur wirklich rein berechnende Funktionen kein IO brauchen. Wenn eine Funktion plötzlich IO benötigt, kann sie es in den meisten Fällen direkt ausmy_thing.ioholen und verwenden. Anders als in Rust muss man nicht jeder Funktion einen Allocator mitgeben, daher ist das nicht so lästig. Wenn sich also ein Codepfad ändert und IO nötig wird, muss man die Änderung nicht durch jede Funktion propagieren, sondern kann es sofort verwenden. Grundsätzlich stimme ich zu, dass Function Coloring noch vorhanden ist, aber faktisch sind dadurch praktisch alle Funktionen async-colored, weshalb das praktische Problem fast verschwindet. Tatsächlich sehen Zig-Entwickler das explizite Weiterreichen eines Allocators auch nicht als lästiges Function Coloring an. Ich vermute, dassIogenauso wenig ein großes Problem sein wirdEin wichtiger Punkt scheint mir hier zu fehlen. Wenn man Rust-Bibliotheken nutzt, muss man zwangsläufig Bedingungen wie async/await, tokio oder send+sync erfüllen, und in der Praxis ist eine sync-API in einer async-App oft unbrauchbar. Zigs Art, IO zu übergeben, löst dieses Problem dagegen grundlegend. Dadurch muss man sich nicht mit procedural macros oder erzwungenen Multiversionen abmühen, zumal dieser Ansatz das Problem von Bibliotheks-Multiversionen am Ende ohnehin nicht wirklich gut löst. Es gibt in Rust viele Diskussionen über das Mischen von async und sync, und unter folgendem Link wird das ebenfalls erklärt: https://nullderef.com/blog/rust-async-sync/. Hoffentlich gelingt es Zig künftig auch, cooperative scheduling, performantes async und Thread-per-Core-async gut zu lösen
Ich bin kein Experte für Kategorientheorie, aber wenn man diesen Weg des Kontextmanagements weitergeht, landet man am Ende bei der IO-Monade. Dieser Kontext kann implizit vorhanden sein, aber wenn man wirklich die Hilfe des Compilers bekommen will, muss er im System als konkrete Entität sichtbar werden. Und obwohl die Ambitionen von Systemprogrammiersprachen bisher immer wieder auf dem Friedhof von Async und Coroutines gelandet sind, gibt es Hoffnung für eine neue Generation darin, dass Andrew die IO-Monade gewissermaßen neu entdeckt und sauber umgesetzt hat. Reale Weltfunktionen haben Farben. Entweder man gibt klare Bewegungsregeln vor, oder man landet unweigerlich auf einem immer komplexeren Weg wie bei C++
co_awaitoder tokio. Ich denke, das ist genau ‘The Way’Es gibt einen einfachen Trick, um alle Funktionen rot zu machen, oder blau
Wenn man
ioals globale Variable benutzt, muss man sich keine Gedanken mehr über Coloring machen. Das ist natürlich ein Witz, aber sicher gibt es etwas Reibung dadurch, dass man dasIo-Interface verwenden muss. Dennoch ist das im Kern etwas anderes als die reale Friktion, die bei async/await entsteht. Für mich liegt das Wesen des Function Coloring darin, dass die statische Farbzuweisung durch das async-Schlüsselwort die Wiederverwendung von Code unmöglich macht. In Zig bekommt eine Funktion in beiden Fällen, ob async oder nicht, IO als Argument, deshalb ist Coloring aus dieser Perspektive bedeutungslos. Zweitens zwingt async/await einen dazu, stacklose Coroutines zu verwenden, also Compiler-gesteuerte Stackwechsel, während Zigs neues IO-System intern async nutzen und trotzdem als Blocking IO arbeiten kann. Genau das ist für mich das eigentliche praktische Problem des Function ColoringAuch Go leidet unter einem „subtilen Coloring“-Problem. Wenn man goroutines verwendet, muss man zur Behandlung von Abbrüchen stets einen
context-Parameter weiterreichen, und viele Bibliotheksfunktionen verlangen ebenfallscontext, wodurch der ganze Code davon durchdrungen wird. Technisch könnte man aufcontextverzichten, aber willkürlichcontext.Backgroundzu übergeben, gilt nicht als empfohlene PraxisDas Konzept von sans-io wurde in Rust und anderswo bereits diskutiert; als Referenz dienen https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/ und https://news.ycombinator.com/item?id=40872020
Ich denke, das Problem des Function Coloring besteht darin, dass man am Ende entweder auf dem Stack arbeitet oder den Stack unwindet, und eines von beidem bleibt immer übrig. Zig behauptet, das Coloring-Problem gelöst zu haben, erlaubt aber in der IO-Implementierung weiterhin blocking/thread pool/green thread. Solches Blocking IO war aber von vornherein nicht das eigentliche Problem. Wenn man sich an die Konvention hält, keinen globalen Zustand zu verwenden, ist so etwas in fast jeder Sprache möglich. Stacklose Coroutines sind noch nicht implementiert; es wirkt ein wenig wie „es fehlen nur noch die letzten Teile“. Wenn man wirklich universelle Funktionsaufrufe will, gibt es meiner Meinung nach zwei Wege
Alle Funktionen async machen und über ein Argument steuern, ob sie synchron ausgeführt werden sollen oder nicht (mit Performanceverlust)
Jede Funktion zweimal kompilieren und je nach Situation passend aufrufen (mit größerem Code und Schwierigkeiten bei Function Pointern)
Ich gehöre nicht zum Kernteam, aber soweit ich gehört habe, ist geplant, genau diese Lösung anzuwenden, also echte Coroutines auf Basis von Stack-Jumping einzufügen, sobald Nutzer und praktische Anwender semiblocking-Implementierungen ausreichend ausprobiert haben und die API stabilisiert ist. Der aktuelle Coroutine-State-Machine-Compiler von LLVM hat das Problem, von libc oder malloc abhängig zu sein. Weil Zigs neues
io-Interface userland-async/await unterstützt, wird die Migration einfach und das Debugging angenehm bleiben, selbst wenn später eine saubere Frame-Jumping-Lösung kommt. Falls sich Coroutines als schwierig erweisen, ist dieio-API außerdem so angelegt, dass sie mit kleineren Anpassungen bestehen kann, ohne stacklose Coroutines zu überstürzenValueTask<T>in C#/.NET erfüllt eine ähnliche Rolle. Wenn etwas synchron abgeschlossen wird, entsteht kein Overhead, und nur bei Bedarf wird daraus einTask<T>. Im Code verwendet man gewöhnlich einfachawait, und zur Laufzeit oder beim Kompilieren entscheidet das Runtime-System oder der Compiler selbst, ob synchron oder asynchron gearbeitet wirdIch mag Zig, aber es macht mich etwas skeptisch, dass der Fokus auf green threads, also Fibers bzw. stackful coroutines, liegt. Rust hat vor 1.0 ein ähnliches Runtime-Trait aus Performancegründen verworfen. Tatsächlich haben OS, Sprachen und Bibliotheken die Nachteile dieses Ansatzes schon mehrfach gelernt, und dazu gibt es auch Material: https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. Fibers galten in den 90ern als skalierbare Form der nebenläufigen Verarbeitung, werden heute aber wegen stackloser Coroutines sowie der Fortschritte bei OS und Hardware nicht mehr empfohlen. Wenn es so weitergeht, wird Zig ähnlich wie Go an Performancegrenzen stoßen und schwerlich ein echter Performance-Konkurrent sein. Ich hoffe,
std.fsbleibt für Fälle erhalten, in denen Performance wichtig istDer Eindruck, wir würden bei green threads, also Fibers, „all-in“ gehen, ist ein Missverständnis. Im verlinkten Artikel des OP wird ausdrücklich erwähnt, dass eine Implementierung auf Basis stackloser Coroutines erwartet wird, und es gibt auch einen entsprechenden Vorschlag: https://github.com/ziglang/zig/issues/23446. Performance ist wichtig, und wenn Fibers in der Praxis leistungsmäßig hinter den Erwartungen zurückbleiben, werden sie sich ohnehin nicht allgemein durchsetzen. Nichts von dem, was in diesem Artikel diskutiert wird, verhindert, dass stacklose Coroutines die Standardimplementierung von
IowerdenIch bin skeptisch gegenüber der Behauptung, green threads seien leistungsschwach. Führende Plattformen für hochgradig nebenläufige Server wie Go, Erlang und Java verwenden green threads oder bewegen sich in diese Richtung. Für Sprachen auf niedrigerem Niveau wie Rust könnten green threads wegen der Kompatibilität mit C FFI ungeeignet sein, aber dass Performance an sich immer das Problem sei, lässt sich daraus nicht zwingend ableiten
Da es nur eine von mehreren Optionen ist, würde ich das nicht als „all-in“ bezeichnen. Welche Implementierung gewählt wird, entscheidet die ausführbare Datei, nicht der Bibliothekscode
Zig zielt auf einen ähnlichen Effekt wie Rust mit seiner Entscheidung, green threads zu entfernen und durch ein async-Runtime-Modell zu ersetzen. Der Kern ist die offizielle Intuition „async=IO, IO=async“. Rust bietet ein pluggable async runtime wie tokio, Zig bietet ein pluggable IO runtime. Die Richtung ist letztlich, das Runtime-System aus der Sprache herauszunehmen, es in den User-Space zu verlagern und dabei alle dasselbe gemeinsame Interface nutzen zu lassen
Das Papier (P1364R0) war umstritten, und ich halte es für argumentativ motiviert, um einen bestimmten Ansatz zu verdrängen. Als weiteres Diskussionsmaterial kann man auch https://old.reddit.com/r/cpp/comments/1jwlur9/stackful_coroutines_faster_than_stackless/, https://old.reddit.com/r/programming/comments/dgfxde/fibers_arent_useful_for_much_any_more/f3bmpww/ ansehen
Es wirkt etwas seltsam, dass in einer Systemsprache wie Zig sogar bei alltäglichen Standard-IO-Operationen Runtime-Polymorphismus erzwungen wird. In den meisten realen Fällen kann die IO-Implementierung statisch feststehen, daher frage ich mich, warum man dafür Runtime-Overhead in Kauf nehmen soll
Ich denke, der Overhead durch dynamischen Dispatch ist bei IO in der Praxis fast immer vernachlässigbar. Das hängt zwar vom IO-Ziel ab, aber meistens ist IO ohnehin nicht CPU-limitiert. Daher heißt es ja auch IO-bound
Auf die Frage „Warum allen Runtime-Overhead aufzwingen?“ würde ich sagen, dass der Compiler in Systemen, die ohnehin nur eine Art von
ioverwenden, vermutlich darauf abzielt, die Kosten der doppelten Indirektion wegzuoptimieren. Und IO hat sowieso andere Bottlenecks, deshalb fällt eine zusätzliche Indirektion kaum ins GewichtIn Zig wird traditionell stärker auf Binärgröße geachtet. Beim
Allocatorgibt es denselben Trade-off:ArrayListUnmanagedist zum Beispiel nicht generisch über den Allocator, daher fällt bei jeder Allokation dynamischer Dispatch an. In der Praxis werden diese indirekten Aufrufkosten von Datei-Allokation oder Schreibkosten aber weit übertroffen. Diese Fixierung auf Binärgröße ist sehr Zig-typisch. Nebenbei gesagt ist devirtualization, also die Optimierung dynamischer Aufrufe zu statischen, ein MythosRuntime-Polymorphismus an sich ist nicht grundsätzlich schlecht. Solange es keine Situation wie einen tight loop mit zusätzlichem Branch oder fehlende Inline-Optimierung gibt, ist das kein Problemfall
Es gefällt mir nicht besonders, dass der neue
io-Parameter überall sichtbar wird, aber ich mag sehr, dass sich damit verschiedene Implementierungen, etwa threadbasiert oder fiberbasiert, leicht nutzen lassen und dem Nutzer keine bestimmte Implementierung aufgezwungen wird, ähnlich wie beimAllocator-Interface. Insgesamt ist das eine deutliche Verbesserung, und wenn unter den verschiedenen stdlib-Implementierungen auch eine synchrone/blockierende IO-Implementierung ohne zusätzlichen Overhead bereitsteht, würde das perfekt zur Zig-Philosophie passen, dass man nicht für etwas bezahlt, das man nicht benutztiolästiger zu sein als dort einfach direkt aufzurufen, wo es gebraucht wirdIn Zig drückt
io.asyncAsynchronität aus, also dass die Reihenfolge von Operationen nicht garantiert sein muss, auch wenn das Ergebnis korrekt bleibt, aber nicht Nebenläufigkeit. Genau diese Trennung zwischen der Bedeutung von async und der Bedeutung vonio-Aufrufen ist der Kern. Ich halte dieses Design für sehr cleverMir gefällt, dass sich durch das IO-Interface ein VFS, also Virtual File System, auf Sprachebene bauen lässt
io-Instanz übergibt, die nur Lesezugriff unterhalb eines bestimmten Verzeichnisses erlaubt. Siehe auch https://news.ycombinator.com/item?id=44549430Ich habe zum Lernen von Zig einmal einen einfachen SSH-Server gebaut. Durch diese neue IO-/Event-Loop-Struktur konnte ich den Ablauf des Codes deutlich leichter verstehen. Danke an Andy
ioleichter verständlich zu machenDer Text ist hervorragend geschrieben, und ich fand ihn äußerst interessant. Besonders gespannt bin ich auf die Implikationen für WebAssembly. Dass man WASI auch im Userspace nutzen kann und zusätzlich Bring Your Own IO möglich ist, finde ich wirklich spannend