地獄を抜けたらそこは地獄だった。
以下はHow to avoid (or escape) async/await hellという記事の日本語訳です。
How to avoid (or escape) async/await hell
async/awaitはたしかに我々をコールバック地獄から解放してくれました。
しかし、それは恐るべき地獄の、ほんのプレリュードにすぎなかったのです。
そう、async/await地獄の誕生です。
この記事ではasync/await地獄が何であるか、そしてそれから逃れるためのヒントをいくつか紹介します。
What is async/await hell
非同期JavaScriptを使用する際、しばしば複数の関数呼び出しすべてにawaitをつけがちです。
これによってパフォーマンス上の問題が発生します。
あるステートメントは別に手前のステートメントに依存はしていないのに、それが終わるまで待たせられてしまうのです。
An example of async/await hell
ピザと飲み物を注文するスクリプトを考えてみましょう。
何も考えずに実装すると以下のようになります。
(async () => {
const pizzaData = await getPizzaData() // async call
const drinkData = await getDrinkData() // async call
const chosenPizza = choosePizza() // sync call
const chosenDrink = chooseDrink() // sync call
await addPizzaToCart(chosenPizza) // async call
await addDrinkToCart(chosenDrink) // async call
orderItems() // async call
})()
一件正しそうに見えるし、実際正しく動作します。
しかしこれは良い実装ではありません。
何故ならば並行性がなくなっているからです。
問題を解決するにはどうすればいいか、順に見ていきましょう。
Explanation
コードは全体を即時関数で括られています。
このコードは正確に以下の順番で動作します。
1.ピザのリストを取得する
2.ドリンクのリストを取得する
3.リストからピザを選ぶ
4.リストからドリンクを選ぶ
5.選んだピザをカートに入れる
6.選んだドリンクをカートに入れる
7.カートを注文する
So what's wrong ?
さきほど言ったように、これらの命令は全て順番にひとつづつ実行され、並行に処理されることはありません。
ちょっと考えてみましょう。
ドリンクのリストを入手する前に、ピザのリストを取得する必要はありますか?
両方のリストは同時に取得すべきです。
しかし、ピザを選ぶ際にはピザのリストが用意されている必要があります。
ドリンクも同じです。
以上より、ピザ関連の処理とドリンク関連の処理はパラレルに行うことができますが、ピザに関する各処理は順番にひとつづつ実行していく必要があることがわかります。
Another example of bad implementation
以下のスニペットは、カート内のアイテムを全て取得して注文のリクエストを行います。
async function orderItems() {
const items = await getCartItems() // async call
const noOfItems = items.length
for(var i = 0; i < noOfItems; i++) {
await sendRequest(items[i]) // async call
}
}
このforループは、sendRequest()
が毎回完了するのを待ってから次のループに入っています。
しかし、実際には待つ必要はありません。
なるだけ早く全てのリクエストを送信し、その後で全てのリクエストが完了するまで待つように変更することができます。
async/await地獄がどれほどプログラムのパフォーマンスに深刻な悪影響を与えているのか、あなたが理解してくれることを願っています。
問い詰めたい。小一時間問い詰めたい。
What if we forget the await keyword ?
async関数の中でawaitを書き忘れていても、関数は処理を行います。
つまり関数内にawaitを書く必要はないということです。
async関数はPromiseを返すので、それを後から使用することができます。
(async () => {
const value = doSomeAsyncTask()
console.log(value) // pending
})()
ただし注意するべき点は、関数の動作が完全に終了しているかどうかをコンパイラは知らないということです。
従って、コンパイラはasyncな関数の動作が完了しているか気にせずにプログラムを終了します。
これがawaitキーワードが必要な理由です。
Promiseの興味深い特性のひとつは、Promiseの取得と解決のための待機を別の場所で行うことができるということです。
そしてこれこそが、async/await地獄から逃れるための鍵です。
(async () => {
const promise = doSomeAsyncTask()
const value = await promise
console.log(value) // settled
})()
見てのとおり、doSomeAsyncTask()
はPromiseを返します。
この時点でdoSomeAsyncTask()
の実行は開始されています。
次の行でawaitキーワードを使用し、実行が完了するまで待機しています。
awaitはその時点でプログラムの実行を一旦止め、Promiseが解決されたら次に進むようにするキーワードです。
How to get out of async/await hell ?
async/await地獄から逃れるためには、以下の手順に従ってください。
Find statements which depend on the execution of other statements
最初の例ではピザとドリンクを選んでいました。
熟考の末、ピザを選ぶ前にはピザのリストを持っている必要があると結論づけました。
また、ピザをカートに突っ込む前に、突っ込むピザを選んでおく必要があります。
従って、この3ステップはお互いに依存していると言えます。
手前の処理を終えるまで、次の処理に進むことはできません。
ところでピザの選択はドリンクの選択に一切依存しないため、ピザの選択とドリンクの選択は並行して実行することができます。
他のステートメントに依存するステートメントと、依存しないステートメントを切り分けることができました。
Group-dependent statements in async functions
これまで見てきたように、ピザの選択にはピザのリスト取得が必要で、取得したピザはカートに突っ込むといった従属関係が存在します。
これらをまとめてasync関数でグループ化しましょう。
このようにしてselectPizza()
とselectDrink()
のふたつの非同期関数を得ることができます。
Execute these async functions concurrently
次に、このふたつの非同期関数をイベントループで同時に実行します。
一般的なパターンは、Promiseの早期returnと、Promise.allメソッドを使うことです。
Let's fix the examples
上記3手順を実行し、例題に適用してみます。
async function selectPizza() {
const pizzaData = await getPizzaData() // async call
const chosenPizza = choosePizza() // sync call
await addPizzaToCart(chosenPizza) // async call
}
async function selectDrink() {
const drinkData = await getDrinkData() // async call
const chosenDrink = chooseDrink() // sync call
await addDrinkToCart(chosenDrink) // async call
}
(async () => {
const pizzaPromise = selectPizza()
const drinkPromise = selectDrink()
await pizzaPromise
await drinkPromise
orderItems() // async call
})()
// もしくはこう書いてもいい
(async () => {
Promise.all([selectPizza(), selectDrink()]).then(orderItems) // async call
})()
大きくふたつの関数にグループ化されました。
関数内では、各ステートメントは前の行に依存しています。
ついで、selectPizza()
とselectDrink()
を同時に実行します。
Promise.all
の例では、全個数がいくつになるかわからないPromiseに対応する必要があります。
対応は簡単で、配列を作ってその中にPromiseを入れるだけです。
Promise.all
は配列内の全てのPromiseが完了するまで待機してくれます。
async function orderItems() {
const items = await getCartItems() // async call
const noOfItems = items.length
const promises = []
for(var i = 0; i < noOfItems; i++) {
const orderPromise = sendRequest(items[i]) // async call
promises.push(orderPromise) // sync call
}
await Promise.all(promises) // async call
}
この記事が、async/awaitの基本とパフォーマンス向上に対する理解のために役立ってくれることを願っています。
この記事が気に入ったら拍手してください。
ヒント:50回拍手できます
FbやTwitterで共有してください。
記事の更新をチェックしたいときはTwitterとmediumをフォローしてください。
ツッコミがあればコメントしてください。
感想
async/awaitはコールバック地獄を解消することには成功したが、だからといって不用意に使うと不要な待ち時間が増えてしまいますよ、という内容。
正直地獄ってほどかと思わないでもないが、実際それに対応すると再びソースが面倒なことになる。
今回は完全に独立していた2グループだったからよかったものの、複数の関連がある複数のグループをまとめようと思うと気苦労は計り知れない。
焦熱地獄から旧地獄にランクアップした感はあるものの、地獄温泉はまだまだ遠い。