Promise.all() だけでは足りない?並列数制限を mapAsyncLimit で理解する
フロントエンド面接では、ただ Promise.all() を知っているだけでは足りないことがあります。
たとえば次のようなケースです。
- API を 100 件まとめて叩きたい
- でも同時実行しすぎるとサーバーやブラウザに負荷がかかる
- そこで「最大 3 件まで並列実行」のような制御が必要になる
今回は、この問題を mapAsyncLimit という形で整理してみます。
問題
items を非同期 mapper で処理しつつ、同時実行数を limit 以下に保つ関数を実装します。
async function mapAsyncLimit<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
// implement
};
期待するイメージ:
const result = await mapAsyncLimit(
[1, 2, 3, 4, 5],
2,
async (n) => {
await wait(100);
return n * 2;
}
);
console.log(result); // [2, 4, 6, 8, 10]
ポイントは次の 2 つです。
実行順ではなく 結果の順番は元の配列順 を維持したい
同時実行数は limit 以下 にしたい
まず Promise.all() だけでは何が足りないのか
Promise.all(items.map(mapper)) は便利ですが、全件を一気に開始します。
それが問題ないケースもありますが、以下のようなときは困ります。
レートリミットに引っかかる
同時に重い処理が走る
ネットワークや CPU に無駄な負荷がかかる
つまり必要なのは、
「全部やる」ではなく「少しずつ流す」仕組み です。
考え方
やることはシンプルです。
結果を入れる配列 results を用意する
次に処理する index を管理する
limit 個の worker を立ち上げる
各 worker は、まだ未処理の item があれば 1 件ずつ取って処理する
終わったらまた次を取りに行く
全 worker が終わったら完了
この形にすると、常に最大 limit 件だけが走るようになります。
実装
async function mapAsyncLimit<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
if (limit <= 0) {
throw new Error("limit must be greater than 0");
}
const results: R[] = new Array(items.length);
let nextIndex = 0;
async function worker() {
while (true) {
const currentIndex = nextIndex;
nextIndex++;
if (currentIndex >= items.length) {
return;
}
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
}
const workerCount = Math.min(limit, items.length);
const workers = Array.from({ length: workerCount }, () => worker());
await Promise.all(workers);
return results;
}
なぜこれで並列数が制限されるのか
たとえば limit = 3 なら、最初に 3 つの worker だけが動きます。
各 worker は 1 件終わるたびに次の仕事を取りに行くので、
同時に動いている mapper は常に最大 3 個 です。
ここが Promise.all() との一番大きな違いです。
よくある落とし穴
- 結果の順番が崩れる
完了順に push すると順番が壊れます。
results[currentIndex] = ...
のように、元の index に入れる のが大事です。
- limit が 0 以下
このケースを無視すると挙動が曖昧になります。
最初に弾いたほうが安全です。
- エラー時の振る舞いを決めていない
今回の実装では、1 件でも mapper が reject すると全体も reject します。
これは Promise.all() と似た挙動です。
要件によっては、
エラーを握りつぶす
fulfilled / rejected を全部集める
リトライする
などの設計もありえます。
面接で見られやすいポイント
この問題はアルゴリズム問題というより、非同期制御をどう分解して考えるか を見られやすいです。
面接では次の説明ができるとかなり強いと思います。
なぜ Promise.all() では不十分なのか
並列数制限が必要な現実的なケース
順番維持の方法
エラー時の設計方針
フロントエンドでも、画像処理・アップロード・複数 API 呼び出しなどで普通に出てくる考え方です。
まとめ
mapAsyncLimit の本質はこれだけです。
全件を一気に始めない
worker を固定数だけ動かす
次の仕事を順番に取りに行く
結果は元 index に保存する
こういう問題は、ただ async/await を知っているかよりも、
非同期処理をどう整理して設計できるか が出やすくて面白いです。
もしこういうフロントエンド寄りの JavaScript / UI interview 問題が好きなら、
実装ベースで練習できる問題をまとめたサイトも作っています。
Discussion