- Die Zig-Sprache führt ein neues Modell auf Basis der
Io-Schnittstelle ein, um die Komplexität des bisherigen asynchronen I/O-Designs zu reduzieren - Dieses Modell behält eine einheitliche Funktionsstruktur ohne Unterscheidung zwischen synchronem und asynchronem Code bei und stellt zwei Implementierungen bereit:
Io.ThreadedundIo.Evented Io.Threadedführt standardmäßig synchrone Ausführung aus, währendIo.Eventedeventbasierte asynchrone Ausführung durchführt- Entwickler können mit den Funktionen
async()undconcurrent()die Parallelausführung steuern; ohne Codeänderung ist eine Leistungsoptimierung möglich - Dieser Ansatz adressiert das Problem des Function Coloring und zielt darauf ab, Zigs Einfachheit und Kontrollierbarkeit zu bewahren und zugleich asynchrone Leistung sicherzustellen
Änderungen im asynchronen Design von Zig
- Zig hat nach einer neuen Herangehensweise gesucht, da das frühere asynchrone Design nicht gut zur Minimalismus-Philosophie der Sprache passte
- Das alte Design hatte eine niedrige Integration mit anderen Sprachfeatures
- Das neue Modell kann synchrones und asynchrones I/O mit derselben Code-Struktur handhaben
- Das neue Modell ist auf einem generischen
Io-Interface aufgebaut- Alle I/O-Funktionen führen ihre Ausführung über eine als Parameter übergebene
Io-Instanz aus - Ähnlich wie bei der
Allocator-Schnittstelle ist es möglich, I/O auf ähnliche Weise wie Speicherzuweisung zu steuern
- Alle I/O-Funktionen führen ihre Ausführung über eine als Parameter übergebene
Struktur des Io-Interfaces
- Die Standardbibliothek enthält zwei Standardimplementierungen
Io.Threaded: standardmäßig synchrone Ausführung, optional parallele Verarbeitung mit ThreadsIo.Evented: eventloopbasierte asynchrone Ausführung (unter anderem mitio_uring,kqueue)
- Anwender können eigene neue
Io-Implementierungen schreiben und so die Ausführungsmethode sehr fein steuern
Codebeispiel und Funktionsweise
- Die Beispiel-Funktion
saveFile()erstellt, schreibt und schließt eine Datei- Mit
Io.Threadedläuft sie über normale Systemaufrufe - Mit
Io.Eventedläuft sie über ein asynchrones Backend - In beiden Fällen ist die Fertigstellung beim Aufruf von
writeAll()garantiert
- Mit
- Der gleiche Code arbeitet in synchronen wie asynchronen Umgebungen gleich
- Bibliotheksautor:innen müssen sich nicht um die Ausführungsart kümmern
Parallelausführung mit async() / concurrent()
- Die Funktion
async()fordert eine asynchrone Ausführung an, kann aber beiIo.Threadedsofort ausgeführt werden- In
Io.Eventederfolgt echte asynchrone Ausführung, sodass beide Dateien parallel gespeichert werden können
- In
- Die Funktion
concurrent()wird verwendet, wenn echte Parallelität nötig istIo.Threadednutzt dabei den Thread-PoolIo.Eventedbehandelt sie wieasync()
- Eine falsche Funktionsauswahl (
asyncanstelle vonconcurrent) gilt als Bug; sie kann sprachlich nicht verhindert werden
Code-Stil und Sprachintegration
- Ohne asynchrone Spezialsyntax bleibt der normale Zig-Code-Stil erhalten
- Bestehende Kontrollflusskonstrukte wie
tryunddeferwerden unverändert verwendet - Andrew Kelley sagt, dass es sich wie normaler Zig-Code anfühlt
- Bestehende Kontrollflusskonstrukte wie
- Als Beispiel wird eine asynchrone DNS-Auflösung gezeigt
- Anders als bei
getaddrinfo()wird nur die erste erfolgreiche Antwort zurückgegeben und die restlichen Anfragen werden abgebrochen
- Anders als bei
Ausblick und aktueller Stand
Io.Eventedbefindet sich noch in der experimentellen Phase; einige Betriebssysteme unterstützen es noch nicht- Eine WebAssembly-kompatible
Io-Implementierung ist geplant und erfordert die Entwicklung entsprechender Features - Zu
Iogibt es 24 Folgeaufgaben, von denen die meisten noch offen sind - Zig ist noch vor Version 1.0; asynchrone I/O und native Codegenerierung sind weiterhin die wichtigsten offenen Aufgaben
- Mit diesem Entwurf wird erwartet, dass sich die Notwendigkeit zur Code-Überarbeitung aufgrund von Änderungen an der I/O-Schnittstelle verringert
Zusammenfassung der Community-Diskussion
- Mehrere Kommentare bewerten Zigs Ansatz als einfacher und flexibler als Rusts async/await-Modell
- In Rust ist die Komplexität hoch, wenn mehrere Executor gleichzeitig eingesetzt werden
- Mit dem
Io-Interface eröffnet Zig die Möglichkeit, mehrere Executor parallel zu betreiben
- Einige bemängeln, dass der Code etwas umfangreich werden kann
- Durch das explizite API-Design verbessert sich jedoch die Kontrolle über Sicherheit, Leistung und Tests
- Auch technische Diskussionen über den Unterschied zwischen asynchroner und Thread-Ausführung sowie über stackful vs. stackless Coroutine-Umsetzungen wurden geführt
- Zig
Ioist als Standardbibliotheks-Erweiterung implementiert, ohne Sprach-spezielle Sonderbehandlung- Künftige Erweiterung um stackless coroutine-Funktionen ist vorgesehen
Fazit
- Das neue asynchrone Modell von Zig zielt darauf ab, sprachliche Einfachheit mit leistungsstarker I/O zu verbinden
- Durch die Lösung des Function-Coloring-Problems, die Integration von synchronem und asynchronem Code und eine explizite Kontrollstruktur wird es als Schlüssel-Schritt zur Stabilisierung von Zig 1.0 bewertet
1 Kommentare
Hacker-News-Kommentar
Insgesamt ist der Artikel korrekt und gut recherchiert.
Es gibt nur ein paar kleine Korrekturen.
Bei
Io.Threaded-Instanzen arbeitetasync()tatsächlich nicht asynchron, sondern wird sofort ausgeführt. Allerdings verwendetstd.Io.Threadedstandardmäßig einen Thread-Pool, um asynchrone Aufgaben zu verteilen.Wird es jedoch mit
init_single_threadedinitialisiert, verhält es sich wie im Artikel beschrieben.Außerdem gab es früher eine Funktion namens
asyncConcurrent(), die inzwischen einfach inconcurrent()umbenannt wurdeWenn du künftig Feedback geben möchtest, kannst du eine Mail an lwn@lwn.net schicken.
Danke für die Korrekturvorschläge und für die Arbeit rund um Zig
Mich würde interessieren, welche Bugs entstehen, wenn man versehentlich
asyncConcurrent()dort verwendet, woasync()nötig wäre.Kann das je nach IO-Modell zu UB (undefiniertem Verhalten) führen, oder ist es nur ein logischer Fehler?
concurrent()ist, dass es die Lesbarkeit und Ausdruckskraft des Codes erhöht, weil klar wird: „Dieser Code muss zwingend parallel laufen.“Ich halte dieses Design für ziemlich vernünftig.
Die Erklärung von Zig ist allerdings verwirrend.
Es wird betont, dass das Problem des Function Coloring gelöst sei, tatsächlich wurde IO aber im Wesentlichen nur in einen Effect Type ausgelagert.
Der Aufrufer muss dabei weiterhin ein Token mitführen, also ist es immer noch eine Art Coloring.
Das erscheint mir ähnlich zur Art, wie Go mit Asynchronität umgeht
Zigs früheres Async-Await-Modell hatte das Coloring-Problem bereits gelöst.
Der Compiler erzeugte je nach Aufrufkontext automatisch synchrone/asynchrone Versionen
Zig löst das in der Praxis ausreichend über Dependency Injection.
Die Komplexität von Async-Aufrufen lässt sich nicht vermeiden, aber für präzise Kontrolle ist das wohl unvermeidlich
Man kann eine globale io-Variable deklarieren und sie überall verwenden, auch wenn das beim Schreiben von Bibliotheken natürlich nicht empfohlen wird.
Wenn man den Artikel What color is your function? mit seinen fünf Bedingungen für Function Coloring betrachtet, erfüllt Zigs Ansatz vermutlich einige davon nicht, besonders 4 und 5
So ein Ansatz kann aber Probleme wie Deadlocks verursachen.
Manche Teile von Code sind nicht thread-sicher, daher kann Coloring sogar hilfreich sein
Dieses Design wirkt Scala async sehr ähnlich.
In Scala wird der Execution Context als impliziter Parameter weitergereicht, in Zig explizit.
In der Praxis war das gegenüber der direkten Nutzung von Threads und Queues oft kaum besser, und das Management des Execution Contexts führte zu komplexem und unvorhersehbarem Verhalten.
Das Zig-Team scheint diesen Ansatz für neu zu halten, vermutlich weil es mit Scala weniger Erfahrung hat
Die JVM löst das mit virtuellen Threads, aber Low-Level-Sprachen erreichen diese Effizienz nur schwer.
Deshalb brauchen Sprachen wie Zig andere Lösungen für Skalierbarkeit
Im früheren Async/Await-System von Zig konnte man Funktionen suspendieren und fortsetzen.
Damit wollte ich bei der OS-Entwicklung einmal Frame-Suspend/Resume auf Basis von Geräte-Interrupts umsetzen.
Im neuen io-System muss man das offenbar selbst implementieren, was ich schade finde
@asyncSuspendund@asyncResume.Das neue Io ist eine gemeinsame Abstraktion für synchrones, thread-basiertes und eventbasiertes Arbeiten und enthält daher keinen Suspend-Mechanismus
Wenn man sich den aktuellen Io.Evented-Prototyp ansieht, könnte das auf Basis von stackless coroutines auch von Drittanbieter-Bibliotheken behandelt werden
Im Beispielcode heißt es, dass die Arbeit abgeschlossen sei, wenn
writeAll()zurückkehrt.Da es verschiedene IO-Implementierungen geben kann, müsste die Fertigstellung in Wirklichkeit schon beim Start von defer garantiert sein.
Andernfalls müsste man die Abhängigkeit zwischen
createFileundwriteAllverfolgen.Dann wirkt das letztlich kaum anders als ein blockierender Aufruf.
Außerdem ist unklar, warum dieses Interface IO heißt.
Tatsächlich ist es eher eine Abstraktion für „in einem anderen Kontext ausführen“
Relevante Dokumentation: std.Io
Das folgende Beispiel ist interessant
In Rust oder Python laufen Coroutines nicht weiter, wenn sie nicht awaited werden.
Im Zig-Beispiel wäre
io.async, falls es selbstständig weiterläuft, eher mit Task-Erzeugung vergleichbar.Das ist ein gültiges Design, aber nicht der Weg, den andere Sprachen gewählt haben
async-Funktion läuft im aufrufenden Thread bis zum ersten yield.await(io)ist die Ausführung garantiert.Ob sofort ausgeführt oder in den Thread-Pool eingereiht wird, hängt von der Io-Runtime-Implementierung ab
awaitein.Bei eventbasiertem io können die beiden Aufgaben verschachtelt (interleaved) ablaufen, bei threaded io auch im Hintergrund.
Es gibt also keine „heimlich irgendwo laufenden Tasks“
Als jemand, der täglich Go nutzt, habe ich den Eindruck, dass Zigs Io mehrere Nachteile von Go korrigiert.
Ich frage mich allerdings, ob Zig ein Konzept wie Channels hat.
In Go gibt es zwar das Schlüsselwort select, aber dass es nicht mit Sockets verwendet werden kann, fand ich immer schade
Go-Channels haben einen Overhead von einigen Dutzend Zyklen und sind daher für kleinteiliges IO ineffizient.
Für größere Datenbewegungen oder Many-to-Many-Synchronisation sind sie dagegen nützlich
std.Io.Queueetwas, das den Go-Channels ähnelt.Auch ein select-artiges Konstrukt lässt sich ähnlich umsetzen, ist syntaktisch aber weniger ergonomisch.
Dafür kann es ohne GC mit verschiedenen IO-Runtimes arbeiten
Ich halte Zigs „colorless“-Ansatz für deutlich besser
Goroutines sind nur Green Threads, Channels nur thread-sichere Queues, und Zig bietet beides bereits in der Standardbibliothek
Die asynchrone Io-Version von Zig wirkt dem Ansatz von Go fast völlig gleich.
In Go sind jedoch bei Aufrufen von C-Bibliotheken die Kosten der Stack-Allokation hoch, und direkte Syscalls bringen Probleme bei der Plattformkompatibilität mit sich.
Zig scheint das konfigurierbar gemacht zu haben, sodass sich verschiedene Trade-offs ohne Codeänderungen wählen lassen
Das neue Async-IO ist für einfache Beispiele hervorragend, könnte aber bei IO-Komplexität auf Server-Niveau an Grenzen stoßen.
Ein dazugehöriges Issue habe ich auf GitHub eingestellt
Das Kernproblem ist, dass Sprach- oder Bibliotheksdesigner Mittel bereitstellen müssen, um verschiedene Ausführungskontexte (sync/async) miteinander zu verbinden.
Dazu braucht man einen Ansatz, bei dem Kontexte in FSMs (endliche Zustandsautomaten) gekapselt werden und zwischen beiden Seiten Kommunikationskanäle bereitstehen
Verwandter Artikel: Function colors represent different execution contexts