世界を動かす技術を、日本語で。

JavaScriptのためのより良いストリームAPIが可能です

概要

  • WHATWG Streams Standard(Web streams) は、ストリームデータ処理の共通APIとして設計
  • 現代のJavaScript開発 には合わない設計上の課題とパフォーマンス問題
  • ロックやBYOB、バックプレッシャー など複雑なAPIセマンティクス
  • 新しいアプローチ では、JavaScriptの言語機能を活用し大幅な高速化を実現
  • 今後のストリームAPI設計 への提案と議論のきっかけ

Web Streamsの課題と設計上の限界

  • WHATWG Streams Standard(Web streams) は、ブラウザやサーバー間で共通のストリームAPIを提供するために設計
  • Node.js、Cloudflare Workers、Deno、Bun など主要ランタイムに採用され、fetch()などの基盤APIにも利用
  • 標準APIには 根本的な使いにくさとパフォーマンス問題 が存在
  • 問題の多くは 設計時の決定 に起因し、現代のJavaScriptスタイルと合致しない
  • async iteration(for await...of) 登場前に設計されたため、独自のreader/writerモデルを採用

過剰な儀式的操作

  • ストリームの 基本的な読み取り操作 に多くの手順やロック管理が必要
  • 例:getReader()でロック取得、read()で読み取り、releaseLock()でロック解除
  • これらは 設計上の都合によるAPIの複雑化 であり、本質的な必須要件ではない
  • async iteration の導入で多少簡潔になったが、APIの根本的な複雑さは残存
  • エラーや追加機能利用時に 元の複雑なAPIに戻る必要 があり、開発者の負担増

ロックモデルの問題

  • getReader()呼び出し時にストリームがロック され、他の操作が不可能
  • releaseLock()の呼び出し忘れ でストリームが永久にロックされる例が多発
  • ロック状態や解除タイミングの 仕様や実装差異 によるバグや混乱
  • 実装者側も ロック状態管理やエッジケース対応 で複雑な内部処理が必要

BYOB(Bring Your Own Buffer)の複雑性

  • メモリ再利用最適化 のために導入されたBYOBだが、実際の利用頻度は低い
  • 専用リーダーやバッファ管理、ArrayBufferのデタッチ など、APIが非常に複雑
  • async iterationやTransformStreams と併用不可、実用性に乏しい
  • 多くの開発者が デフォルト読み取りで妥協 し、BYOBの恩恵を受けない
  • 正しいBYOB対応実装は 大規模・エラーが起きやすい ため敬遠されがち

バックプレッシャーの理論と実態

  • バックプレッシャー(Backpressure) は、消費者の速度に応じて生産者の速度を制御する仕組み
  • コントローラーの desiredSize 値でシグナルを送る設計
  • しかし controller.enqueue()は常に成功 し、desiredSizeが負でも止められない
  • 理論上は有効だが、実装や運用面で機能不全 に陥りやすい

まとめと代替アプローチの提案

  • Web streamsは 過去の制約と設計方針 による複雑さとパフォーマンス問題を抱える
  • JavaScriptの最新言語機能(async iteration等) を前提とした新API設計が必要
  • 代替案では 2倍から120倍の高速化 を実現、根本的な設計見直しの効果を実証
  • 今後のストリームAPIは シンプルさ・実用性・パフォーマンス のバランスが重要
  • 現行仕様を否定するのではなく、新しい方向性の議論のきっかけ とする提案

Hackerたちの意見

BYOBリードについての指摘はその通りだね。パフォーマンスやGCのプレッシャーを減らすために重要な機能が、WHATWG標準で正しく実装するのがこんなに難しいなんて、ほんとイライラする。もっとシンプルで使いやすいバッファ管理のアプローチがあれば、JSで高パフォーマンスなデータ処理ツールを作ってる私たちには大助かりなんだけど。

JSで高パフォーマンスなデータ処理ツールを作ってる ちょっと無邪気な質問かもしれないけど、なんでJSで高パフォーマンスなデータツールを作ろうと思うの?JSってそんな用途には向いてない気がするんだけど。

