JavaScriptのasync/awaitのループの落とし穴

2024/12/06に公開

foreach, for of, map などのループ処理とasync,awaitを組み合わせたときの挙動について説明します。

JavaScriptのLoop処理

JavaScriptのfor文やwhile文のループ処理はご存知でしょうか?

jsを書いたことがある人ならば「基本文法だからそんなの知っているよ」って人が多いと思います。jsのループ処理は色々な書き方があります。

なにかの配列に対してループ処理を行う場合、配列のforEachや構文のforwhileが使えます。

配列のforEachを使ったループ処理は以下のようになります。

// 配列を引数に受け取り、forEachを使ってループ処理を行う関数
function forEachLoop(arr) {
  arr.forEach((i) => {
    console.log(i);
  });
}

// メイン関数
function main() {
  const arr = [1, 2, 3, 4, 5];
  forEachLoop(arr);
  console.log("done");
}

// 実行
main();

実行結果

alt text

for ofを使ったループ処理は以下のようになります。

// 配列を引数に受け取り、for ofを使ってループ処理を行う関数
function forOfLoop(arr) {
  for (const i of arr) {
    console.log(i);
  }
}

// メイン関数
function main() {
  const arr = [1, 2, 3, 4, 5];
  forOfLoop(arr);
  console.log("done");
}

// 実行
main();

実行結果

alt text

どちらも同じような実行結果になりました。

では、Promise, async/awaitを使ったループ処理はどうなるでしょうか。

Promise, async/awaitを使ったループ処理

Promise, async/await を使ったループ処理を書いてみましょう。

forEach

配列のforEachを使ったループ処理を書いてみます。最初に言いますが、非同期関数を扱うときはforEachは使わない方が良いです

// 指定した時間待機するPromiseを返す関数
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 配列を引数に受け取り、forEach内でasync/awaitを使ってループ処理を行う関数
function asyncForEach(arr) {
  arr.forEach(async (i) => {
    await sleep(1000);
    console.log(i);
  });
}

// メイン関数
async function main() {
    const arr = [1, 2, 3, 4, 5];
    asyncForEach(arr);
    console.log("done");
}

// 実行
main();

このコードを実行すると、doneが先に表示されてしまいます。

alt text

なぜでしょうか?

forEachは配列の各要素を引数にする関数を実行します。forEachに渡されているのはasyncのついたアロー関数です。async関数はPromiseを返します。つまり以下のような関数と似たような動作をします。

// 配列を引数に受け取り、forEach内でPromiseを使ってループ処理を行う関数
function asyncForEachWithPromise(arr) {
  arr.forEach((i) => {
    // Promiseを返す。つまり、forEachは関数が終了したとみなし、次の処理に進む。
    return new Promise(resolve => {
        sleep(1000).then(() => {
            console.log(i);
            resolve();
        });
    })
  }),
}

関数からなにかがreturnされたら、それは関数の終了を意味します。forEachPromiseのなかのsleepの処理が終わるまで待たず、次の処理を実行します。配列の各要素に対して同じように行ないforEachはすぐに終了します。
そのため、doneが先に表示されてしまいます。

以下のようにforEachの宣言にawaitを追加したとしても意味はありません。forEachの中で実行するアロー関数の返り値は破棄され、forEachはPromiseを返しません。forEach自体は何も返すことはありません。そのためarr.forEachの手前に追加したawaitは無視されます。

// 指定した時間待機するPromiseを返す関数
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 配列を引数に受け取り、forEach内でasync/awaitを使ってループ処理を行う関数
async function asyncForEach(arr) {   // async を追加
  await arr.forEach(async (i) => {   // forEachの手前に await を追加。でもPromiseが返らないので意味がない
    await sleep(1000); 
    console.log(i);
  });
}

// メイン関数
async function main() {
    const arr = [1, 2, 3, 4, 5];
    await asyncForEach(arr);       // await を追加
    console.log("done");
}

// 実行
main();

実行結果は以下のようになります。doneが先に表示されます。

alt text

forEachのアロー関数に記述されたasync/awaitは意味がないのでしょうか?

そうではありません。forEachの中に記述されたアロー関数のasyncは、アロー関数内でawaitを使えます。ただそれだけで、アロー関数の外側のforEachには影響しません。

// 配列を引数に受け取り、forEach内でasync/awaitを使ってループ処理を行う関数
function asyncForEach(arr) {
  arr.forEach(
    // ここの関数のasyncは
    async (i) => {
      // ここのawaitで意味がある。が、forEachには影響しない。
      await sleep(1000);
      console.log(i);
  });
}

