Article

JavaScriptのGeneratorとAsyncIteratorの実践活用法

高田晃太郎
JavaScriptのGeneratorとAsyncIteratorの実践活用法

主要ブラウザの互換性データ(MDN/Can I use)では、JavaScriptのGeneratorはほぼ100%、Async Iterator/Async Generatorは広範なモダン環境で実装済みと示され、Node.jsでもv10以降で実用段階に達しています¹²⁴⁵。つまり、ES2015で導入されたGenerator(generator関数)とES2018で標準化されたAsync Iterator(for-await-ofで使う非同期イテレーション)は、今日のプロダクションで互換性の不安を理由に見送る段階ではありません⁴。HTTPリクエストのストリーミングや巨大CSVの処理、イベント駆動のパイプラインなど、バッチ展開ではコストが跳ね上がる領域において、これらの機構は「遅延評価(必要になったら作る)」と「バックプレッシャー(消費速度に合わせる)」を自然なJavaScript構文で組み込みます³⁴。公開事例やコミュニティのベンチマークでは、ピークメモリの削減、TTFB(Time to First Byte)/TTFRの短縮、スループットの安定化が継続的に報告されており、クラウドのランタイムコストやSLOの改善につながる可能性が高いと考えられます³⁵。合言葉は一括ではなく、流すこと。配列に溜めずに「流しながら処理する」発想へ切り替えるだけで、設計もレビューもシンプルになります。

なぜいまGeneratorとAsync Iteratorなのか

まず設計論から整理します。同期のGeneratorは「必要になった瞬間にだけ次の値を作る」遅延評価を提供します⁴。非同期のAsync Iterator(Async Generatorを含む)は、その哲学をIOを伴う世界に拡張し、for-await-ofという読みやすい構文でストリーミング消費を可能にします⁴。どちらも、呼び出し側の消費速度に生産側が合わせるというバックプレッシャー(供給を絞る仕組み)の自然な実装になります³。これがメモリ効率に効きます。巨大な配列や全件読み出しを避けることで、ピークメモリは入力サイズではなくパイプラインのバッファ幅にほぼ支配されます³。KubernetesのPodに割り当てるメモリを下げられれば、同じノード上での複数レプリカ稼働が現実味を帯び、結果的にコストとレイテンシが両面で改善されます。

互換性の観点では、GeneratorはES2015以降の標準的環境で広く利用可能です¹。Async IteratorもNode.js LTSと主要ブラウザで安定稼働しており、トランスパイルを極力避けたい環境でも導入しやすい段階に達しました²⁵。フレームワーク側の対応も進み、Node.jsのReadable.fromやWeb Streams APIとの連携が公式に用意されています³。つまり、アプリケーション層だけでなく、基盤のストリーム実装と相互運用できるということです。

チーム運用では、テスタビリティと責務分離が重要です。Generatorは副作用を抑えた逐次生成器として設計しやすく、入力と出力の関係を固定できるため、プロパティベーステストやスナップショットテストとの相性が良好です。Async Iteratorに至っては、遅延と失敗が常態という前提で、明示的なキャンセルとタイムアウトをコーディング規約に含めるだけで、障害時の復旧行動がコードに刻まれます。結果として運用手順書の分量が減り、レビュー基準を定量化しやすくなります。

実装パターン集:現場で使うための最短距離

同期Generatorの基本と早期終了

同期Generatorは、重い計算やファイル読み出しを伴わない純粋な逐次処理に向いています。典型例は範囲生成や状態機械の駆動です。早期終了を考慮して、finallyでリソースを解放する癖を付けると大型実装に展開しやすくなります。要するに「配列を作らず、必要な分だけ順番に返す」書き方です。

// range: 配列を作らずに逐次生成
export function* range(start = 0, end = Infinity, step = 1) {
  try {
    for (let i = start; i < end; i += step) {
      yield i;
    }
  } finally {
    // ここで必要なら後始末
  }
}

// 消費側: for...ofは早期breakでGeneratorをクローズする
for (const n of range(0, 10, 2)) {
  if (n >= 6) break;
  console.log(n);
}

イテレーションを中断した瞬間にfinallyが走るため、外部リソースを持つGeneratorでも安全に扱えます。メモリは常に一定で、入力サイズに比例して増えません。

非同期ページネーションをAsync Generatorで記述する

ネットワーク越しのページネーションはAsync Iteratorの代表的ユースケースです。指数バックオフやリトライ、キャンセルといった運用要件をひとつの関数に閉じ込められる点が、チーム開発の強みになります。JavaScriptのasync/awaitとfor-await-ofの相性が良く、読みやすいコードで非同期イテレーションを表現できます。

import { setTimeout as delay } from 'node:timers/promises';