でも、その問題を解決しようとする代わりに、このAPIは「難しすぎて誰も使わないから忘れよう」って感じだよね。今、バイトを操作する必要があるときは、言語をGolangに切り替えてる。GCが簡単な言語で、IOはすべてBYOB APIに基づいてる。インターフェースはこうだよ:Reader { read(b: Uint8Array): [number, Error?] } 自分のUint8Arrayの割り当てを渡して、リーダーは最大でその全体を埋めて、(埋めたバイト数、エラー)を返す。これは完全にプルストリームAPIで、コアには一つのメソッドしかない。APIがそんなにシンプルなのは、常に同期的で、リーダーがバッファにデータを埋めるか、今はデータがないことを示すエラーを返すまでブロックされるから。GoにはバッファリングなしのTeeReaderがあって、これも書き込めるまでブロックされる。https://pkg.go.dev/io#TeeReader JSでは同じAPIを実現できないけど、Goはコルーチン/ゴルーチンのランタイムでどこでもawaitを挿入できるからね。でも、そんなシンプルさとゼロアロケーションのパフォーマンスを夢見たいよね。

実は、この記事が提案してるよりもさらに良いAPIを思いついたんだ!彼らはUInt8Arrayの非同期イテレータを使うことを提案してるけど、悪くはないけど、もう一歩足りない感じ。彼らの提案はこうだね:type Stream = { next(): Promise }> } 俺の提案はこれで、ストリームイテレータって呼んでる!type Stream = { next(): { done, value: T } | Promise } もちろん、俺のバージョンに偏見があるけど、客観的にも俺の方が優れてると思うよ: - 俺のは簡単に彼らのから作れる - 彼らのは概念的に「ストリーム」がイテレータのイテレータで定義されてるから、ループのループが必要になるけど、俺のは一つのイテレータだけで、1つのforループで消費できる。 - 整数のストリームだけに制限されないし、彼らはそう - 俺の方法だと、同期入力に対して同期変換を定義すれば、全体のイテレーションが同期でできるから、同期関数で結果を取得して使える。これは大きいよ、そうじゃないと全てのコードを二回書かなきゃいけないから:一回は同期イテレータとforループで、もう一回は非同期イテレータとfor awaitループで。 - 入力を単語に分ける時にPromiseを無駄にする問題が解消される。非同期イテレータだと、二つの単語を作るには二つのPromiseを作らなきゃいけないけど、ストリームイテレータならデータが利用可能ならPromiseは必要なくて、ただyieldすればいい。 - ストリームイテレータは同時実行性の管理に役立つ。これは非同期イテレータにはできない大きなこと。非同期イテレータはPromiseを見たら常に待つから、同時実行性があればそれが常に排除されるってことと同じだね。

もう一つ面白い結果があるよ:フィードバック問題から解放される。問題を見てみるために、フィードバックのあるストリームを作ってみよう。例えば、材料からマフィンを作る組立ラインがあって、レシピでは3つ目のマフィンは潰して次のマフィンの材料にしなきゃいけないとする。これでうまくいくけど、誰かが最終段階を追加して、マフィンを12個の箱に詰めるようにしたら、ラインが完全に詰まっちゃう!まだフルボックスのマフィンを作ってないから、ラインの最初に使うマフィンが作れないし、3つ目の材料が足りないからフルボックスのマフィンも作れない。アイテムをまとめることが義務付けられているなら、フィードバックがないことを暗黙のうちに仮定しているけど、フィードバックがストリームの一級の能力であってもいいはずだよね。

もちろん、俺のバージョンに偏見があるけど、客観的にも俺の方が優れてると思うよ: > - 俺のは簡単に彼らのから作れる それって、優れてるってことにはならないよね?逆に、彼らのは君のから簡単には作れないし、トリビアルな1バイトのチャンクを返すか、任意のバッファリングをするしかない。だから、彼らの提案は優れたプリミティブなんだ。全体的に見て、I/O指向のイテレータはTのチャンクを返すべきだと思う。そうじゃないと、バッファの膨張が無料でついてくるから。readv/writevが導入されたのには理由があるんだよ。

Uint8Arrayなんて存在しないよ。Uint8Arrayは一連のバイトのためのプリミティブで、ストリームのデータはそれだから。そこに型を追加するのはプロトコルの問題じゃなくて、アプリケーションレベルの問題だよ。

あなたのアイデアは、UInt8Arrayをストリームにフラットにすることですね。論理は理解できるけど、それはひどいアイデアだと思う。* オーバーヘッドが大きすぎる。1KiBが1024個のオブジェクトに変わっちゃうし、ローカリティも最悪。* 生のバイトAPI...ネットワークやファイルシステムなどは、基本的にバイト配列で動作するからね。最も敬意を表して言うけど...このアイデアは、効率的なシステムを最適化することに慣れていない人にしか魅力的に映らないと思う。