このように非同期処理を行うときはforEachを使わない方が良いでしょう。

次に、forEachの代わりにfor ofを使ってみましょう。

for of

構文のfor ofを使ったループ処理は以下のようになります。

// 指定した時間待機するPromiseを返す関数
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 配列を引数に受け取り、for of内でasync/awaitを使ってループ処理を行う関数
async function asyncForOf(arr) {
  for (const i of arr) {
    await sleep(1000);
    console.log(i);
  }
}

// メイン関数
async function main() {
    const arr = [1, 2, 3, 4, 5];
    await asyncForOf(arr);
    console.log("done");
}

// 実行
main();

実行結果は以下のようになります。

alt text

実行結果を見ると、順番に1秒ずつ待機してから表示されていることがわかります。

for ofを利用したループ処理では、ループ内のawaitを待ちます。逐次実行してほしい場合はfor ofを使うと良いでしょう。

もう一度比較のために一部コードを抜粋します。

for ofawaitは、関数定義のasyncスコープで動作します。forEachのときはアロー関数のasyncスコープで動作していたのと違います。

// for of 版
async function asyncForOf(arr) {    // ← ここのasync と
  for (const i of arr) {
    await sleep(1000);              // ← ここのawaitが対応する。
    console.log(i);
  }
}

// forEach 版
function asyncForEach(arr) {
  // forEach の返り値は`void(undefind)`なので、await が効かない。
  arr.forEach(
    async (i) => {                  // ← ここのasync と
      await sleep(1000);            // ← ここのawaitが対応する。
      console.log(i);
  });
}

ということで非同期処理を逐次処理で行いときはfor ofを使いましょう。

map と Promise.all

for ofを使ったループ処理は逐次処理を行うのに適しています。しかし、並行処理をしたいときはどのようにすればよいでしょうか。たとえば配列にそれぞれの処理を行うが、それぞれの処理は独立していて順番に実行する必要がなく、すべて完了したことがわかれば良い場合です。

その場合は配列のmapPromise.allを使うと良いでしょう。

// 指定した時間待機するPromiseを返す関数
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 配列を引数に受け取り、map内でasync/awaitを使ってループ処理を行う関数
async function asyncMap(arr) {
  // mapメソッドは関数の返り値を配列にして返す
  const tasks = arr.map(async (i) => {
    await sleep(1000);
    console.log(i);
  });

  await Promise.all(tasks);
}

// メイン関数
async function main() {
    const arr = [1, 2, 3, 4, 5];
    await asyncMap(arr);
    console.log("done");
}

// 実行
main();

実行結果は以下のようになります。

alt text

並行的にsleep関数が実行されているため、各数字がほぼ同時に表示されています。そして、最後にdoneが表示されています。

配列のmapメソッドは、配列の各要素に対して関数を適用し、その返り値を新しい配列の要素として返します。今回はmapメソッドにasync関数を渡しています。async関数はPromiseを返すため、mapメソッド全体としては[Promise, Promise, Promise, ...]の配列を作成します。

Promise.allは、Promiseの配列を受け取り、全てのPromiseが終了(resolve)されるまで待ちます。全てのPromiseが終了したら、Promise.allは終了します。

よって、並行処理を行いたいときはmapPromise.allを使うと良いでしょう。

ちなみにPromise.allはすべてのPromiseの結果の配列を返します。例えば、以下のように配列の要素を2倍にして返す処理を追加できます。

// 指定した時間待機するPromiseを返す関数
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

// 配列を引数に受け取り、map内でasync/awaitを使ってループ処理を行う関数
async function asyncMap(arr) {
  // mapメソッドは関数の返り値を配列にして返す
  const tasks = arr.map(async (i) => {
    await sleep(1000);
    console.log(i);
    return i * 2;       // 2倍にして返す処理を追加
  });

  return await Promise.all(tasks);  // Promise.allの結果を返す
}

// メイン関数
async function main() {
    const arr = [1, 2, 3, 4, 5];
    const result = await asyncMap(arr);
    console.log(result);    // [2, 4, 6, 8, 10]
    console.log("done");
}

// 実行
main();

まとめ

JavaScriptのループ処理について、forEachfor ofmapを使ったループ処理を紹介しました。

  • forEachは非同期処理を行うときは使わない方が良い
  • for ofは逐次処理
  • mapPromise.allは並行処理

処理に応じて適切なループ処理を選択しましょう。

参考

GitHubで編集を提案

Discussion