- Der Web-Streams-Standard wurde für konsistentes Daten-Streaming zwischen Browser und Server entwickelt, leidet derzeit jedoch unter Komplexität und Leistungsgrenzen, die die Developer Experience verschlechtern
- Die bestehende API verursacht durch Designbeschränkungen wie Lock-Verwaltung, BYOB und Backpressure unnötige Last sowohl bei der Nutzung als auch bei der Implementierung
- Cloudflare schlägt ein neues Stream-Modell auf Basis von asynchroner Iteration (async iteration) vor, das eine 2- bis bis zu 120-mal höhere Performance zeigt
- Die neue API steigert Effizienz und Konsistenz durch eine einfache async-iterable-Struktur, explizite Backpressure-Richtlinien und Unterstützung für synchrone/asynchrone Parallelität
- Dieser Ansatz könnte ein einheitliches Streaming-Modell für Node.js, Deno, Bun, Browser und andere Laufzeiten ermöglichen und als Ausgangspunkt für künftige Standardisierungsdiskussionen dienen
Strukturelle Grenzen von Web Streams
- Der WHATWG-Streams-Standard wurde 2014–2016 entwickelt und browserzentriert entworfen; da es damals noch keine async iteration gab, wurde ein separates Reader-/Writer-Modell eingeführt
- Dadurch entstanden unnötige Abläufe wie Lock-Verwaltung, komplexe Lese-Schleifen und BYOB-Pufferbehandlung
- Das Locking-Modell belegt Streams exklusiv und verhindert parallelen Konsum; wird
releaseLock() vergessen, kann der Stream dauerhaft gesperrt bleiben
- Die Funktion BYOB (Bring Your Own Buffer) zielte auf Speicherwiederverwendung, ist wegen eines komplexen Modells zur Trennung und Übertragung von Puffern in der Praxis jedoch wenig genutzt und schwer zu implementieren
- Backpressure wird theoretisch unterstützt, ist aber strukturell nicht real steuerbar, da etwa
enqueue() selbst bei negativem desiredSize erfolgreich ist
- Bei jedem
read()-Aufruf wird die Erstellung eines Promise erzwungen, was bei hochfrequentem Streaming Leistungseinbußen und GC-Last verursacht
Probleme aus der Praxis
- Wenn der Response-Body von
fetch() nicht konsumiert wird, kann es zur Erschöpfung des Connection-Pools kommen; bei Verwendung von tee() entsteht unbegrenztes Memory-Buffering
TransformStream verarbeitet sofort und unabhängig davon, ob gelesen werden kann, was bei langsamen Konsumenten zu einer explodierenden Puffergröße führt
- Beim Server-Side Rendering (SSR) verursacht die Verarbeitung tausender kleiner Chunks GC-Thrashing, wodurch die Performance stark einbricht
- Einzelne Laufzeiten (Node.js, Deno, Bun, Workers) haben zur Entschärfung nicht standardisierte Optimierungspfade eingeführt, was wiederum Kompatibilität und Konsistenz verschlechtert
- Die Web Platform Tests verlangen mehr als 70 komplexe Testdateien – ein Ergebnis von übermäßigem internem Zustandsmanagement und unintuitivem Verhalten
Designprinzipien der neuen Streams-API
- Streams werden als einfache async iterables definiert und können direkt mit
for await...of konsumiert werden
- Es wird eine Pull-through-Transformation verwendet, bei der Verarbeitung nur dann stattfindet, wenn der Konsument Daten anfordert
- Explizite Backpressure-Richtlinien (
strict, block, drop-oldest, drop-newest) verhindern außer Kontrolle geratenen Speicherverbrauch
- Daten werden als Batch-Chunks (
Uint8Array[]) übergeben, um die Kosten der Promise-Erzeugung zu senken
- Die API wird auf ausschließliche Byte-Verarbeitung vereinfacht; BYOB und komplexe Controller-Konzepte entfallen
- Durch Unterstützung synchroner Pfade wird bei CPU-zentrierten Workloads der Promise-Overhead entfernt
Beispiele und Eigenschaften der neuen API
- Mit
Stream.push() lässt sich einfach ein Writer-/Readable-Paar erzeugen, und mit Stream.text() kann der gesamte Text gesammelt werden
Stream.pull() baut eine lazy Pipeline auf, die nur beim Konsum ausgeführt wird
Stream.share() und Stream.broadcast() unterstützen explizites Management mehrerer Konsumenten
- Eine kombinierte Sync/Async-API (
Stream.pullSync(), Stream.textSync()) maximiert die Performance bei Operationen ohne I/O
- Für die Interoperabilität mit Web Streams ist eine Konvertierung über einfache Adapter-Funktionen möglich
Performance-Vergleich und Ausblick
- Benchmarks unter Node.js zeigen bis zu 80- bis 90-mal, im Browser über 100-mal schnellere Verarbeitung
- Beispiel: 275GB/s gegenüber 3GB/s in einer dreistufigen Transformationskette
- Die Leistungssteigerung beruht auf dem Wegfall asynchronen Overheads, Batch-Verarbeitung und einem Pull-basierten Design
- Die Implementierung wurde vollständig in reinem TypeScript/JavaScript geschrieben; bei einer nativen Implementierung sind weitere Verbesserungen möglich
- Cloudflare präsentiert diesen Ansatz als Ausgangspunkt für die Standardisierungsdiskussion und bittet die Entwickler-Community um Feedback
Fazit
- Web Streams waren unter den damaligen Einschränkungen sinnvoll, passen jedoch nicht zu den Sprachfunktionen und Entwicklungsmustern des modernen JavaScript
- Das neue Modell auf Basis von async iterables vereint Einfachheit, Performance und explizite Kontrolle und zeigt das Potenzial für ein konsistentes Streaming-Ökosystem über Laufzeiten hinweg
- Cloudflare veröffentlicht auf GitHub unter jasnell/new-streams Referenzimplementierung, Dokumentation und Beispielcode
- Ziel ist nicht die sofortige Einführung eines neuen Standards, sondern ein praktischer Ausgangspunkt für die Diskussion über eine „bessere Streams-API“
1 Kommentare
Hacker-News-Kommentare
Ich habe selbst ein besseres Stream-Interface entworfen als die in diesem Artikel vorgeschlagene API
Der bestehende Vorschlag hat die Form
async iterator of UInt8Array, ich schlage dagegen eine Struktur vor, bei dernext()sowohl synchrone als auch asynchrone Ergebnisse zurückgeben kannDadurch
lässt sich die Iteration im Vergleich zur bestehenden Struktur einfacher mit einem einzigen Iterator durchführen
wenn man auf synchrone Eingaben synchrone Transformationen anwendet, kann die gesamte Verarbeitung synchron erfolgen, was Codeduplizierung reduziert
es werden weniger unnötige Promises erzeugt, was die Performance verbessert
Concurrency-Kontrolle wird möglich, wodurch die Grenzen von Async-Iteratoren überwunden werden können
Mit deinem Ansatz lässt sich ihre Struktur nicht einfach bauen, umgekehrt aber schon
Ein I/O-zentrierter Iterator muss Chunks in Einheiten von T zurückgeben, um Pufferverschwendung zu vermeiden
Der Grund für die Verwendung von
Uint8Arrayist die Anpassung an Byte-Streams auf OS-EbeneTatsächlich ist eine solche Struktur auch in C-basierten Projekten am effizientesten, daher ist es natürlich, Protokolle mit Typinformationen darauf aufzubauen
In früheren Versionen lag der Unterschied bei bis zu 105-mal
Ich erinnere mich, dass es in Node 16 Optimierungen für async-Verarbeitung gab und damals einige Tests kaputtgingen
Uint8Arrayexistiert nichtUint8Arrayist einfach ein primitiver Typ zur Darstellung eines Byte-Arrays, und Typinformationen sollten auf Anwendungsebene statt auf Protokollebene behandelt werdenSiehe: Clojure-Transducers-Dokumentation
Async Iterable ist ebenfalls keine perfekte Lösung
Promise- und Stack-Switching-Overhead sind hoch, sodass die Performance bei kleinen Dateneinheiten schlecht ist
In Lit-SSR wurde zur Lösung dessen ein Ansatz verwendet, bei dem Thunk-Funktionen in ein synchrones Iterable eingebettet werden
Nur wenn async-Arbeit nötig ist, wird der Thunk aufgerufen und awaited, wodurch sich die SSR-Performance um das 12- bis 18-Fache verbessert hat
Da die Streams API jedoch schwer ein solches fragiles Vertragsmodell übernehmen kann, halte ich eine Struktur wie
write()undwriteAsync()mit optionaler asynchroner Verarbeitung für idealIch habe ein Beispiel mit einem synchronen Generator als GitHub-Code geteilt
Der Kern ist der Teil
step.value.then(value => this.next(value))next(): {done, value: T} | Promise)Seit der Debatte „Do not unleash Zalgo“ von 2013 gibt es die Tendenz, Formen wie
MaybeAsynczu vermeidenIch denke aber, dass diese Angst übertrieben ist und schnelles, flexibles API-Design behindert
Man kann auch Utilities bauen, die mehrere Werte auf einmal holen, und das Geschwindigkeitsproblem von Generatoren ist in der Praxis wohl nicht so groß
In Node.js mit Web Streams zu arbeiten ist schmerzhaft
Sie wurden browserzentriert entworfen und sind in Server-Umgebungen unpraktisch
Selbst für einfache Transformationen muss man einen Transform-Stream umhüllen, und intuitives Chaining wie mit
.pipe()ist schwierigDer Ansatz über Async Iterable ist viel natürlicher und passt gut zu
for-await-ofDie Web-Streams-Spezifikation ist zu abstraktionszentriert und dadurch wenig praxisnah
Ich dachte, das sei einfach nur für Client-Server-Kompatibilität da
Der eigentliche Vorteil ist nicht nur Performance, sondern auch Konsistenz zwischen Umgebungen (convergence)
Wenn
ReadableStreamin Browsern, Workern und anderen Runtimes gleich funktioniert,verbessert das die Portabilität des Codes und reduziert Backpressure-Bugs
Die Standardisierung der Stream-Schicht ist entscheidend für den Aufbau zuverlässiger Streaming-Systeme
Ich habe früher eine Abstraktion namens Repeater gebaut
Das ist das Konzept des Promise-Konstruktors, übertragen auf Async Iterable, mit Steuerung von Events über push/stop
Die Repeater-Bibliothek ist stabil genug, um auf 6,5 Millionen Downloads pro Woche zu kommen
In letzter Zeit bevorzuge ich eher Streams, aber die Kritik rund um
tee()ist weiterhin gültigIch denke, die Richtung Async Iterable als grundlegende Abstraktion ist richtig
stopbei Repeater zugleich als Funktion und als Promise funktioniertNachdem ich den Quellcode gesehen habe,
dachte ich, dass das zwar vom traditionellen Muster abweicht, aber eine bewusste Wahl für ergonomisches Design sein könnte
Ich bin so nostalgisch, dass ich sogar „Up, Up, Down, Down, Left, Right, Left, Right, B, A“ in meine E-Mail-Signatur schreiben würde
Ich habe auch einmal einen Wrapper gebaut, um AsyncIterable kompakter zu verwenden
fluent-async-iterator war nützlich für kleines Daten-Streaming in Lambda- oder CLI-Pipelines
Ich hatte gehofft, dass es inzwischen eine bessere API gäbe
Das Backpressure-Verhalten von
ReadableStream.tee()ist verwirrend, weil es dem vonpipe()in Node.js entgegengesetzt istIn der Spezifikation heißt es, „der langsamste Ausgang sollte das Tempo bestimmen“, aber in der tatsächlichen Implementierung blockiert es, selbst wenn die schnelle Seite nicht konsumiert wird
Ich denke, eine knappe Push-basierte Struktur wie in einer neuen Stream-API wäre besser
Node und Web Streams verwenden unendliche Queues, sodass man synchron massenhaft
res.write()aufrufen kann,aber diese API erzwingt einen yield-Fluss auf Generator-Basis und ist dadurch sicherer
Dass bei der Verwendung von undici(fetch) in Node.js Probleme mit erschöpften Connection-Pools auftreten,
liegt an den Grenzen einer Garbage-Collection-Sprache
Wenn Ressourcen nicht explizit geschlossen werden, kommt es je nach GC-Timing zu Lecks
Der RAII-Ansatz (reference counting) von C++ ist eher sicherer
Was das Freigeben von Ressourcen angeht, hoffe ich, dass sich das Muster
using/await usingweiter verbreitetIch wende gerade eine Struktur mit Unterstützung für dispose/disposeAsync wie bei
usingin C# auf DB-Treiber anBenchmark-Zahlen wie 530GB/s überschreiten die Speicherbandbreite des M1 Pro (200GB/s) und sind daher schwer glaubwürdig
Es ist gut möglich, dass es sich um einen vibe-codierten Benchmark mit unzureichender Qualitätssicherung der Implementierung handelt