Promise は new した瞬間から実行され始める・Promise.all の同時実行数を制御する

JavaScript の Promise について、今さらなお話を2つ。

Promise は new Promise() と書いた瞬間から実行され始める

まずは、Promise は new Promise() と書いた瞬間から実行され始めている、というお話。

どういうことか。例として、次のような Node.js スクリプトを書いてみた。

const fs = require('fs').promises;

(async () => {
  console.log(`${new Date().toISOString()} Start`);
  
  const file1 = new Promise((resolve) => {
    console.log(`${new Date().toISOString()} file1 Start`);
    fs.writeFile('./file1.txt', `${new Date().toISOString()} file1 Test`, 'utf-8').then(() => {
      console.log(`${new Date().toISOString()} file1 Created`);
      resolve('file1 Created!');
    });
  });
  
  const file2 = new Promise(async (resolve) => {
    console.log(`${new Date().toISOString()} file2 Start`);
    await fs.writeFile('./file2.txt', `${new Date().toISOString()} file2 Test`, 'utf-8');
    console.log(`${new Date().toISOString()} file2 Created`);
    resolve('file2 Created!');
  });
  
  console.log(`${new Date().toISOString()} Wait 5 Seconds Start`);
  await new Promise((resolve) => setTimeout(resolve, 5000));
  console.log(`${new Date().toISOString()} Wait 5 Seconds End`);
  
  console.log(`${new Date().toISOString()} Promise.all() Start`);
  const results = await Promise.all([file1, file2]);
  console.log(`${new Date().toISOString()} Promise.all() Finished : [${results}]`);
  
  console.log(`${new Date().toISOString()} Finished`);
})();

このスクリプトを実行した結果は次のとおり。

$ node ./example.js
2022-03-13T03:50:29.543Z Start
2022-03-13T03:50:29.546Z file1 Start
2022-03-13T03:50:29.547Z file2 Start
2022-03-13T03:50:29.547Z Wait 5 Seconds Start
2022-03-13T03:50:29.549Z file1 Created
2022-03-13T03:50:29.549Z file2 Created
2022-03-13T03:50:34.554Z Wait 5 Seconds End
2022-03-13T03:50:34.554Z Promise.all() Start
2022-03-13T03:50:34.555Z Promise.all() Finished : [file1 Created!,file2 Created!]
2022-03-13T03:50:34.555Z Finished

$ cat ./file1.txt
2022-03-13T03:50:29.546Z file1 Test

$ cat ./file2.txt
2022-03-13T03:50:29.547Z file2 Test

const file1const file2 は、いずれも new Promise(); を書いただけ。それぞれの Promise の終了を待機しているのは await Promise.all() の部分なので、それぞれの Promise 処理が始まるのは5秒待機した後、Promise.all() の行からなのでは?と勘違いしやすい。

しかし実際は、const file1const file2new Promise() を宣言した直後から処理が開始していて、5秒待機する処理の段階で既にファイル生成が終了していることが、コンソールログやファイル内に書き込んだ時刻から確認できる。Promise.all() 部分は Promise の完了をきちんと待機しているものの、非同期処理が開始しているポイントではないのだ。

もう少し別の例を見てみよう。

(async () => {
  console.log(`${new Date().toISOString()} Start`);
  
  const promises = [
    new Promise((resolve) => setTimeout(resolve('Promise 1'), 1000)),
    new Promise((resolve) => setTimeout(resolve('Promise 2'), 2000)),
    new Promise((resolve) => setTimeout(resolve('Promise 3'), 3000))
  ];
  console.log(`${new Date().toISOString()} Promises Created`);
  
  console.log(`${new Date().toISOString()} Wait 5 Seconds Start`);
  await new Promise((resolve) => setTimeout(resolve, 5000));
  console.log(`${new Date().toISOString()} Wait 5 Seconds End`);
  
  console.log(`${new Date().toISOString()} Promise.all() Start`);
  const results = await Promise.all(promises);
  console.log(`${new Date().toISOString()} Promise.all() Finished : [${results}]`);
  
  console.log(`${new Date().toISOString()} Finished`);
})();

実行結果は次のとおり。Promise.all() StartPromise.all() Finishedほぼ同時なところに注目してほしい。

$ node ./example.js
2022-03-13T04:03:20.808Z Start
2022-03-13T04:03:20.812Z Promises Created
2022-03-13T04:03:20.812Z Wait 5 Seconds Start
2022-03-13T04:03:25.818Z Wait 5 Seconds End
2022-03-13T04:03:25.819Z Promise.all() Start
2022-03-13T04:03:25.819Z Promise.all() Finished : [Promise 1,Promise 2,Promise 3]
2022-03-13T04:03:25.819Z Finished

