forループ内でawaitしたらESLintにtoo heavyweightって言われたから本当なのか試してみた ESLintのno-await-in-loopルールの意味
はじめに
こんにちは、SHIFT の開発部門に所属している Katayama です。
以下のように JavaScript(node.js)の非同期処理において Promise が返される関数を for 文内で await するようなコードを書くとそれぞれ、ESLint からエラーが表示される。
いずれも以下のように書かれている通り、繰り返し処理する際にパフォーマンスが低下するので、この書き方は NG と指摘をしている。
・no-await-in-loopからの引用。
・VS Code に表示されているメッセージ(eslint-config-airbnb-base のstyle.js)からの引用。
上記の ESLint のエラーの解決策は、stackoverflow にあるStuck with eslint Error i.e Separately, loops should be avoided in favor of array iterationsやUnexpected 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 の注意」を参照)。
イメージとしては以下の図のような感じだろう(以下は「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);
};
《この公式ブロガーの記事一覧》
お問合せはお気軽に
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/