JavaScript Tips | 配列ユーティリティ:並列処理

JavaScript JavaScript
スポンサーリンク

テーマの整理:「並列処理」とは何か

ここでいう「並列処理」は、配列の要素に対する非同期処理を「同時に複数まとめて動かす」ことです。 順次処理(1 件ずつ)に比べて速く終わりやすい一方で、負荷やレート制限を意識しないと危険にもなります。

業務だと、こんな場面で並列処理が欲しくなります。

複数ユーザーの情報を一気に取得したい。 複数ファイルを同時にアップロードしたい。 複数のチェック処理をまとめて投げて、全部終わってから結果を見たい。

ここでは、「配列 × 非同期 × 並列」を扱うためのユーティリティを、初心者向けにかみ砕いて説明します。

基本形:完全並列で動かす parallelMap(Promise.all)

実装と考え方

まずは「全部並列で動かして、全部終わったら結果を配列で返す」一番シンプルな形です。 これは、Promise.all をラップした「非同期 map の並列版」と考えてください。

async function parallelMap(array, asyncMapper) {
  if (!Array.isArray(array)) {
    return [];
  }
  if (typeof asyncMapper !== "function") {
    return array.slice();
  }

  const promises = array.map((item, index) => asyncMapper(item, index));
  return Promise.all(promises);
}
JavaScript

重要なポイントをかみ砕きます。

array は「処理したい対象の配列」。 asyncMapper は「各要素を非同期で変換する関数」(async 関数 or Promise を返す関数)。 まず普通の map で「Promise の配列」を作る。 Promise.all で「全部の Promise が終わるのを待って、結果を配列で受け取る」。

つまり、「同期の map を、そのまま並列の async にしたもの」です。

例題:ユーザー ID の配列から、API でユーザー情報を並列取得する

async function fetchUser(id) {
  console.log("start", id);
  await new Promise((r) => setTimeout(r, 500));
  console.log("end", id);
  return { id, name: `User-${id}` };
}

async function main() {
  const ids = [1, 2, 3];

  const users = await parallelMap(ids, (id) => fetchUser(id));

  console.log(users);
}

main();
JavaScript

ログを見ると、「1・2・3 がほぼ同時に start して、ほぼ同時に end している」ことが分かるはずです。 これが「完全並列」のイメージです。

完全並列の注意点:速いけど“雑に速い”

完全並列は速いですが、業務では次のようなリスクがあります。

API のレート制限を一気に踏み抜く可能性がある。 外部サービスに負荷をかけすぎて、エラーが増える。 大量のファイルアップロードなどでネットワーク帯域を食いつぶす。

「並列にできるからといって、何でも全部並列にしていいわけではない」というのが、実務での大事な感覚です。 そこで出てくるのが、「同時実行数を制限した並列処理」です。

同時実行数を制限する parallelLimit(制限付き並列)

実装と考え方

「最大 N 個まで並列で動かして、それ以上は順番待ちにする」ユーティリティを作ります。 これは、前に説明した runWithLimit の「map 版」と考えてください。

async function parallelLimit(array, asyncMapper, limit = 5) {
  if (!Array.isArray(array)) {
    return [];
  }
  if (typeof asyncMapper !== "function") {
    return array.slice();
  }
  if (typeof limit !== "number" || limit <= 0) {
    limit = 1;
  }

  const result = new Array(array.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < array.length) {
      const index = currentIndex;
      currentIndex += 1;

      const item = array[index];
      const value = await asyncMapper(item, index);
      result[index] = value;
    }
  }

  const workers = [];
  const workerCount = Math.min(limit, array.length);
  for (let i = 0; i < workerCount; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return result;
}
JavaScript

重要なポイントを丁寧に分解します。

limit は「同時に動かす最大数」。 result は「最終的な結果を入れる配列」で、元の配列と同じ長さ。 currentIndex は「次に処理すべき要素の位置」を共有するカウンタ。 worker は「仕事を取りに行って処理する役割」を持つ async 関数。 worker を limit 個だけ立ち上げることで、「同時に動く処理の数」を制限する。 全部の worker が終わったら、result に全要素の結果が入っている。

これが「制限付き並列処理」の基本パターンです。

例題:ユーザー ID を最大 2 並列で API 取得する

async function fetchUser(id) {
  console.log("start", id);
  await new Promise((r) => setTimeout(r, 500));
  console.log("end", id);
  return { id, name: `User-${id}` };
}

async function main() {
  const ids = [1, 2, 3, 4];

  const users = await parallelLimit(ids, (id) => fetchUser(id), 2);

  console.log(users);
}

main();
JavaScript

ログを眺めると、「常に 2 つまでしか同時に start していない」ことが分かるはずです。 これが、レート制限や負荷を意識した“業務レベルの並列処理”です。

並列処理+エラー制御:失敗をどう扱うか