より一般的なストリームの概念は面白いと思うけど、彼らの提案は異なる前提に基づいているみたい。彼らはストリームをAsyncIteratorと互換性があるようにしたいみたいで、既存のイテレーターのエコシステムにフィットさせようとしてるんだと思う。そして、Uint8ArrayはOSのストリームと一致させるためにあるんじゃないかな。データの中身を知らずにバイトのバッチを移動させることが多いから。これは完全に新しいストリームの概念として意図されているわけではなく、C/C++や他の言語がJSのために機能を提供できるようにするためのものだと思う。例えば、私の個人的なプロジェクトであるCで書かれたグラフデータベースには、AsyncIteratorストリームに似たオブザーバー/オブザーバブルがあって、Uint8Arrayのバッチを移動させてるんだ(正確にはuint8_t*バッファで、容量やカウントがある)。Cではこれが一番速くて簡単な方法だからね。他のプロトコルが型情報を意識する場合は、ストリームの上に構築されることになると思う。

これはClojureのトランスデューサーの実装に似てるね。「次のものをください。」 – https://clojure.org/reference/transducers

ここ数ヶ月間取り組んでいるEidosという言語では、ストリームもイテレータを使って実現されてるんだ。めっちゃ簡単だよ。で、レイジーなforループもイテレータだし、パイピング構文もある。つまり、こんなことができるってわけ: >> fn double(iter: $iterator) { return *for x in iter { $yield( x * 2 )} } >> fn add_ten(iter: $iterator) { return *for x in iter { $yield( x + 10 )} } >> fn print_all(iter: $iterator) { for x in iter { $print( x )} } >> const source = *for x in [1, 2, 3] { $yield( x )} >> source |> double |> add_ten |> print_all 12 14 16 これでバックプレッシャーも無料で得られるし、コンパイラはインライン化やアンローリング、カーネル融合など、イテレータのタイプに応じて賢い判断ができるんだ。

他のコメントでも批評や考慮点はよくカバーされてるね。もう一つの考慮点(ストリームとは関係ないけど、もっと一般的なこと)はAPIデザインと開発者のUX/DXだね。type Stream = { next(): { done, value: T } | Promise } 上記は以下の組み合わせとして効果的に議論できるよ: type Stream = { next(): { done, value: T } } type Stream = { next(): Promise } 2番目のシグネチャの正当性はカバーされてるけど、APIがごちゃごちゃしてる。具体的には: > 「俺のやり方だと、同期入力に対して同期変換を定義すれば、全体のイテレーションが同期でできるから、同期関数で結果を取得して使うことができる。これは大きい。そうじゃないと、同期イテレータとforループ用にコードを書いて、非同期イテレータとfor awaitループ用にもう一回書かなきゃいけない。コードを二重に書くのは、どんな実装シナリオでもクリーンだと思う。APIコールで一般的な柔軟性が欲しいことはめったにない。そうすると、コードを読むときやレビューするときに混乱や曖昧さが生じるし、コードを追加したり編集したりするのも大変になる。両方のユースケースを別々に扱う際の繰り返しは、よく考えられた構成で簡単に処理できる。」

最近マイクロベンチマークをやって、ノード24では同期関数を待つのが呼び出すより約90倍遅いことがわかった。関数が trivial な場合、これはよくあることだよね。数バージョン前に戻ると、その数字は約105倍になる。14までテストしたかどうかは覚えてないけど。16では非同期処理の最適化があったことを覚えてる。それがnextTick()の動作に依存していたいくつかのテストを壊したんだ。セットアップと実行のステップが間違った順序で発火するようになって、モックがPromiseの代わりに数値を返すようになった。どこかにそのコードがまだあるかな…

他のレスポンダーが見落としてるコンテキストは、Elixirのような一部の関数型言語では、ストリームやイテレータがデータの段階的変換に使われていて、各ステップでの蓄積を必要としないってことだね。これらの言語のgoroutinesのバージョンで、JavaScriptにはそれがない。ジェネレーターは一応あるけど、あまり使われてないし、互いに組み合わせることもあまりない。だから、ストリームを修正するなら、IOバウンドのワークフローだけに調整された実装は、変換ワークフローを犠牲にすることになるから、もったいない機会を逃すことになるよ。