export async function* fetchPages(url, {
  pageSize = 100,
  maxPages = Infinity,
  signal,
  maxRetries = 3,
  baseDelayMs = 200,
} = {}) {
  let page = 1;
  try {
    while (page <= maxPages) {
      let attempt = 0;
      // リトライ付きの1ページ取得
      while (true) {
        try {
          const u = new URL(url);
          u.searchParams.set('page', String(page));
          u.searchParams.set('limit', String(pageSize));
          const res = await fetch(u, { signal });
          if (res.status === 429) throw new Error('RATE_LIMIT');
          if (!res.ok) throw new Error(`HTTP_${res.status}`);
          const json = await res.json();
          const items = Array.isArray(json) ? json : json.items ?? [];
          if (items.length === 0) return; // 終端
          yield items;
          page += 1;
          break;
        } catch (err) {
          if ((err?.name === 'AbortError') || signal?.aborted) throw err;
          attempt += 1;
          if (attempt > maxRetries) throw err;
          const backoff = baseDelayMs * 2 ** (attempt - 1);
          await delay(backoff, undefined, { signal });
        }
      }
    }
  } finally {
    // 監視やレートリミット解除のためのログ出力などをここで
  }
}

// 消費側: for-await-of で逐次処理
const controller = new AbortController();
(async () => {
  for await (const batch of fetchPages('https://api.example.com/items', {
    pageSize: 500,
    maxPages: 100,
    signal: controller.signal,
  })) {
    // バッチ単位でDBへフラッシュするなど
    await processBatch(batch);
  }
})();

// 必要ならタイムアウトでキャンセル
setTimeout(() => controller.abort(), 30_000);

この形に統一しておくと、API仕様変更やレート制御ポリシーの改定があっても関数の内部だけで吸収できます。消費側はビジネスロジックに集中できます。

パイプライン合成:map/filterの同期・非同期版

逐次処理の最大の利点は、配列メソッドの直感を崩さずに巨大データへ拡張できることです。同期と非同期の両方に対して、同じ構文意識で合成できる薄いユーティリティを置くと学習コストが最小化します。TypeScriptの型推論とも相性が良く、レビュー時に意図が読み取りやすくなります。

// 同期版の合成
export function* map(iterable, fn) {
  for (const x of iterable) yield fn(x);
}
export function* filter(iterable, pred) {
  for (const x of iterable) if (pred(x)) yield x;
}

// 非同期版の合成(入出力ともにAsyncIterable)
export async function* amap(asyncIterable, fn) {
  for await (const x of asyncIterable) yield await fn(x);
}
export async function* afilter(asyncIterable, pred) {
  for await (const x of asyncIterable) if (await pred(x)) yield x;
}

// 使用例:fetchPages -> afilter -> amap とバッチ内展開
for await (const items of fetchPages('https://api.example.com/items')) {
  for await (const out of amap(
    afilter(itemsToAsyncIterable(items), async (i) => i.score > 0.8),
    async (i) => ({ id: i.id, rank: await rank(i) })
  )) {
    await sink(out);
  }
}

// 配列バッチをAsyncIterableにする補助
function itemsToAsyncIterable(items) {
  return {
    async *[Symbol.asyncIterator]() {
      for (const i of items) yield i;
    },
  };
}

この合成スタイルに慣れると、ビジネスロジックを純関数的に分割し、IOボーダーを横断する流れを簡潔に記述できます。レビュー観点も入力と出力の整合性検証に集中できます。

Streamsとの橋渡し:Node.js/Web双方の相互運用

Async Iterableはストリームと親和性が高く、Node.js・Web双方で公式に橋が用意されています³。既存のストリームベース実装に段階的に組み込めるため、全面置換を避けながら効果を得られます。JavaScriptのストリーミング処理をプラットフォーム横断で再利用したいときに有効です。

import { Readable } from 'node:stream';

// AsyncIterable -> Node.js Readable
export function toNodeReadable(asyncIterable) {
  return Readable.from(asyncIterable);
}

// Node.js Readable -> AsyncIterable(Node v10+)
export function toAsyncIterable(readable) {
  return readable[Symbol.asyncIterator]();
}

// Web Streams: AsyncIterable -> ReadableStream
export function toWebReadable(asyncIterable) {
  const iterator = asyncIterable[Symbol.asyncIterator]();
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next();
      if (done) controller.close();
      else controller.enqueue(value);
    },
    async cancel(reason) {
      await iterator.return?.(reason);
    },
  });
}

API境界でのデータ受け渡しをこのアダプタで標準化しておけば、HTTPハンドラやワーカー間連携におけるTTFB/TTFR短縮を、既存コードの破壊を最小に抑えて実現できます⁵。より詳しい背景は、関連する解説記事や公式ドキュメントと合わせて整備すると効果的です。

イベントを非同期イテラブル化してバックプレッシャーを導入する

