return と return await の3つの違い
Promise
を返す非同期関数を扱うとき Promise
をそのまま返す書き方と Promise
を await
してから返す二通りの方法があります。
const fetchUsers1 = async () => {
return axios.get("/users");
};
// または
const fetchUsers2 = async () => {
return await axios.get("/users");
};
上記のコード例は一見するとどちらも同じように動作するように思えますが、以下のような違いがあります。
- 例外がキャッチされる場所
- スタックトレースの出力
- マイクロタスクのタイミング
例外がキャッチされる場所
初めに考えられるもっとも顕著な例として try/catch
ブロックと共に使用されている場合です。端的に説明すると return
の場合には関数の呼び出し元で例外がキャッチされますが await return
の場合には関数の内部で例外がキャッチされます。
以下の例で確認してみましょう。alwaysRejectPromise
関数は常に Promise
を拒否します。
const func1 = async () => {
try {
return alwaysRejectPromise();
} catch (e) {
console.log("func1 の中で catch");
}
};
const func2 = async () => {
try {
return await alwaysRejectPromise();
} catch (e) {
console.log("func2 の中で catch");
}
};
func1().catch((e) => console.log("func1 の呼び出し元で catch"));
func2().catch((e) => console.log("func2 の呼び出し元で catch"));
実行結果は以下のとおりです。
func2 の中で catch
func1 の呼び出し元で catch
期待通り、Promise
を単に return
している func1
の場合には関数の呼び出し元の catch
句で例外が捕捉され、await return
している func2
の場合には func2
の内部の catch
句で例外が捕捉されています。
どうしてこのような違いが生じるのでしょうか?await 演算子は Promise
が決定される(履行または拒否されるまで)まで待機することがポイントです。
つまりは await return
の場合には Promise
が完了するまで関数内で待機するため Promise
が拒否されたときにはまだ処理が関数内に残っています。そのため関数内の catch
句で例外が捕捉されます。
一方で return
の場合には関数内で Promise
が完了するまで待機されません。そのため Promise
が拒否されたときにはすでに関数内での処理は終了してしまっています。ですから関数内の catch
句ではなく呼び出し元の catch
句で例外が捕捉されるのです。
スタックトレースの出力
2 つ目の違いとしてはスタックトレースの出力の違いがあげられます。これはさきほどの例外がキャッチされる場所と同じ原理です。実施のコード例で確認してみましょう。始めに return
の例です。
async function foo() {
return bar();
}
async function bar() {
await Promise.resolve();
throw new Error("something went wrong");
}
foo().catch((error) => console.log(error.stack));
スタックトレースは次のように出力されます。
Error: something went wrong
at bar (index.js:7:9)
これを関数 foo()
内で return await
を使用するように修正します。
async function foo() {
- return bar();
+ return await bar();
}
async function bar() {
await Promise.resolve();
throw new Error("something went wrong");
}
foo().catch((error) => console.log(error.stack));
スタックトレースの詳細は以前のバージョンに比べてより詳細なものになります。
Error: something went wrong
at bar (index.js:7:9)
at async foo (index.js:2:10)
マイクロタスクのタイミング
単純に述べると return await
は冗長です。次のコードは関数内部で Promise
が完了するのを待機した後、関数の呼び出し元でもう一度 Promise
が完了するのを待つ必要があります。
const foo = async () => {
return await Promise.resolve(42);
};
const main = async () => {
await foo();
console.log("function foo finished");
};
main();
このことがどのように問題となるか理解するには JavaScript におけるマイクロタスクの概念を理解する必要があります。
マイクロタスクとは?
マイクロタスクは簡潔に述べると非同期処理の待ち行列(キュー)の一種です。例えば Promise
や Mutation Observer API
において使用されています。マイクロタスクは先入れ先出しのキューとなっており、Promise
が呼ばれたタイミングではマイクロタスクキューにタスクを登録します。その後マイクロタスクが実行できるタイミングになったらマイクロタスクキューから順番にタスクを呼び出して実行します。
マイクロタスクが実行できるタイミングは 1 回のイベントループがが終了するタイミングです。イベントループが収集したすべてのタスクが完了した後に保留されていたマイクロタスクの実行を始めます。端的に換言すると、すべての同期処理が完了した後になってはじめて非同期処理(マイクロタスク)が実行されるということです。以下の例で確認してみましょう。
console.log(1); // ①
console.log(2); // ②
Promise.resolve().then(() => console.log(3)); // ③
for (let i = 0; i < 1000000000; i++) {} // ④
console.log(4); // ⑤
始めはコードの順番にならって ①、② の処理が実行されます。その後 ③ の処理ではマイクロタスクキューにタスクが登録されこの時点ではまだ実行されません。その後興味深いのは ④ の処理でありここでは大量の for
ループで処理をブロッキングしています(ここでは大雑把に 3000ms かかると仮定しましょう)。③ の処理の実行は実際には 1ms で完了するのですが、マイクロタスクキューに登録されたタスクは現在のイベントループのすべてのタスクの完了を待たなければいけません。3000ms が経過した後にようやく ⑤ の処理が実行されその後にマイクロタスクの実行が開始されるので最後に 3
が出力されます。
マイクロタスクの可視化
マイクロタスクは前述のとおり各イベントループの終了時に実行されます。次に行う実験の前にマイクロタスクの周期を表示する関数を作成します。
const tick = () => {
let i = 1;
const exec = async () => {
await Promise.resolve();
console.log(i++);
if (i < 5) {
exec();
}
};
exec();
};
tick()
関数を実行すると下記のように 1 マイクロタスク毎にログを出力します。
1
2
3
4
5
ここで return await
の話に戻りましょう。冒頭のコードと同時に tick()
関数を呼び出してどのマイクロタスクのタイミングで foo
関数の処理が完了したのかを確認してみましょう。
const foo = async () => {
return await Promise.resolve(42);
};
const tick = () => {
// ...
};
const main = async () => {
await foo();
console.log("function foo finished");
};
tick();
main();
結果は次のとおりです。
1
2
function foo finished
3
4
function foo finished
が 2
の後に表示されていることから 2 回目のマイクロタスクのタイミングで関数 foo()
が実行されていることがわかります。
次に foo()
関数を return await
しないように修正します。
const foo = () => {
return Promise.resolve(42);
};
const tick = () => {
// ...
};
const main = async () => {
await foo();
console.log("function foo finished");
};
tick();
main();
結果は次のとおりです。
1
function foo finished
2
3
4
今度は function foo finished
が 1
の後に実行されています。つまり、return await
と比べて return
する場合には 1 つは早いマイクロタスクで foo()
の処理が完了するということになります。
これが、return await
が冗長ということの意味です。return await
をすることによって余分なマイクロタスクが発生してしまうのです。
1 点興味深い仕様として async
関数で return await
しなかった場合には return await
した場合よりも 1 つ後のマイクロタスクで実行されます。
const foo = async () => {
return Promise.resolve(42);
};
const tick = () => {
// ...
};
const main = async () => {
await foo();
console.log("function foo finished");
};
tick();
main();
1
2
3
function foo finished
4
これは async
関数が値を返す際には暗黙的の本来の戻り値を用いて Promise.resolve
されることに関係します。
つまり
async function foo() {
return 1;
}
は擬似的に次のコードと理解できます。
function foo() {
return Promise.resolve(1)
}
さらに async
関数において Promise
を return
した場合には「return
に使われた Promise
に対する then
の実行」を待つのに 1 マイクロタスク「then
で登録されたコールバックの実行」に 1 マイクロタスクを余分に消費します。
return await
の場合には完了済の Promise
を返すので await
する際に 1 マイクロタスクを消費しますが、その後 async関数において
Promiseを
return` した場合のマイクロタスクの消費を実行しないので 1 マイクロタスク早くなるわけです。
結果としては次のようになります。
マイクロタスク | |
---|---|
return | 1 |
return(async 関数) | 3 |
return await(async 関数) | 2 |
ただし、1 つ遅いマイクロタスクで実行されるということは非同期処理感の優先順位にまつわる話であり、必ずしもパフォーマンス上の問題に直結するわけではないことに注意してください。
まとめ
return
と return await
の違いについて述べてきました。基本的には関数内部で try/catch
をする必要がない場合にわざわざ await
を書くのは無駄な処理となります。
幸いなことに無駄な return await
を禁止する ESlint ルールが存在するので興味を持ったかたはこのルールを適用させてみるとよいでしょう。
ただし、try/catch
を関数内部で使用しない場合でも return await
をしない場合にはスタックトレース上不利になることは留意してください。
Discussion
マイクロタスクの可視化の項目のコードについて、もしかしたら間違いではないかと思う箇所あったのでコメントさせていただきます。
foo
関数のreturn await
の話として最初に次のコードそして、その比較として次のコード
をあげられていますが、コードの違いとしては
tick()
とmain()
の順番が変わって、foo
自体も Async function ではなくなっています。しかし、記事内にて
とあり、さらに冒頭のコードではつぎのような Async function 内での
return
とreturn await
の比較になっています。従って、記事の文脈的に Async function における
return
とreturn await
の違いの説明をしていると思われますので、コードの比較としてはつぎのようになるのではないでしょうか?ただこの場合、手元で実際に実行してみますと
return
の方ではdeno
でもnode
でも同じように次の出力となりました。一方、
return await
の方も実行してみると次の出力になります。この場合、
return await
の方が早く終ることから、「return await
は冗長ではない」という結論になるのではないでしょうか?ただ、記事内でつぎのようにも説明されているので混乱してしまいました。
私はコードの比較自体が単純なミスなのではないかと推測したのですが、もしコードの比較が意図通りのものだとしたら、記事の結論としては「Promise を返す通常の関数と
return await
する Async function を比較したときに、後者は冗長である」ということを意味しているのでしょうか?tick()
とmain()
の順番が変わっているのは誤りです🙇♂️ ありがとうございます。tick()
→main()
の順番で実行するのが正しいです。async
の有無についてですが記事内で統一性がなく混乱させてしまいました。「マイクロタスクのタイミング」のケースの場合にだけ「通常の関数でPromise
をreturn
した場合」「async
関数でPromise
をreturn
した場合」「async
関数でPromise
をawait return
した場合」の3つの比較となっております。「return await は冗長」という主張は「通常の関数で
Promise
をreturn
した場合」と「async
関数でPromise
をawait return
した場合」との比較した場合の結論となっております。なるほど、理解することができました。ありがとうございます👍
読み直してみたのですが、コメントで指摘した「マイクロタスクの可視化」の項目において、最初のコードで
return await Promise.resolve(42);
ではなく、return Promise.resolve(42);
になってしまっています。これによってコード上では「async
関数でPromise
をreturn
した場合」と「通常の関数でPromise
をreturn
した場合」の比較になってしまっています。なのでawait
を付け忘れているのではないでしょうか?本当ですね
await
をつけ忘れていました🙇♂️ご指摘ありがとうございます!
やっぱりそうでしたか!
ちょうど非同期処理について学習していて、実際に実行したら違和感があったので気づいてよかったです👍