Promise.all() 部分で初めて非同期処理を開始させるには?

それでは、Promise.all() を書いた行で初めて Promise 処理を開始させるにはどうしたら良いのか?結論をいうと、「Promise を返す関数」を配列にしておき、Promise.all() の実行時点でそれぞれの関数を実行すればいい

最初に書いたファイル生成のサンプルコードを改良して、次のように書いてみた。

const fs = require('fs').promises;

(async () => {
  console.log(`${new Date().toISOString()} Start`);
  
  const file1 = () => {
    return new Promise((resolve) => {
      console.log(`${new Date().toISOString()} file1 Start`);
      fs.writeFile('./file1.txt', `${new Date().toISOString()} file1 Test`, 'utf-8').then(() => {
        console.log(`${new Date().toISOString()} file1 Created`);
        resolve('file1 Created!');
      });
    });
  };
  
  const file2 = () => {
    return new Promise(async (resolve) => {
      console.log(`${new Date().toISOString()} file2 Start`);
      await fs.writeFile('./file2.txt', `${new Date().toISOString()} file2 Test`, 'utf-8');
      console.log(`${new Date().toISOString()} file2 Created`);
      resolve('file2 Created!');
    });
  };
  
  console.log(`${new Date().toISOString()} Wait 5 Seconds Start`);
  await new Promise((resolve) => setTimeout(resolve, 5000));
  console.log(`${new Date().toISOString()} Wait 5 Seconds End`);
  
  console.log(`${new Date().toISOString()} Promise.all() Start`);
  const results = await Promise.all([file1, file2].map((promiseFunction) => promiseFunction()));
  console.log(`${new Date().toISOString()} Promise.all() Finished : [${results}]`);
  
  console.log(`${new Date().toISOString()} Finished`);
})();

違いは、const file1const file2 の宣言部分。const file1 = new Promise(); と書いていた部分が、const file1 = () => new Promise(); というように、new Promise() オブジェクトを return する関数の宣言、という形になっている。file1file2 は元々 Promise オブジェクトだったが、改良後のコードでは Function になった、というワケだ。

もう一つの違いは Promise.all() の引数部分で、元は [file1, file2] としていたところに map() が加わっている点。それぞれは「Promise を返す関数 (promiseFunction)」なので、map() の中で関数を実行し、Promise オブジェクトの配列に変換している。要するにこの map() のコールバック関数の時点で、初めて Promise 処理が開始しているということになる。

それではこの改良後のコードの実行結果を見てみよう。Start の後に「Promise を返す関数」を2つ宣言して5秒待機しているが、この時点では何もコンソールログが出力されていない。Promise.all() Start の行の後に初めて file1file2 の処理が始まっていて、当初期待していた位置で非同期処理が始められていることが分かる。

$ node ./example.js
2022-03-13T04:09:57.053Z Start
2022-03-13T04:09:57.055Z Wait 5 Seconds Start
2022-03-13T04:10:02.062Z Wait 5 Seconds End
2022-03-13T04:10:02.062Z Promise.all() Start
2022-03-13T04:10:02.062Z file1 Start
2022-03-13T04:10:02.063Z file2 Start
2022-03-13T04:10:02.064Z file1 Created
2022-03-13T04:10:02.064Z file2 Created
2022-03-13T04:10:02.064Z Promise.all() Finished : [file1 Created!,file2 Created!]
2022-03-13T04:10:02.064Z Finished

$ cat ./file1.txt
2022-03-13T04:10:02.062Z file1 Test

$ cat ./file2.txt
2022-03-13T04:10:02.063Z file2 Test

Promise の挙動として当たり前なことではあるのだが、コレを押さえておかないと、並列実行数を制御するコードが正しく書けない。

Promise.all() の並列実行数を制御するコードを書く

さて、それでは、Promise.all() による並列実行数の制御方法に話を移す。

例えば、「全100回の API コールを行うが、同時リクエスト数は 10 個に制限したい」といった場合の書き方を押さえておく。他にも、

というような感じで、Promise.all() に大量の配列を一気に渡して処理させるのはおっかない場合があると思うので、その制御方法を見ていこう。

const fs   = require('fs').promises;
const https = require('https');

/**
 * `https` モジュールを使ってリクエストする
 * 
 * @param {string} url リクエスト先 URL
 * @param {*} options オプション
 * @return {string} レスポンス文字列
 * @throws リクエストエラー・リクエストタイムアウト時
 */
