k8o

LLMS

Readable Byte Streamsでバイナリデータを効率的に読み取る

Readable Byte Streamsは、ReadableStreamのバイトストリーム拡張で、内部キューを経由せずバッファへ直接書き込む経路を提供します。BYOBリーダーによるバッファの持ち込みや、autoAllocateChunkSizeによるデフォルトリーダーとの互換性を解説します。

公開: 2026年4月5日(日)
更新: 2026年4月5日(日)

はじめに

大きなファイルやネットワークからのデータを一度にすべてメモリに載せるのではなく、少しずつ処理したいことがあります。Streams APIのReadableStreamはそのための仕組みで、データを小さなかたまり(チャンク)に分けて順番に読み取れます。

// fetchのレスポンスもReadableStream
const response = await fetch('/large-file.bin');
const reader = response.body.getReader();

while (true) {
  // チャンクを1つずつ読み取る
  const { done, value } = await reader.read();
  if (done) break;
  // valueはUint8Array(バイナリデータのかたまり)
  processChunk(value);
}

fetchの例ではブラウザがストリームを作ってくれるのでread()で読むだけですが、自分でストリームを作ることもできます。new ReadableStream()のコンストラクタにデータの供給方法を定義し、getReader()で取得したリーダーでデータを読み取ります。この記事ではデータを供給する側を「データソース側」、リーダーで読む側を「読み取り側」と呼びます。

通常のReadableStreamではチャンクは内部キューを経由して読み取り側に届きます。テキストデータであればこれで十分ですが、大きなバイナリデータを扱う場合はキューの経由がオーバーヘッドになることがあります。

Baseline 2026に追加されたReadable Byte Streamsは、条件が揃えば内部キューを経由せず読み取り側のバッファに直接データを書き込む経路を提供します。

通常のストリームでバイナリデータを読む

ReadableStreamを自分で作成してバイナリデータを読む場合を見てみます。以降のコード例では、sourceはファイルやソケットなどのデータソースを表す擬似的なオブジェクトです。

ReadableStreamのコンストラクタにはpull関数を渡します。ストリームがデータを必要とするたびにこの関数が呼ばれ、controller.enqueueでチャンクをキューに追加します。

const stream = new ReadableStream({
  async pull(controller) {
    // 1024バイト分のバッファを確保してデータを読み取る
    const chunk = new Uint8Array(1024);
    const bytesRead = await source.read(chunk);

    if (bytesRead === 0) {
      // データの終端に達したらストリームを閉じる
      controller.close();
    } else {
      // 読み取ったバイト数分だけのビューを作ってキューに追加
      controller.enqueue(new Uint8Array(chunk.buffer, 0, bytesRead));
    }
  },
});

読み取り側ではgetReader()でリーダーを取得し、read()を繰り返し呼びます。

const reader = stream.getReader();

const chunks: Uint8Array[] = [];
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
}

// すべてのチャンクを1つのバッファに結合
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
  result.set(chunk, offset);
  offset += chunk.byteLength;
}

チャンクは内部キューを経由して届くため、最終的に1つのバッファにまとめたい場合は結合のためのコピーが必要です。

バイトストリームで同じデータを読む

Readable Byte Streamsでは、読み取り側が自分でバッファを用意し、データソースにそのバッファへ直接書き込んでもらえます。この「自分のバッファを持ち込む」仕組みをBYOB(Bring Your Own Buffer)と呼びます。

コンストラクタにtype: 'bytes'を指定するとバイトストリームになります。pull関数で受け取るコントローラーは通常と異なり、byobRequestというプロパティを持ちます。これは「読み取り側がこのバッファにデータを書き込んでほしいと要求している」ことを表すオブジェクトです。

const stream = new ReadableStream({
  type: 'bytes',

  async pull(controller) {
    // controller.byobRequestは読み取り側が渡したバッファへの参照
    // このバッファに直接データを書き込む
    const view = controller.byobRequest.view;
    const bytesRead = await source.read(
      new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
    );

    if (bytesRead === 0) {
      controller.close();
    } else {
      // 書き込んだバイト数を通知
      controller.byobRequest.respond(bytesRead);
    }
  },
});

読み取り側ではgetReader({ mode: 'byob' })でBYOBリーダーを取得します。通常のread()と異なり、BYOBリーダーのread()には書き込み先のメモリ領域をUint8Arrayで指定します。データソースはそのメモリ領域に直接データを書き込みます。

以下はデータの総サイズが事前にわかっている場合の例です。1つのバッファを確保し、未使用部分を順番に渡していきます。

const reader = stream.getReader({ mode: 'byob' });

// 書き込み先のメモリ領域を確保する
let buffer = new ArrayBuffer(knownSize);
let offset = 0;

