その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
する/しないの非同期関数を100万回呼び出すような簡易的なものです。
stackblitz上でそれぞれ5回ずつ実行した結果は次のとおりです。
回数 | awaitする場合 | 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しまくってました🥺)
私みたいに「一旦何も考えずに、awaitついてるから安心」みたいな気持ちの人が大多数なので、eslintとかで静的にエラーにする仕組みがないと啓蒙コストの方が高そうかも、、?🤔
ありがとうございます!
仰る通りで規約化などしようとすると啓蒙コストは高そうですね。
また、その必要性自体もかなり限定的だとも思うので、開発メンバー全員で遵守すべきものでもないのかなと思っています。(記事を書いておいてなんですが)
とはいえ、「なんとなく」で書くのと、理解したうえで書くのとでは大きな差があると思うので、少しでも気付きを得てもらえていたら嬉しいです。
ESLint のルールで
return await asyncFunc();
を禁止するルールがあったので、長年何も考えずに従ってきましたが、こういう背景があったのですね。このようなルールが実際に存在することは知らなかったので、ありがとうございます。
また上記に関連して、再度調査した中で
await
を使用することによるメリットも存在し、学びがあったため別記事で書こうと思います。