Node.JSのストリームが好きだな。250MBのメモリマシンを借りて、ストリームを使ってGB単位のデータを処理するのはすごく満足感がある。

ずいぶん前に、Repeaterっていう抽象化を書いたんだ。要するに、Promiseコンストラクタが非同期イテラブルに翻訳されたらどうなるかっていうアイデア。import { Repeater } from "@repeaterjs/repeater"; const keys = new Repeater(async (push, stop) => { const listener = (ev) => { if (ev.key === "Escape") { stop(); } else { push(ev.key); } }; window.addEventListener("keyup", listener); await stop; window.removeEventListener("keyup", listener); }); const konami = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"]; (async function() { let i = 0; for await (const key of keys) { if (key === konami[i]) { i++; } else { i = 0; } if (i >= konami.length) { console.log("KONAMI!!!"); break; // keyupリスナーを削除 } } })(); https://github.com/repeaterjs/repeater これは機能が完全で安定してる抽象化の一つで、NPMを見てみると、何らかの理由で週に650万回以上ダウンロードされてるみたい。最近は、著者とは逆の見解を持っていて、特にfetch提案にどれだけ埋め込まれているかを考えると、ストリームを使うべきだと思ってる。でも、teeの批判は致命的だから、もしかしたら著者が正しいのかも。まだこのことについて考えている人がいるのはワクワクするね。非同期イテラブルをデフォルトの抽象化にするのがいいと思う。

ちょっと脱線するけど、チートコードが大好き! 30ライフ追加されたし :-) あのコードには懐かしさが詰まってる。実際、メールの締めくくりには「Up, Up, Down, Down, Left, Right, Left, Right, B, A」って書くことが多いんだ。

リソースの解放に関してちょっと気になる点がある...ライブラリでもっと普及してほしいけど、C#のusingに似た使い方で、using/await usingを使うことができるんだ。今、接続プールのクリーンアップの一環としてそれを使うDBドライバーを作ってるところ。

Asyncイテラブルも、同じようにプロミスやスタックスイッチのオーバーヘッドがあるから、必ずしも素晴らしい解決策ではないよ。特にSSRの時に、個々のタグ名や属性、バインディングなどの小さなオブジェクトを扱うときは、自然に各文字列をwrite()するのが一番だと思う。でも、そうするとパフォーマンスが同期イテラブルに比べてひどくなるから、選択肢が出てくる:1. バッファを使って大きなチャンクを作り、スタックスイッチを減らす。これはストリームでも同じことが必要だよ。2. 同期イテラブルを使って、非同期コンポーネントをサポートできないことを受け入れる。記事ではこの問題を少し解決するために同期ストリームを提案してるけど、データのトラバース中に非同期操作をトリガーするデータがある場合、事前に同期ストリームが必要か非同期ストリームが必要かを知るのは難しい。非同期コンポーネントに遭遇したときに必要になるんだ。実際に求めているのは、必要なデータだけが非同期である方法だと思う。Lit-SSRでもこの問題に直面して、解決策としてはサンクを含む同期イテラブルに移行した。プロデューサーが非同期なことをする必要がある場合はサンクを送信して、コンシューマーがサンクを受け取ったら、次の値を得る前にそのサンクを呼び出して待たなきゃいけない。もしコンシューマーが非同期値をサポートしていない場合(同期のrenderToString()の文脈など)、サンクを受け取ったらエラーを投げることができる。これにより、実際のウェブサイトから抽出したコンポーネントに比べてSSRのベンチマークで12〜18倍のスピードアップが得られた。ストリームAPIがそんなに脆弱な契約(つまり、next()を早く呼びすぎると壊れる)を採用できるとは思わないけど、コンシューマーがマイクロタスク内でできるだけ多くの値をプルして、非同期値が出たときだけawaitするような方法があれば、本当に価値があると思う。write()writeAsync()みたいな感じで。悲しいのは、ジェネレーターがツリー状のデータに対して機能するストリーミングAPIにとっては本当に適しているのに、ジェネレーターは遅すぎるってこと。