while (true) {
  // bufferの未使用部分をUint8Arrayで指定して渡す
  const view = new Uint8Array(buffer, offset, buffer.byteLength - offset);
  // read(view)は「viewが指すメモリ領域にデータを書き込んで」という意味
  const { done, value } = await reader.read(view);

  if (value) {
    // read()後は渡したviewが使用不可になり、valueから取り戻す
    buffer = value.buffer;
    offset += value.byteLength;
  }

  if (done) break;
}

const result = new Uint8Array(buffer, 0, offset);

内部キューが空でbyobRequestが使われた場合、キューを経由せず読み取り側のバッファに直接データが書き込まれます。通常のストリームのようにチャンクを配列に蓄積して結合する処理も不要です。

なお、データの総サイズが事前にわからない場合は、バッファの拡張処理が必要になります。

ReadableByteStreamController

ここからは、バイトストリームの例に登場したAPIを掘り下げていきます。

バイトストリームのpullで受け取るコントローラーはReadableByteStreamControllerです。先ほどの例ではbyobRequestを使いましたが、読み取り側がBYOBリーダーを使っていない場合や、キューにデータが残っている場合はbyobRequestnullになります。その場合は通常のストリームと同様にenqueueでキューにデータを追加します。

getReader()で読み取った場合は、データソース側のpullbyobRequestnullになるため、enqueueでデータを渡します。

// データソース側: byobRequestの有無で分岐
const stream = new ReadableStream({
  type: 'bytes',

  async pull(controller: ReadableByteStreamController) {
    if (controller.byobRequest) {
      // BYOBリーダーが使われている場合: 直接書き込み
      const view = controller.byobRequest.view;
      const bytesRead = await source.read(
        new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
      );

      if (bytesRead === 0) {
        controller.close();
      } else {
        controller.byobRequest.respond(bytesRead);
      }
    } else {
      // 通常のリーダーが使われている場合: キューに追加
      const chunk = new Uint8Array(1024);
      const bytesRead = await source.read(chunk);

      if (bytesRead === 0) {
        controller.close();
      } else {
        controller.enqueue(new Uint8Array(chunk.buffer, 0, bytesRead));
      }
    }
  },
});

// 読み取り側: 通常のリーダーで読み取る
// BYOBリーダーとは異なり、バッファを渡す必要がない
const reader = stream.getReader();

const chunks: Uint8Array[] = [];
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
}

ただし、通常のリーダーを使うとキューを経由するため、バイトストリームの直接書き込みの恩恵は受けられません。

バッファの所有権

BYOBリーダーのread(view)を呼ぶと、渡したviewは使用不可(detached)になります。これは、データの書き込み中にアプリケーション側が同じメモリ領域を変更してしまうことを防ぐためです。書き込みが完了すると、戻り値のvalueから同じメモリ領域にアクセスできるようになります。

const view = new Uint8Array(new ArrayBuffer(1024));
const { value } = await reader.read(view);

// viewはdetachedなので再利用してはいけない
// 書き込まれたデータはvalueから読める
console.log(value[0]);

データソース側でも同様に、byobRequest.respond()を呼んだ時点でbyobRequest.viewはdetachedになります。

autoAllocateChunkSize

バイトストリームの例ではBYOBリーダーを使いましたが、既存のコードが通常のgetReader()を使っている場合もあります。autoAllocateChunkSizeを指定すると、通常のリーダーからの読み取り時にストリームが自動でバッファを確保し、データソース側でbyobRequestを通じた書き込みが利用できるようになります。ただし、キューにデータが残っている場合は通常通りbyobRequestnullになります。

const stream = new ReadableStream({
  type: 'bytes',
  // 通常のリーダーからの読み取り時に自動で1024バイトのバッファを確保
  autoAllocateChunkSize: 1024,

  async pull(controller: ReadableByteStreamController) {
    if (!controller.byobRequest) {
      // キューにデータが残っている場合はbyobRequestがnullになるため、何もしない
      return;
    }

    const view = controller.byobRequest.view;
    const bytesRead = await source.read(
      new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
    );

    if (bytesRead === 0) {
      controller.close();
    } else {
      controller.byobRequest.respond(bytesRead);
    }
  },
});

// BYOBリーダーではなく通常のリーダーでも動作する
const reader = stream.getReader();
const { value } = await reader.read();

autoAllocateChunkSizeを指定しない場合、通常のリーダーではbyobRequestが常にnullになります。BYOBリーダーと通常のリーダーの両方をサポートするには、autoAllocateChunkSizeを指定するか、前述のReadableByteStreamControllerの例のようにbyobRequestの有無で分岐します。

おわりに

Readable Byte Streamsは、条件が揃えば内部キューをバイパスして読み取り側のバッファへ直接書き込める仕組みです。BYOBリーダーで読み取り側がバッファの管理を制御でき、autoAllocateChunkSizeで通常のリーダーとの互換性も保てます。

大きなファイルの読み取りやネットワークストリームの処理など、バイナリデータのコピーコストが問題になる場面で活用できます。

0
もくじ