本文へ移動

イベントループへ処理を譲る ​

同期処理が長く続くパイプラインでは、tap のコールバックでイベントループへ処理を譲ることで、タイマーや I/O、タイマー起点のキャンセルを進められます。Mogami は自動では待機を挿入しないため、既存のオペレータを組み合わせて以下のヘルパーを定義します。

各ヘルパーと一緒に、mogami-stream から defer、from、tap を import してください。

使用例 ​

次の yieldEvery はこのページで定義するヘルパーで、1,024件ごとに処理を譲ります。

js
import { filter, range } from "mogami-stream";

const controller = new AbortController();
await range(0, 100_000)
  .pipe(
    yieldEvery(1_024),
    filter((value) => value % 2 === 0),
  )
  .forEach((value) => console.log(value), { signal: controller.signal });

キャンセル可能な待機 ​

await Promise.resolve() が処理を譲る先はマイクロタスク(microtask)のみです。遅延ゼロのタイマーを使うと、ホストがタイマーや I/O を処理できます。待機が終わるとタイマーと abort リスナーを解除します。

js
/**
 * @param {AbortSignal} signal
 * @returns {Promise<void>}
 */
export function yieldToHost(signal) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted();
    const abort = () => {
      clearTimeout(timer);
      reject(signal.reason);
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", abort);
      resolve();
    }, 0);
    signal.addEventListener("abort", abort, { once: true });
  });
}

一定件数ごとに処理を譲る ​

js
/**
 * @param {number} count
 * @returns {<T>(source: AsyncIterable<T>) => import("mogami-stream").Stream<T>}
 */
export function yieldEvery(count = 1_024) {
  if (!Number.isSafeInteger(count) || count <= 0)
    throw new RangeError("count must be a positive safe integer");
  return (source) =>
    from(source).pipe(
      tap((_value, { index, signal }) =>
        (index + 1) % count === 0 ? yieldToHost(signal) : undefined,
      ),
    );
}

tap はコールバックを逐次 await し、Promise を値として持つ要素もそのまま下流へ渡します。index は実行ごとに 0 から始まるため、同じオペレータを再利用できます。

N 番目の要素を上流から読み取ってから待機し、待機完了後に下流へ渡します。下流から読み取り要求がない間は、タイマーも設定されません。

ほぼすべての要素を除外する filter など、大量に読み取っても出力しない可能性があるステージより前に配置してください。同じ 1,024 件でもコールバックの重さによって占有時間は大きく異なるため、1件あたりの処理コストに合わせて件数を調整します。

経過時間で処理を譲る ​

単調増加クロックが処理中も進むホスト環境では、要素ごとに経過時間を判定する方法も使えます。defer の内側で時刻を保持することで、実行ごとの状態を分離します。

js
/**
 * @param {number} budgetMs
 * @returns {<T>(source: AsyncIterable<T>) => import("mogami-stream").Stream<T>}
 */
export function yieldAfter(budgetMs = 4) {
  if (!Number.isFinite(budgetMs) || budgetMs <= 0)
    throw new RangeError("budgetMs must be finite and positive");
  return (source) =>
    defer(() => {
      // Each execution owns its clock, including simultaneous iterations.
      let lastYield = performance.now();
      return from(source).pipe(
        tap((_value, { signal }) => {
          if (performance.now() - lastYield < budgetMs) return;
          return yieldToHost(signal).then(() => {
            lastYield = performance.now();
          });
        }),
      );
    });
}

yieldEvery(1_024) の代わりに yieldAfter(4) を使い、前回の yield 以降の上流処理時間と下流の待機時間を含めて判定します。このステージに要素が到着した時点でしか判定しないため、1回の重いコールバックで指定時間を超えることがあります。Cloudflare Workers では I/O 後にしか performance.now() が進まないため、件数ベースの yieldEvery を使ってください。

間隔の選び方 ​

遅延ゼロのタイマーでも、実行環境によって最小待機時間が設けられる場合があります。待機の間隔を短くするとタイマーが動く機会は増えますが、全体の処理時間も長くなるため、要素ごとの待機は避けて負荷に応じた間隔を選んでください。

なお、すでに実行中の同期コールバックを途中から中断することはできないため、1回あたりの処理が重い場合はより小さな単位に分割してください。ソースやコールバックがすでに I/O を await している場合は、明示的な待機を追加する前に計測して確認してください。