awaitあり・なしで何が変わる?パフォーマンスと挙動を再比較
前回、不要なawaitについて書いた記事の続きです。
こちらの記事では、なんとなくで書きがちなawaitについて、不要となるケースを記載していました。
async function getUsers() {
const res = await fetch("/api/users");
if (res.status !== 200) {
throw new Error("エラーだよ");
}
const data = await res.json(); // ← ここでのawaitはあってもなくても動作は変わらない
return data;
}
その中で実際にパフォーマンスの比較を行いましたが、比較対象が不十分だったため、本記事で改めて検証結果を記載します。
結論としては次のとおりです。
-
今回の検証結果では、下記の順にパフォーマンスが良い
- 通常の関数でPromiseを返す
- async関数で
awaitせずにPromiseを返す - async関数で
awaitしてPromiseを返す
- 一方で
awaitを使用することで、例外をキャッチできるスコープや、スタックトレースの内容に差がある - 状況に合わせて
awaitを使い分けられることが理想的
パフォーマンスの比較
今回は下記の3パターンで比較してみました。
async awaitasync no awaitno async
それぞれ下記のように定義しています。
// async関数でawaitする
async function asyncAwait() {
return await Promise.resolve(0);
}
// async関数でawaitしない
async function asyncNoAwait() {
return Promise.resolve(0);
}
// 通常の関数
function noAsync() {
return Promise.resolve(0);
}
これらをvitestのベンチマークテストで検証した結果がこちらです。
実行環境
- node v22.13.1
- vitest v3.1.1
✓ async.bench.js 4582ms
name hz min max mean p75 p99 p995 p999 rme samples
· asyncAwait 7,684,032.76 0.0000 1.3435 0.0001 0.0001 0.0002 0.0002 0.0003 ±0.66% 3842017 slowest
· asyncNoAwait 8,264,645.39 0.0000 0.1803 0.0001 0.0001 0.0002 0.0002 0.0003 ±0.46% 4132323
· noAsync 9,514,842.17 0.0000 6.2526 0.0001 0.0001 0.0001 0.0002 0.0003 ±2.52% 4757422 fastest
BENCH Summary
noAsync - async.bench.js
1.15x faster than asyncNoAwait
1.24x faster than asyncAwait
結果が一見して分かりづらいため、実行速度順に整理すると次のようになります。
// 通常の関数(fastest)
function noAsync() {
return Promise.resolve(0);
}
// async関数でawaitしない
async function asyncNoAwait() {
return Promise.resolve(0);
}
// async関数でawaitする(slowest)
async function asyncAwait() {
return await Promise.resolve(0);
}
また、noAsyncは、
-
asyncNoAwaitに比べて1.15倍速い -
asyncAwaitに比べて1.24倍速い
結果となりました。
【参考】StackBlitzでの比較
ちなみにStackBlitzで検証した場合、asyncAwaitとasyncNoAwaitの順序が逆転する結果となりました。
これは、StackBlitzがWebContainerで実行されているなどが関係していそうですが、詳しく特定することはできていません。
以上の結果から、パフォーマンスの観点では、前回の結果と同じく通常の関数としてPromiseをそのまま返すことが最も有利という結果になりました。
しかし、通常のAPI呼び出しを考えると、ステータスコードの判定を行う目的でfetch結果に対してawaitを使用するなどで、async関数として定義することが場面のほうが多いかと思われます。
そのため、async関数同士で比較した場合についても考えます。
asyncAwait vs asyncNoAwait
前述の結果から、async関数として定義した場合、awaitを使用せずにreturnする方がパフォーマンス上有利な結果となっており、前回の記事で記載したような不要なawaitを避けることの意義が見出せます。
実際に、ESLintではasync関数でawaitを使用したreturn文を不正とするルールが存在していました。
このルールはasync関数において、awaitの使用を禁止することで、パフォーマンスの向上が図ることを示しており、逆にパフォーマンスを求めない場合は、このルールを使用しなくて良いケースとして挙げられています。
If you do not want the performance benefit of avoiding return await
しかし、現在このルールは非推奨のルールとなっており、その理由として下記のように記載されています。
The original intent of this rule was to discourage the use of return await, to avoid an extra microtask. However, due to the fact that JavaScript now handles native Promises differently, there is no longer an extra microtask. More technical information can be found in this V8 blog entry.
すなわち、従来まではasync関数にてawaitを書いた場合は余分なマイクロタスク[1]を避けるためのルールであったが、現在ではそれらの追加のマイクロタスクが不要となったため、非推奨となっています。
現状に至るまでの経緯については、こちらの記事が参考になるかと思います。
上記でも論じているように、実態としてasyncAwaitとasyncNoAwaitを比較すると、後者の方が有利ではあるものの、決定的な違いにはなり得ないといったところでしょうか。
ちなみに、マイクロタスク観点での比較はこちらの記事が詳しいです。
パフォーマンス以外の視点で見た場合
async関数内でawaitを使用する場合と使用しない場合とで、パフォーマンス以外にも違いが生じることになります
例外をキャッチする場所
awaitを使用した場合、例外はそのスコープでキャッチすることができます。
一方でawaitを使用しない場合、例外はその呼び出し元でしかキャッチすることができません。
function rejectPromise() {
return Promise.reject(new Error('エラー'));
}
// ① awaitを使用する場合
async function withAwait() {
try {
return await rejectPromise();
} catch (_e) {
console.log('withAwaitの場合はここでキャッチされる');
}
}
// ② awaitを使用しない場合
async function withoutAwait() {
try {
return rejectPromise(); // awaitしない
} catch (_e) {
console.log('ここは呼び出されない');
}
}
withAwait().catch((_e) => console.log('ここは呼び出されない'));
withoutAwait().catch((_e) => console.log('withoutAwaitの場合はここでキャッチされる'));
stackblitz
つまり、awaitを使用することでその関数内のcatch句で例外を捉えることができます。
一方で、awaitを使用しない場合は、その呼び出し元のcatch句でしか例外を捉えることができません。
この差は、awaitによってPromiseが決定されるまで待機するかどうかの違いにより生じます。
awaitでPromiseの決定を待機する場合
-
awaitによりPromiseが解決または拒否されるまで待機する -
Promise.rejectによりPromiseが拒否される - 拒否されたタイミングでは、処理がまだ関数内
- そのため関数内の
catch句で捕捉される
awaitを使用せずにPromiseの決定を待機しない場合
-
awaitがないためPromiseが解決または拒否されるまで待機しない -
Promise.rejectによりPromiseが拒否される - 拒否されたタイミングでは、関数内での処理は終了している
- そのため関数の呼び出し元の
catch句で捕捉される
スタックトレースの内容
awaitの有無で、スタックトレースの出力にも差が生じます。
async function rejectPromise() {
await Promise.resolve();
throw new Error('エラーが発生しました');
}
// ① awaitを使用する場合
async function withAwait() {
return await rejectPromise();
}
// ② awaitを使用しない場合
async function withoutAwait() {
return rejectPromise();
}
withAwait().catch((error) => console.log(error.stack));
withoutAwait().catch((error) => console.log(error.stack));
Error: エラーが発生しました
at rejectPromise (/home/projects/vitejs-vite-foquyhkl/stacktrace.js:3:9)
at async withAwait (/home/projects/vitejs-vite-foquyhkl/stacktrace.js:8:10)
Error: エラーが発生しました
at rejectPromise (/home/projects/vitejs-vite-foquyhkl/stacktrace.js:3:9)
stackblitz
まとめ
-
今回の検証結果では、下記の順にパフォーマンスが良い
- 通常の関数でPromiseを返す
- async関数で
awaitせずにPromiseを返す - async関数で
awaitしてPromiseを返す
awaitを使用することで、そのスコープ内で例外をキャッチできるawaitを使用することで、スタックトレースの内容が詳細になる
前回の記事では、パフォーマンスの観点で見ると無駄なawaitは省略すべきという内容でしたが、それ以外の違いも理解することで、状況に応じた判断を行えるのが理想だと思います。
-
同期的に実行している処理が終了した後に実行される処理(非同期処理)のこと ↩︎
Discussion