EventEmitterやDOMイベントは、素朴にハンドラで書くと自然に無限バッファになります。キューを明示し、キャンセルと容量制限を設けることで耐障害性が上がります。非同期イテレーションに変換すれば、for-await-ofで自然に流せます。

import { EventEmitter } from 'node:events';

export function fromEvents(emitter, eventName, { highWaterMark = 100, signal } = {}) {
  const queue = [];
  let resolve;
  const onEvent = (evt) => {
    if (queue.length >= highWaterMark) return; // ドロップ or 別ポリシー
    queue.push(evt);
    resolve?.();
  };
  emitter.on(eventName, onEvent);

  return {
    async *[Symbol.asyncIterator]() {
      try {
        for (;;) {
          if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); // NodeでDOMExceptionが無い場合はErrorで代用
          if (queue.length === 0) {
            await new Promise((r) => (resolve = r));
            resolve = undefined;
            continue;
          }
          yield queue.shift();
        }
      } finally {
        emitter.off(eventName, onEvent);
      }
    },
  };
}

// 使用例
const emitter = new EventEmitter();
const ai = fromEvents(emitter, 'data', { highWaterMark: 1000 });
(async () => {
  for await (const evt of ai) await handle(evt);
})();

このパターンはメッセージングやログ収集の取り込みで特に効果を発揮します。バッファの意味をコードとして明示できるため、SREとの合意形成が容易になります。

エラーハンドリング、キャンセル、リソース管理

for-await-ofは同期のtry/catchと同様の表現力を持ちます⁴。生成側のtry/finallyと消費側のtry/catchを適切に配置することで、障害時の後始末と再試行が明確になります。加えて、AbortControllerを第一級で扱い、キャンセル可能設計を標準化するとサービス全体のMTTRが縮まります。

// 生成側での後始末(DBカーソル、ファイルハンドル等)
import fs from 'node:fs/promises';

export async function* readLines(path, { signal } = {}) {
  const fh = await fs.open(path, 'r');
  const stream = fh.createReadStream();
  const iter = stream[Symbol.asyncIterator]();
  try {
    let buf = '';
    for await (const chunk of iter) {
      if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
      buf += chunk.toString('utf8');
      let idx;
      while ((idx = buf.indexOf('\n')) >= 0) {
        const line = buf.slice(0, idx);
        buf = buf.slice(idx + 1);
        yield line;
      }
    }
    if (buf) yield buf;
  } finally {
    await fh.close();
  }
}

// 消費側のエラーとキャンセル制御
const ac = new AbortController();
(async () => {
  try {
    for await (const line of readLines('./big.csv', { signal: ac.signal })) {
      if (line.includes('STOP')) ac.abort();
      await consume(line);
    }
  } catch (e) {
    if (e.name === 'AbortError') {
      console.warn('Canceled by policy');
    } else {
      console.error('Stream failed', e);
    }
  }
})();

早期終了時には生成側のfinallyが確実に走るため、ファイルやソケットはリークしません。キャンセルを例外で表現する是非は議論がありますが、AbortErrorで統一するとログの分類やメトリクス集計が容易です。

パフォーマンスとベンチマーク:メモリ・TTFB・スループット

技術選定の裏付けには計測が欠かせません。以下は、全件配列展開とAsync Iterableのストリーミング処理を比較する最小ベンチマークです。Node.js v20で動作し、処理時間とピークに近いメモリを観察できます。実際の差はデータ特性と下層IOで変動するため、ここで示すのは測定手法と観点です。

import { performance } from 'node:perf_hooks';

function heavyTransform(x) { return x * 2; }

async function streamingSum(asyncIterable) {
  let s = 0;
  for await (const x of asyncIterable) s += heavyTransform(x);
  return s;
}

async function* source(n) {
  for (let i = 0; i < n; i++) yield i;
}

function arraySum(arr) {
  return arr.map(heavyTransform).reduce((a, b) => a + b, 0);
}

function mem() { return process.memoryUsage().rss / (1024 * 1024); }

async function main() {
  const N = 10_000_000; // 1千万

  const m0 = mem();
  const t0 = performance.now();
  const s1 = await streamingSum(source(N));
  const t1 = performance.now();
  const m1 = mem();

  const t2 = performance.now();
  const arr = Array.from({ length: N }, (_, i) => i);
  const s2 = arraySum(arr);
  const t3 = performance.now();
  const m2 = mem();

  console.log({ s1, streamingMs: t1 - t0, memDeltaMB: m1 - m0 });
  console.log({ s2, arrayMs: t3 - t2, memDeltaMB: m2 - m1 });
}

main();

