⏱️

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;
}

その中で実際にパフォーマンスの比較を行いましたが、比較対象が不十分だったため、本記事で改めて検証結果を記載します。

結論としては次のとおりです。

  • 今回の検証結果では、下記の順にパフォーマンスが良い
    1. 通常の関数でPromiseを返す
    2. async関数でawaitせずにPromiseを返す
    3. 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で検証した場合、asyncAwaitasyncNoAwaitの順序が逆転する結果となりました。

これは、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]を避けるためのルールであったが、現在ではそれらの追加のマイクロタスクが不要となったため、非推奨となっています。

現状に至るまでの経緯については、こちらの記事が参考になるかと思います。

上記でも論じているように、実態としてasyncAwaitasyncNoAwaitを比較すると、後者の方が有利ではあるものの、決定的な違いにはなり得ないといったところでしょうか。

ちなみに、マイクロタスク観点での比較はこちらの記事が詳しいです。

パフォーマンス以外の視点で見た場合

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の決定を待機する場合

  1. awaitによりPromiseが解決または拒否されるまで待機する
  2. Promise.rejectによりPromiseが拒否される
  3. 拒否されたタイミングでは、処理がまだ関数内
  4. そのため関数内のcatch句で捕捉される

awaitを使用せずにPromiseの決定を待機しない場合

  1. awaitがないためPromiseが解決または拒否されるまで待機しない
  2. Promise.rejectによりPromiseが拒否される
  3. 拒否されたタイミングでは、関数内での処理は終了している
  4. そのため関数の呼び出し元の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

まとめ

  • 今回の検証結果では、下記の順にパフォーマンスが良い
    1. 通常の関数でPromiseを返す
    2. async関数でawaitせずにPromiseを返す
    3. async関数でawaitしてPromiseを返す
  • awaitを使用することで、そのスコープ内で例外をキャッチできる
  • awaitを使用することで、スタックトレースの内容が詳細になる

前回の記事では、パフォーマンスの観点で見ると無駄なawaitは省略すべきという内容でしたが、それ以外の違いも理解することで、状況に応じた判断を行えるのが理想だと思います。

脚注
  1. 同期的に実行している処理が終了した後に実行される処理(非同期処理)のこと ↩︎

GitHubで編集を提案

Discussion