conartist6の提案、type Stream = { next(): { done, value: T } | Promise } が気に入った。TはUint8Arrayで、可能な限り同期、無理な場合は非同期。エンジニアたちは2013年に「Zalgoを解き放つな」ということでパニックになったことがあった。異なるアクティベーションパターンを持つコールバックの使用についての心配だね。特にコールバックに関しては賢明な意見だと思う。コールバックがすぐに発火することもあれば、実際には非同期であることもあって混乱するから。 https://blog.izs.me/2013/08/designing-apis-for-asynchrony/ こういう狭い特定の制御はずっと前からあった。似たような理由で、MaybeAsync = T | Promiseを使うのは一般的に良くない。私たちはZalgoを恐れすぎてる。あの恐怖は過剰で、いい速いことができないのがすごく辛い。必要なときに非同期に行けないのもね。複数を引っ張ることについては、やっぱりそれ次第だよね。キューイングされたデファラブルを使って、好きなだけ引っ張るユーティリティ関数を作るのは難しくないけど、一度に一つだけ流すことができる。けど、少なくともいくつかのストリームソースは、待たずに複数の結果を出すのが全然問題ないと思う。内部で前のプロミスを待って、それをカーソルとして使うことができる。ジェネレーターが遅すぎるとは知らなかった。ここではジェネレーターインターフェースの主要な部分を使ってる感じだね、十分だと思う。

そう、その問題がまさに俺が解決策を提供してることなんだ。君がやってることと同じだけど、もっと堅牢だよ。それに、なぜジェネレーターが遅すぎるって言ってるのか気になる。もしかして非同期ジェネレーターを使ってた?これが同期ジェネレーターを使って作ったものだよ: https://github.com/bablr-lang/stream-iterator/blob/trunk/lib... これが魔法の部分: return step.value.then((value) => { return this.next(value); });

Node.jsのWebストリームの実用的な痛みは、ブラウザ用に最初に設計されて、サーバーにバックポートされたように感じることだね。大きなファイルを処理したり、サービス間でデータをパイプする必要があるときは、APIと戦う羽目になって、仕事が進まない。非同期イテラブルのアプローチは、for-await-ofと自然に組み合わせられて、他の非同期/awaitエコシステムとも相性が良いから、もっと理にかなってる。現在のWebストリームAPIは、すべてをトランスフォームストリームでラップしないと簡単な操作を適用できないという奇妙なインピーダンスミスマッチがある。Nodeの元々のストリーム実装にも問題があったけど、少なくとも.pipe()は直感的だった。操作をチェーンして、仕様を読まなくてもバックプレッシャーについて考えることができた。Webストリームの仕様は、複雑な問題の解決策は常により多くの抽象化だと考える人が書いたように感じる。

ノードのウェブストリームを実際に使ってる人がいるなんて、初めて知ったよ。クライアントとサーバーの両方で動くコードのためだけだと思ってた。

なんか、JavaのOKIOのデザインに似てる気がするな。最終的には似たような目標を持ってるし。内部の詳細やデザインの決定についてのプレゼンテーションがあるよ。 [1] https://github.com/square/okio [2] https://www.youtube.com/watch?v=Du7YXPAV1M8

プロミスはそんなに重いものであるべきじゃないよね。もしそうなら、JSエンジンにバグがあるってことだと思う。ネイティブレベル(C++やRust)では、プロミスはイベントループのコールバックリストに追加されるクロージャーに過ぎないから。確かに、ストリームされたバイトごとに1つ作ったら大きくなるけど、1メガバイトごとに1つなら(1ギガバイトあたり1000個)、パフォーマンスの1%も影響しないはず。

プロミス自体が重い部分じゃなくて、for awaitループで使われるawaitキーワードが実際には重いと思う。なぜなら、awaitはデバッグのためにコールスタックを保持しようとするから、パフォーマンスの観点から見ると、プロミスが比較的軽いものであるのに対して、awaitは高レベルで高コストな構造になるんだよね。だから、すべてを1つのストリームにフラットにするなら、各ステップで防御的にawaitするようなforループの実装はできない。そうすると、めちゃくちゃ遅くなっちゃうから。言語の変更提案としては、for await? (ストリームの値) { }みたいな構文があればいいと思う。これなら、基盤のプロトコルがプロミスを返さない限り、高コストなawaitを実行しないで済むから。

pull-streamモジュールとそのエコシステムがここで関係してくるね。基本的には関数を使うだけって感じ。クラスは使わず、状態を持たないことがほとんど。https://www.npmjs.com/package/pull-stream