この手法では、配列化のコストとピークメモリが顕在化します³。一般に、Generator/Async Iteratorは関数呼び出しのオーバーヘッドを伴うため単純なCPUバウンドでは不利になる場合もありますが、IO待ちやメモリ局所性が効かないワークロードでは、TTFB/TTFRの短縮とメモリ平準化によって総合的なユーザー体感やSLOが改善する可能性があります⁵。重要なのは、テストデータを本番に近づけ、GCログやevent loopの遅延も併せて観察することです。Nodeの—trace-gcやCPU/メモリプロファイルを併用すると、意思決定の粒度が一段上がります。

並列性を求める場合は、Async Iteratorの上で制限付き並列実行を組み合わせると、外部APIやDBへの負荷を一定に保ちながらスループットを引き上げられます。乱暴なPromise.allではなく、同時実行数を絞る設計が鍵です。

// 制限付き並列実行(p-limit相当の最小実装)
export async function* mapConcurrent(asyncIterable, worker, { concurrency = 8 } = {}) {
  const inflight = new Set();
  for await (const x of asyncIterable) {
    const p = (async () => worker(x))();
    inflight.add(p);
    p.finally(() => inflight.delete(p));
    if (inflight.size >= concurrency) {
      yield await Promise.race(inflight);
    }
  }
  while (inflight.size) {
    yield await Promise.race(inflight);
  }
}

// 使用例: 逐次ソースに並列処理を載せて、出力は逐次吐き出す
for await (const result of mapConcurrent(
  fetchPages('https://api.example.com/items'),
  processItem,
  { concurrency: 5 }
)) {
  await sink(result);
}

これにより、システム全体のバックプレッシャーが守られたまま、レイテンシ分散とリソース効率のバランス点を探索できます。DB接続や下流APIのSLOに合わせてconcurrencyを設定し、SREと合意したレートリミットをコードに落とし込んでください。

アーキテクチャ運用:ROI、移行、観測性

ビジネス価値の観点では、ピークメモリとウォールタイムのプロファイルが改善されるほど、クラウドコストとユーザー体験が比例して良くなります。バッチ処理をストリーミングに置き換えるだけで、コンテナのメモリ要求を下げ、オートスケーリングの反応を速め、TTFB/TTFRを短縮できる可能性があります。フロントエンドでは、Async IteratorとWeb Streamsの組み合わせで、サーバーからの部分レンダリングや逐次デコードを前提としたUIに移行し、LCPやINPの改善に寄与し得ます。バックエンドでは、ETLやレポーティングにおける全件メモリ展開をやめるだけで、夜間バッチの失敗率を下げられる可能性があり、運用当番の負荷軽減につながります。

移行戦略としては、まず外部IOの境界からAsync Iterator化するのが効果的です。APIクライアント、ファイルリーダー、DBカーソルといった層をラップし、上位層はしばらく配列に変換して従来の処理を維持します。十分な検証の後、少しずつ中間層を配列不要のパイプラインへ置き換えます。可観測性は最初から設計に織り込み、イテレーションの進捗、バッファ占有、キャンセル理由、リトライ回数をメトリクスに出力し、ダッシュボードでSLOと並べて可視化します。サンプル実装を内製パッケージとして公開し、コード生成テンプレートに組み込むと普及速度が上がります。関連の設計原則はまとめておくと新人受け入れ時の学習が速くなります。

型、安全性、レビュー基準

TypeScriptでは、Iterable/AsyncIterableの型境界をきちんと露出させることで、合成関数の型推論が効き、レビューでの見落としが減ります。副作用を外に寄せ、Generatorの中では入力から出力だけを返す純粋性をできる範囲で保つようガイドライン化すると、テスト容易性と障害時の切り分け速度が上がります。コードレビューでは、キャンセル経路、finallyの有無、バッファ上限、並列度の設定根拠を必ず確認対象に含めると良いでしょう。

まとめ:配列の代わりに流れを設計する

配列に積んでから処理するという習慣を、一歩ずつ流しながら処理する設計へと置き換えるだけで、GeneratorとAsync Iteratorは性能と運用に目に見える差を生みます。互換性は成熟し、言語機能も枯れており、チームに必要なのは統一されたパターンと計測の文化です¹²⁴。まずは外部IOの境界をAsync Iterator化し、遅延評価のパイプラインを少しずつ広げてみませんか。測定可能なメトリクスを用意し、TTFBやピークメモリ、リトライの挙動をダッシュボードで追いかければ、投資対効果は自然に可視化されます。あなたのプロダクトにとって、最初に流れに変えるべき箇所はどこでしょうか。次のスプリントで、ひとつだけ配列をやめてストリームに置き換えるタスクを計画に入れてみてください。結果は数字で可視化されるはずです。

参考文献

  1. Can I use. ES6 Generators support.
  2. Can I use. JavaScript built-in: AsyncIterator support.
  3. Node.js. Backpressuring in Streams.
  4. MDN Web Docs. Iterators and generators — JavaScript.
  5. Daishi Kato. Comparing the Stream API and async generators in Node.js v10. LogRocket Blog.