見出し画像

forループ内でawaitしたらESLintにtoo heavyweightって言われたから本当なのか試してみた ESLintのno-await-in-loopルールの意味

はじめに

こんにちは、SHIFT の開発部門に所属している Katayama です。

以下のように JavaScript(node.js)の非同期処理において Promise が返される関数を for 文内で await するようなコードを書くとそれぞれ、ESLint からエラーが表示される。

いずれも以下のように書かれている通り、繰り返し処理する際にパフォーマンスが低下するので、この書き方は NG と指摘をしている。

no-await-in-loopからの引用。

Performing an operation on each element of an iterable is a common task. However, performing an await as part of each operation is an indication that the program is not taking full advantage of the parallelization benefits of async/await.(イテラブルの各要素に対して演算を行うことは、一般的な作業である。しかし、それぞれの操作の一部として await を実行することは、プログラムが async/await の並列化の利点を十分に活用していないことを示すものです。) Usually, the code should be refactored to create all the promises at once, then get access to the results using Promise.all(). Otherwise, each successive operation will not start until the previous one has completed.(通常は、すべてのプロミスを一度に作成し、その結果に Promise.all() を使ってアクセスするようにコードをリファクタリングする必要があります。そうでなければ、連続する各操作は前のものが完了するまで始まりません。)

・VS Code に表示されているメッセージ(eslint-config-airbnb-base のstyle.js)からの引用。

iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations.(イテレータ/ジェネレータは regenerator-runtime を必要とし、このガイドでは重すぎるため許可しません。これとは別に、ループは避け、配列の反復処理を優先すべきです。)

上記の ESLint のエラーの解決策は、stackoverflow にあるStuck with eslint Error i.e Separately, loops should be avoided in favor of array iterationsUnexpected await inside a loop. (no-await-in-loop)に書かれている通り、map を利用して非同期処理を並列化し、Promise.all で並列化した Promise 全ての結果を単一の Promise にしてそれを await する事で並列化した全ての Promise の処理完了を待つという方法が取られる。

これは良く行われる実装だが、実際にどれだけのパフォーマンスの差が出るのか?気になったので実際に for()で await をした場合と、map で await をした場合の処理のパフォーマンスについて検証してみようと思う。

※上記の ESLint のエラーは、いずれも ESLint にてeslint-config-airbnb-baseを継承し、以下のように設定している場合の話。2022 年 4 月 19 日現在、no-await-in-loop についてはerrors.jsに、no-restricted-syntax についてはstyle.jsに、それぞれルールが設定されている。

$ yarn add --dev eslint-config-airbnb-base
{
	...
	"extends": [
		...
		"airbnb-base",
		...
	],
	...
}

for()文で await をした場合のパフォーマンス

以下のような Promise を返す関数を for()文内で await してみる。実行は簡単に Chrome の console 上で実際に実行してみて確かめた。

function funcPromise(v) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(v);
      resolve(v);
    }, 100);
  });
}
async function test() {
  const start = performance.now();

  const array = [...Array(100)].map((_, i) => i);
  for (let index = 0; index < array.length; index += 1) {
    await funcPromise(index);
  }

  const end = performance.now();
  console.log(end - start);
}

実行結果は以下の動画の通りで、100 ミリ秒ごとに数字が console 上に出力され、合計で 10088 ミリ秒(10.088 秒)かかっている事が分かる。これは Promise を返す処理を 1 つ 1 つ待っている(直列的な処理になっている)ため(ちなみに 3 回試行した際の平均値は 10085 ミリ秒(10.085 秒)だった)。

※今回のパフォーマンス検証のために利用した performance.now()についてはPerformanceを参照。

map()で await をした場合のパフォーマンス

次にmap()内で await をする場合の処理実行のパフォーマンスを検証してみる。「for()文で await をした場合のパフォーマンス」と同様に、以下の関数を Chrome の console 上に定義し、呼び出して検証してみる。

