そのawait、本当に必要? 不要なawaitを避けるための考え方
はじめに
JavaScript(TypeScript)で非同期処理を書く際に、流れでなんとなくawaitを書いていないでしょうか?
私はこれまで、fetch()やres.json()などを呼び出したらセットでawaitを書いてしまっていましたが、コードレビューで不要なawaitを書いてしまっているとの指摘を受けました。
そのため本記事では、非同期処理でawaitを書かなくても良いケースと、不要なawaitを書いたことによるパフォーマンスの差について調査した結果を記載します。
結論としては、次のとおりです。
- 不必要に
awaitを使用するとパフォーマンスは悪化する - 非同期処理の実行結果を参照しない場合
awaitは不要
awaitが不要となる場合
今回指摘を受けたコードは下記のようなものです。
async function getUsers() {
const res = await fetch("/api/users");
if (res.status !== 200) {
throw new Error("エラーだよ");
}
const data = await res.json();
return data;
}
内容としては、fetch()を呼び出し、ステータスコードで成否を判定し、問題なければres.json()を呼び出して中身のデータを返すといったものです。
上記の書き方でも、動作に特に問題はありません。
しかし、このコードではawait res.json()のawaitは不要な処理となります。
なぜなら、getUsers()関数内ではres.json()の実行結果を参照せず、そのまま返しているだけだからです。
これはつまり、getUsers()関数の呼び出し元では下記のようにawait getUsers()のように書くことになり、getUsers()内部でawait res.json()と書いても書かなくても、動作に違いは出ないことになります(返されるのはいずれも Promise<User[]> であり、呼び出し元での扱いが変わらないためです)。
// getUsersの呼び出し元
async function main() {
const users = await getUsers(); // ← getUsersがasync functionなのでawaitする必要がある
// usersを使用する処理
}
// getUsers再掲
async function getUsers() {
const res = await fetch("/api/users");
if (res.status !== 200) {
throw new Error("エラーだよ");
}
const data = await res.json();
return data;
}
逆に、必要なawaitはawait fetch()となります。
なぜなら、getUsers()関数では、ステータスコードによるハンドリングとしてres.statusを参照する必要があり、そのためにはawaitを使用してPromiseを解決する必要があるためです。
また、わかりやすくTypeScriptの世界で考えた場合、getUsers()の戻り値の型に着目すると、await res.json()と書いてもres.json()と書いても型はどちらもPromise<User[]>になります。
type User = {
id: number;
name: string;
}
// getUsers再掲(戻り値の型はPromise<User[]>)
async function getUsers(): Promise<User[]> {
const res = await fetch("/api/users");
if (res.status !== 200) {
throw new Error("エラーだよ");
}
const data = await res.json();
return data;
}
// awaitしないバージョン(戻り値の型はPromise<User[]>)
async function withoutAwaitGetUsers(): Promise<User[]> {
const res = await fetch("/api/users");
if (res.status !== 200) {
throw new Error("エラーだよ");
}
- const data = await res.json();
+ const data = res.json();
return data;
}
このように、内部でres.json()にawaitを使用しているかどうかに関わらず、呼び出し元でその実行結果を参照する場合は、awaitを使用することでPromise<User[]>からUser[]を取得する必要があることがわかります。
不要なawaitを書くことによる影響
動きとして変わらないのであれば、awaitをつけていても良いのではないか?という解釈もあります。
そこで、実際にawaitをつけることでパフォーマンスにどの程度の影響が出るか、簡単に検証しました。
処理の中身は、Promise.resolveをawaitするasync関数と、awaitしない通常の関数を100万回呼び出すような簡易的なものです。
stackblitz上でそれぞれ5回ずつ実行した結果は次のとおりです。
| 回数 | awaitするasync関数 | awaitしない通常の関数 |
|---|---|---|
| 1回目 | 2.697s | 2.465s |
| 2回目 | 2.697s | 2.478s |
| 3回目 | 2.702s | 2.473s |
| 4回目 | 2.734s | 2.463s |
| 5回目 | 2.772s | 2.492s |
平均して比較すると、awaitしない方が約0.25秒早い結果になりました。
今回検証した処理は、I/Oの待ち時間と比較すると無視できるレベルにはなるものの、コードの違いが関数内部でawaitを書くか書かないかの差だけであり、敢えてパフォーマンスを落とす必要はないため、確かに不要なawaitを避けることには意味がありました。
awaitを書くことによるオーバーヘッド
awaitを書く場合と、書かない場合とで処理を比較した場合、下記のような違いがあります。
awaitを書く場合
-
Promiseの完了(fulfillまたはreject)を待つ(中断あり) - その関数の処理を中断し、イベントループ[1]のキューに再開する処理が登録される
-
Promiseが解決されると、処理が再開されreturn文が実行される(ただし、返り値としてはPromiseになる) - 呼び出し元が
awaitするタイミングでイベントループのキューに登録
awaitを書かない場合
- 呼び出し元に
Promiseの状態で返す(中断なし) - 呼び出し元が
awaitするタイミングでイベントループのキューに登録
つまり、awaitを書く場合は関数内で一旦処理が中断してしまうことになってしまいますが、awaitを書かない場合はその中断をスキップできることになります。
結果として、追加のイベントループへのスケジューリングコストと、余分なPromiseの生成によりオーバーヘッドが発生します。
非同期処理であるres.json()について
今回の趣旨から少しずれますが、awaitの使い方に関連して、別で注意した方が良いケースがあります。
それは、下記のようにawait res.json()の実行結果を参照するような処理の場合、即座にres.json()をawaitするケースです。
async function getUsersAndProcess() {
const res = await fetch("/api/user");
const data = await res.json(); // ← 即座にawaitする
// dataを参照する処理
}
コードからも自明ですが、fetch()とres.json()はどちらも非同期関数で、awaitすることでフェッチが完了するまで待機することになりますが、両者の処理が完了となるタイミングは異なります。
await fetch()はレスポンスヘッダーの受信が完了するまで待機するのに対して、await res.json()はレスポンスボディの受信が完了するまで待機することになります。
そのため、ステータスコードによる判定でハンドリングが可能な場合でも、await res.json()を即座に呼び出してしまうと、不必要にレスポンスボディの受信完了まで待つことになってしまいます。
フェッチする側としては、ステータスコードによるハンドリングをawait res.json()より前に実行することで、無駄な待機時間を回避することができます。
const res = await fetch("/api/user");
if (res.status !== 200) {
return null;
}
const data = await res.json();
この辺りの内容については、詳しくは以下の記事が理解しやすいかと思います。
このように、不要なawaitを避けることで、無駄な待機時間やリソース消費を防ぐことができます。
非同期処理を深く理解していなくても、上記のようなコードを書くことは多いとは思いますが、その理由を理解しておくことが大事かと思います。
まとめ
- 非同期処理の結果を参照しない場合
awaitは不要であり、逆に使用することでパフォーマンスが落ちてしまう。 - ステータスコードによる早期リターンが可能な場合は、
await res.json()の実行前に処理することで、無駄な待機時間の発生を防ぐことが可能。
今後はasync/awaitを使うときに流れで書くのではなく、「ここでは本当にawaitが必要か?」を意識してコードを書くようにしたいと思います。
当たり前のことですが、なんとなくで書くのではなく、きちんと理解してコードを書くことが大切ですね。
追記
-
非同期の処理を順番に実行するための仕組み ↩︎
Discussion
async functionか否かという違いもありますが、両方ともasync function同士で比較するとまた違った結果になりそうで面白いですね
ありがとうございます。
確かに下記のような3パターンが考えられ、それぞれで比較すべきだと思ったので、別の記事でまとめてみようと思います。
await null も観ていただけるとありがたいです。( ※ queueMicrotask 相当の挙動
とても勉強になりました!(不要なawaitしまくってました🥺)
私みたいに「一旦何も考えずに、awaitついてるから安心」みたいな気持ちの人が大多数なので、eslintとかで静的にエラーにする仕組みがないと啓蒙コストの方が高そうかも、、?🤔
ありがとうございます!
仰る通りで規約化などしようとすると啓蒙コストは高そうですね。
また、その必要性自体もかなり限定的だとも思うので、開発メンバー全員で遵守すべきものでもないのかなと思っています。(記事を書いておいてなんですが)
とはいえ、「なんとなく」で書くのと、理解したうえで書くのとでは大きな差があると思うので、少しでも気付きを得てもらえていたら嬉しいです。
ESLint のルールで
return await asyncFunc();を禁止するルールがあったので、長年何も考えずに従ってきましたが、こういう背景があったのですね。このようなルールが実際に存在することは知らなかったので、ありがとうございます。
また上記に関連して、再度調査した中で
awaitを使用することによるメリットも存在し、学びがあったため別記事で書こうと思います。