全部成功しないとダメなケース

「1 つでも失敗したら全体を失敗にしたい」なら、parallelMap の中で Promise.all をそのまま使う形で十分です。 この場合、どれか 1 つが reject すると、全体が reject します。

async function parallelMapStrict(array, asyncMapper) {
  const promises = array.map((item, index) => asyncMapper(item, index));
  return Promise.all(promises); // 1 つでも失敗したら全体が失敗
}
JavaScript

一部失敗は許容したいケース

「失敗したものは失敗として記録しつつ、成功したものはちゃんと使いたい」なら、 Promise.allSettled を使うか、parallelLimit の中で try/catch する形が向いています。

例えば、制限付き並列で「成功なら値、失敗ならエラーオブジェクト」を結果に入れる形です。

async function parallelLimitSafe(array, asyncMapper, limit = 5) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();
  if (typeof limit !== "number" || limit <= 0) limit = 1;

  const result = new Array(array.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < array.length) {
      const index = currentIndex;
      currentIndex += 1;

      const item = array[index];
      try {
        result[index] = await asyncMapper(item, index);
      } catch (e) {
        result[index] = e; // 失敗はエラーとして記録
      }
    }
  }

  const workers = [];
  const workerCount = Math.min(limit, array.length);
  for (let i = 0; i < workerCount; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return result;
}
JavaScript

業務では、「一部失敗は許容するが、ログには残したい」「後で再試行したい」といった要件が多いので、 「失敗をどう結果に残すか」まで含めて並列処理ユーティリティを設計しておくと、後々楽になります。

並列処理を選ぶときに必ず考えてほしいこと

本当に並列にしていいか?

並列処理は速くなりがちですが、 「外部サービスの都合」「レート制限」「ネットワーク負荷」「DB ロック」など、 現実世界の制約を無視すると簡単に事故ります。

そこで、毎回自分にこう問いかけてほしいです。

この処理は、順番にやる必要があるか? 外部サービスに負荷をかけすぎないか? 同時に何件までなら安全か?

その答えを、parallelMapparallelLimit のどちらを使うか、limit をいくつにするか、という形に落とし込んでいきます。

並列処理は「速さ」と「安全さ」のバランス調整

完全並列は「速さ寄り」。 順次処理は「安全さ寄り」。 制限付き並列は「その間を取る」ものです。

業務コードでは、このバランスを意識して設計することがとても大事です。 「とりあえず全部並列」は、最初は気持ちいいですが、後で痛い目を見ることが多いです。

手を動かして「並列処理」の感覚をつかむ

次のコードを実行して、完全並列と制限付き並列の違いをログで体感してみてください。

async function parallelMap(array, asyncMapper) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();

  const promises = array.map((item, index) => asyncMapper(item, index));
  return Promise.all(promises);
}

async function parallelLimit(array, asyncMapper, limit = 2) {
  if (!Array.isArray(array)) return [];
  if (typeof asyncMapper !== "function") return array.slice();
  if (typeof limit !== "number" || limit <= 0) limit = 1;

  const result = new Array(array.length);
  let currentIndex = 0;

  async function worker() {
    while (currentIndex < array.length) {
      const index = currentIndex;
      currentIndex += 1;
      const item = array[index];
      const value = await asyncMapper(item, index);
      result[index] = value;
    }
  }

  const workers = [];
  const workerCount = Math.min(limit, array.length);
  for (let i = 0; i < workerCount; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return result;
}

async function demo() {
  const ids = [1, 2, 3, 4];

  console.log("=== parallelMap (full parallel) ===");
  await parallelMap(ids, async (id) => {
    console.log("start", id);
    await new Promise((r) => setTimeout(r, 500));
    console.log("end", id);
    return id * 10;
  });

  console.log("=== parallelLimit (limit=2) ===");
  await parallelLimit(ids, async (id) => {
    console.log("start", id);
    await new Promise((r) => setTimeout(r, 500));
    console.log("end", id);
    return id * 10;
  }, 2);
}

demo();
JavaScript

「全部並列」と「最大 2 並列」の違いが、ログの出方でかなりはっきり見えるはずです。

まとめ:並列処理ユーティリティで「速さをコントロールする」

配列ユーティリティとしての「並列処理」は、 単に速くするためのテクニックではなく、「速さをちゃんとコントロールするための設計」です。

プロジェクトに例えば次のような関数を置いておくイメージです。

export async function parallelMap(...) { ... }      // 完全並列
export async function parallelLimit(...) { ... }    // 制限付き並列
JavaScript

そして、「配列 × async を速く回したいときは、必ずどちらかを選ぶ」と決めておく。 それだけで、非同期コードの挙動が読みやすくなり、 「どこまで並列にしてよくて、どこからは抑えるべきか」をチームで共有しやすくなります。

タイトルとURLをコピーしました