async function test() {
  const start = performance.now();

  const array = [...Array(100)].map((_, i) => i);
  const result = await Promise.all(
    array.map(async function (item) {
      const v = await funcPromise(item);
      return v;
    })
  );
  console.log(result);

  const end = performance.now();
  console.log(end - start);
}

実行結果は以下の動画の通りで、「for()文で await をした場合のパフォーマンス」とは違い、ほぼ同時に数字が console 上に出力され、合計で 115.39 ミリ秒(0.115 秒)しかかかっていない事が分かる(ちなみに 3 回試行した際の平均値は 116.25 ミリ秒(0.116 秒)だった)。これは Promise を返す処理を 1 つ 1 つの完了(resolve)を待つのではなく、それぞれの処理を並列に処理しているため(map 内で await をすればその中は同期的な処理になるが)。

公式に以下のように書かれている通り、Promise.all の返り値は Promise が渡された順に並ぶ。つまり、今回で言えば、並列処理されるとはいえ、array から値を取り出すのが同時に行われるわけではなく、それ自体は 0 から順番に行われているので、Promise が渡された順番に Promise.all の返り値も並んでいる(ただし、map で並列に走る処理の実行自体は非同期なので、その処理の実行自体が順番になるわけではない(Promise.all の返り値と一致しない)。これについては「Promise.all の注意」を参照)。

Returned values will be in order of the Promises passed, regardless of completion order.(返値は、実行完了順とは関係なく、 Promise が渡された順に並びます。)

イメージとしては以下の図のような感じだろう(以下は「Promise.all の注意」に書いた内容を反映し、処理自体が非同期に処理されるので、処理の完了まで順番通りにならない事を図示している。上記の setTimeout の例では一律 100 ミリ秒待機なので処理の実行結果も Promise.all の返り値と同じように順番通りだが、必ずしもそうはならない)。

Promise.all の注意

Promise.all の返り値は Promise.all に Promise が渡された順に並ぶ、というのは実際に以下のようなコードの実行結果で分かる。また、処理の実行自体が順番になるわけではなく、それぞれの処理が完了したタイミングで console 上にログが出力され、Promise.all の返り値と順番が一致しない事も分かる。

動画で実行していたコードは以下。

const funcPromise1000 = (v) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log(v);
      resolve(v);
    }, 1000);
  });

const funcPromise2000 = (v) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log(v);
      resolve(v);
    }, 2000);
  });

const funcPromise3000 = (v) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log(v);
      resolve(v);
    }, 3000);
  });

async function test() {
  const start = performance.now();

  const array = [funcPromise3000, funcPromise2000, funcPromise1000];
  const result = await Promise.all(array.map((func) => func(func.name)));
  console.log(result);

  const end = performance.now();
  console.log(end - start);
}

まとめとして

今回は async/await を for()文内で実行した場合のパフォーマンスについて、map()との比較で検証してみた。処理の順番が重要でない場面においては map()を利用する事になると思われるが、非同期処理かつ処理の順番が重要となると for()を使わざるをえないと思われる。ただ、基本的には for()の async/await をすると速度面で不利である事は確かなので、今後は気を付けていきたいと思った。

おまけ

「for()文で await をした場合のパフォーマンス」等の章では昔ながらの書き方をしていたが、以下のように簡潔(アロー関数)で書く事もできる。

const funcPromise = (v) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log(v);
      resolve(v);
    }, 100);
  });
const test = async () => {
  const start = performance.now();

  const array = [...Array(100)].map((_, i) => i);
  for (let index = 0; index < array.length; index += 1) {
    await funcPromise(index);
  }

  const end = performance.now();
  console.log(end - start);
};
const test = async () => {
  const start = performance.now();

  const array = [...Array(100)].map((_, i) => i);
  const result = await Promise.all(array.map((item) => funcPromise(item)));
  console.log(result);

  const end = performance.now();
  console.log(end - start);
};


《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/