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 await
async no await
no 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