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

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コールで一般的な柔軟性が欲しいことはめったにない。そうすると、コードを読むときやレビューするときに混乱や曖昧さが生じるし、コードを追加したり編集したりするのも大変になる。両方のユースケースを別々に扱う際の繰り返しは、よく考えられた構成で簡単に処理できる。」

Hacker Newsで議論の続きを見る