👩‍💻

#127 配列に対して行う共通処理を非同期で実行したい~forEach、Promise.all+mapを使用する方法の紹介

に公開

はじめに

最近、forEachの中で行う処理を非同期処理にするために、「Promise.allとarray.mapを使用する処理に変更する」というものを目にする機会がありました。
本記事ではPromise.allとarray.mapを使用した実装方法の紹介の他、forEachで実装した場合について動作別にコード例をいくつか紹介していきたいと思います。

Promise.all + array.mapの使用例

以下で紹介するコードでは、

  • 任意の配列の要素ごとに実行する共通の処理は並列に実施
    • = 配列の順番を保証しない
  • 共通処理の内部では、処理を順番に実施
    • = 内部処理の順番を保証する

することが期待動作となります。イメージとしては

Promise.all+mapでの期待動作

のようなものを想定しています。

const numList = [3, 1, 7];

function sleep(ms: number): Promise<void> {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function outputProcessLog(num: number, message: string): Promise<void> {
  console.log(`${message}:開始`);
  await sleep(num);
  console.log(`${message}:完了`);
}

function outputLog() {
  Promise.all(
    numList.map(async (num: number) => {
      console.log(`${num}】(・ω・)`);

      // 各タイムアウト処理
      await outputProcessLog(num * 1 * 1000, `${num}】内部処理1`);
      await outputProcessLog(num * 10 * 1000, `${num}】内部処理2`);
      await outputProcessLog(num * 5 * 1000, `${num}】内部処理3`);

      console.log(`${num}】_(:3」∠)_`);
    })
  );
}

outputLog();

実際に出力されたログは以下の通りです。

【3】(・ω・)
【3】内部処理1:開始
【1】(・ω・)
【1】内部処理1:開始
【7】(・ω・)
【7】内部処理1:開始
【1】内部処理1:完了
【1】内部処理2:開始
【3】内部処理1:完了
【3】内部処理2:開始
【7】内部処理1:完了
【7】内部処理2:開始
【1】内部処理2:完了
【1】内部処理3:開始
【1】内部処理3:完了
【1】_(:3」∠)_
【3】内部処理2:完了
【3】内部処理3:開始
【3】内部処理3:完了
【3】_(:3」∠)_
【7】内部処理2:完了
【7】内部処理3:開始
【7】内部処理3:完了
【7】_(:3」∠)_

期待通りに処理が行われていることが確認できました。
次は、こちらの処理をベースにしてforEachでの実装例がどのような動作になるのか確かめていきたいと思います。

なお、以下については共通で使用するため、以降は記載を省略させていただきます。

const numList = [3, 1, 7];

function sleep(ms: number): Promise<void> {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function outputProcessLog(num: number, message: string): Promise<void> {
  console.log(`${message}:開始`);
  await sleep(num);
  console.log(`${message}:完了`);
}

forEachを使用した動作パターン紹介

配列の順番:保証しない、内部処理順序:保証する

こちらはPromise.all + array.mapで紹介した動作と同じものとなります。(なのでイメージは省略しています)
awaitを各内部処理に対して付与することで、内部処理順序を保証するようにしています。

// 以下は共通使用のため記載を省略
// const numList
// function outputCompletedLog

numList.forEach(async (num: number) => {
    console.log(`${num}】(・ω・)`);
    
    await outputProcessLog(num * 1 * 1000, `${num}】内部処理1`);
    await outputProcessLog(num * 10 * 1000, `${num}】内部処理2`);
    await outputProcessLog(num * 5 * 1000, `${num}】内部処理3`);
    
    console.log(`${num}】_(:3」∠)_`);
});

実際に実行してみると、以下のように出力されました。

【3】(・ω・)
【3】内部処理1:開始
【1】(・ω・)
【1】内部処理1:開始
【7】(・ω・)
【7】内部処理1:開始
【1】内部処理1:完了
【1】内部処理2:開始
【3】内部処理1:完了
【3】内部処理2:開始
【7】内部処理1:完了
【7】内部処理2:開始
【1】内部処理2:完了
【1】内部処理3:開始
【1】内部処理3:完了
【1】_(:3」∠)_
【3】内部処理2:完了
【3】内部処理3:開始
【3】内部処理3:完了
【3】_(:3」∠)_
【7】内部処理2:完了
【7】内部処理3:開始
【7】内部処理3:完了
【7】_(:3」∠)_

こちらも同じ出力結果になったことが確認できました。

配列の順番:保証しない、内部処理順序:保証しない

内部処理順序を保証する必要がない場合は、Promise.allをawaitすることでPromise作成時に処理を同時に実行できるので、処理時間を早くすることができます。

// 以下は共通使用のため記載を省略
// const numList
// function outputCompletedLog

numList.forEach(async (num: number) => {
    console.log(`${num}】(・ω・)`);
    
    const process1 = outputProcessLog(num * 1 * 1000, `${num}】内部処理1`);
    const process2 = outputProcessLog(num * 10 * 1000, `${num}】内部処理2`);
    const process3 = outputProcessLog(num * 5 * 1000, `${num}】内部処理3`);

    await Promise.all([process1, process2, process3]);
    
    console.log(`${num}】_(:3」∠)_`);
});

実行した出力結果です。

【3】(・ω・)
【3】内部処理1:開始
【3】内部処理2:開始
【3】内部処理3:開始
【1】(・ω・)
【1】内部処理1:開始
【1】内部処理2:開始
【1】内部処理3:開始
【7】(・ω・)
【7】内部処理1:開始
【7】内部処理2:開始
【7】内部処理3:開始
【1】内部処理1:完了
【3】内部処理1:完了
【1】内部処理3:完了
【7】内部処理1:完了
【1】内部処理2:完了
【1】_(:3」∠)_
【3】内部処理3:完了
【3】内部処理2:完了
【3】_(:3」∠)_
【7】内部処理3:完了
【7】内部処理2:完了
【7】_(:3」∠)_

forEachを使用するときの注意点

forEachは同期的に動作する

forEachは配列の各要素に対してコールバック関数を順番に呼び出します。
ただし、forEach自体は同期的に動作するため、コールバック関数内で非同期処理を実行しても、forEachはその非同期処理が終了するのを待たずに次の要素に進んでしまいます。
例えば上記のコードを利用して

typeScript
// 以下は共通使用のため記載を省略
// const numList
// function outputCompletedLog

numList.forEach(async (num: number) => {
    console.log(`${num}】(・ω・)`);
    
    const process1 = outputProcessLog(num * 1 * 1000, `${num}】内部処理1`);
    const process2 = outputProcessLog(num * 10 * 1000, `${num}】内部処理2`);
    const process3 = outputProcessLog(num * 5 * 1000, `${num}】内部処理3`);

    await Promise.all([process1, process2, process3]);
    
    console.log(`${num}】_(:3」∠)_`);
});

console.log(`_(:3」∠)_【forEachの外の後続処理】_(:3」∠)_`);

forEachの後続処理がどのタイミングで実施されるのか確認してみましょう。

【3】(・ω・)
【3】内部処理1:開始
【3】内部処理2:開始
【3】内部処理3:開始
【1】(・ω・)
【1】内部処理1:開始
【1】内部処理2:開始
【1】内部処理3:開始
【7】(・ω・)
【7】内部処理1:開始
【7】内部処理2:開始
【7】内部処理3:開始
_(:3」∠)_【forEachの外の後続処理】_(:3」∠)_
【1】内部処理1:完了
【3】内部処理1:完了
【1】内部処理3:完了
【7】内部処理1:完了
【1】内部処理2:完了
【1】_(:3」∠)_
【3】内部処理3:完了
【3】内部処理2:完了
【3】_(:3」∠)_
【7】内部処理3:完了
【7】内部処理2:完了
【7】_(:3」∠)_

forEachの後続処理が実施されてから、forEachで呼び出した処理が完了していることが確認できました。
確実にforEachの処理が完了してから後続処理を実施する必要がある場合は、特に注意が必要ですね。

非同期処理の性質

async/awaitを使用すると非同期処理を記述できますが、awaitによって待機されるのは、その特定の非同期処理の中だけです。forEach自体が同期処理(=非同期処理の仕組みを持たない)ため、コールバックはすべての要素に対してすぐに実行されます。
そのため、「コールバック内で行われる非同期処理が終了する前に、ループ処理そのものが完了する」と言う状態になっていました。
もちろん、ループ処理が完了したからといって非同期処理が中断される訳ではないので、そのまま並行して処理は進行します。

おわりに

いかがだったでしょうか。
Promise.allとmap、forEachを使った非同期処理の実装例についてまとめてみました。
今回ご紹介したのは並行処理の方法ですが、並行処理ではなく同期処理を保証したい場合は、for...ofを使うことでawaitの非同期処理が完了するまで待つことができるそうです。
本記事では文章量が多くなってしまったため、こちらの紹介はしませんでしたが、機会があれば取り上げてみたいと思います。
こちらも、状況に応じて使用するメソッドを選択できるようになると良いですね。


以上です。最後まで閲覧いただき、ありがとうございます。

参考

MDN

Discussion