const request = (url, options = {}) => new Promise((resolve, reject) => {
  const req = https.request(url, options, (res) => {
    res.setEncoding('utf8');
    let data = '';
    res.on('data', (chunk) => data += chunk)
       .on('end' , ()      => resolve(data));
  })
    .on('error'  , (error) => reject(error))
    .on('timeout', ()      => { req.destroy(); reject('Request Timeout'); });
  req.end();
});

/**
 * `Promise.all()` の同時実行数を制限しながら処理する
 * 
 * @param {Array<() => Promise<T>>} promiseFunctions Promise を返す関数の配列
 * @param {number} concurrencyLimit 同時実行数・デフォルト値は 5 にしておく
 * @param {Promise<Array<*>>} 全ての実行結果の配列
 */
 const promiseAllWithConcurrencyLimit = async (promiseFunctions, concurrencyLimit = 5) => {
  const results = [];    // 全ての実行結果を格納する配列
  let currentIndex = 0;  // ループ処理で管理する添字
  while(true) {
    // 引数 `promiseFunctions` より `concurrencyLimit` の数だけ要素を抜き出す
    const chunkPromiseFunctions = promiseFunctions.slice(currentIndex, currentIndex + concurrencyLimit);
    // 全要素の処理が終了した場合は `while` ループを抜ける
    if(!chunkPromiseFunctions.length) break;
    
    // Promise を返す関数を `Promise.all()` 内で初めて実行する
    const currentResults = await Promise.all(chunkPromiseFunctions.map((chunkPromiseFunction) => chunkPromiseFunction()));
    // 実行結果を格納する
    results.push(...currentResults);
    // 添字を更新する
    currentIndex += concurrencyLimit;
  }
  return results;
};

(async () => {
  console.log(`${new Date().toISOString()} Start`);
  
  // リクエストしたい URL が100個あるテイ
  const urls = [
    'https://example.com/example-1',
    'https://example.com/example-2',
    'https://example.com/example-3',
    // …中略…
    'https://example.com/example-98',
    'https://example.com/example-99',
    'https://example.com/example-100'
  ];
  // 「Promise を返す関数」の配列を組み立てておく
  const promiseFunctions = urls.map((url) => () => request(url));
  
  // 非同期処理を 10 個ずつ並列実行していく
  console.log(`${new Date().toISOString()} promiseAllWithConcurrencyLimit() Start`);
  const results = await promiseAllWithConcurrencyLimit(promiseFunctions, 10);
  console.log(`${new Date().toISOString()} promiseAllWithConcurrencyLimit() Finished`);
  
  console.log(`${new Date().toISOString()} Finished`);
})();

キモとなるのは promiseAllWithConcurrencyLimit() 関数。この関数に対して、「Promise を返す関数の配列」と「同時実行数」を引数に渡してやれば、適切に並列実行数を制御できる。

やっていることは単純で、「Promise を返す関数の配列」を指定の個数に slice() でちぎって、その数だけ Promise.all() を実行、結果を配列に蓄えておいて全要素の処理が終わったら終了、という流れだ。

// 要するにこんな状態になるようにしている
const results1to5  = await Promise.all([p1, p2, p3, p4, p5]);
const results6to10 = await Promise.all([p6, p7, p8, p9, p10]);
const allResults = [...results1to5, ...results6to10];

ココでは1件のリクエスト処理が完了するのに1秒かかるテイだとして、10個並列実行しても1秒ちょっとで終わるとする。ということは、全100リクエストを10個ずつに分解するので、1秒 × 10回 = 10秒程度で処理が完了すれば、ちゃんと10件ずつ並列実行できていることになる。

$ node ./example.js
2022-03-13T04:49:18.671Z Start
2022-03-13T04:49:18.673Z promiseAllWithConcurrencyLimit() Start
2022-03-13T04:49:28.692Z promiseAllWithConcurrencyLimit() Finished
2022-03-13T04:49:28.693Z Finished

時刻を見てもらえば分かるとおり、10秒ちょっとかかって promiseAllWithConcurrencyLimit() 処理が終わっているので、全体で10秒かけて、100件のリクエスト処理が完了したと分かる。もっと関数内で細かくログ出ししてもらえば、詳細も確認できるだろう。

今回参考にしたのは以下の記事。

Promise.all() を闇雲に使うと並列実行数が制御されずにおっかないなーと思っていたので、今回 Promise 処理が開始されるタイミングをちゃんと押さえ直したうえで、並列実行数を制御するための考え方を理解できた。