return と return await の3つの違い

2022/04/04に公開
6

Promise を返す非同期関数を扱うとき Promise をそのまま返す書き方と Promiseawait してから返す二通りの方法があります。

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 におけるマイクロタスクの概念を理解する必要があります。

マイクロタスクとは?

マイクロタスクは簡潔に述べると非同期処理の待ち行列(キュー)の一種です。例えば PromiseMutation 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 が出力されます。

microtask1

マイクロタスクの可視化

マイクロタスクは前述のとおり各イベントループの終了時に実行されます。次に行う実験の前にマイクロタスクの周期を表示する関数を作成します。

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 finished2 の後に表示されていることから 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 finished1 の後に実行されています。つまり、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 関数において Promisereturn した場合には「return に使われた Promise に対する then の実行」を待つのに 1 マイクロタスク「then で登録されたコールバックの実行」に 1 マイクロタスクを余分に消費します。

return await の場合には完了済の Promise を返すので await する際に 1 マイクロタスクを消費しますが、その後 async関数においてPromisereturn` した場合のマイクロタスクの消費を実行しないので 1 マイクロタスク早くなるわけです。

結果としては次のようになります。

マイクロタスク
return 1
return(async 関数) 3
return await(async 関数) 2

ただし、1 つ遅いマイクロタスクで実行されるということは非同期処理感の優先順位にまつわる話であり、必ずしもパフォーマンス上の問題に直結するわけではないことに注意してください。

まとめ

returnreturn await の違いについて述べてきました。基本的には関数内部で try/catch をする必要がない場合にわざわざ await を書くのは無駄な処理となります。

幸いなことに無駄な return await を禁止する ESlint ルールが存在するので興味を持ったかたはこのルールを適用させてみるとよいでしょう。

https://eslint.org/docs/rules/no-return-await

ただし、try/catch を関数内部で使用しない場合でも return await をしない場合にはスタックトレース上不利になることは留意してください。

参考

GitHubで編集を提案

Discussion

PADAone🐕PADAone🐕

マイクロタスクの可視化の項目のコードについて、もしかしたら間違いではないかと思う箇所あったのでコメントさせていただきます。

foo 関数の
return await の話として最初に次のコード

const foo = async () => {
  return Promise.resolve(42);
};

const tick = () => {
  // ...
};

const main = async () => {
  await foo();
  console.log("function foo finished");
};

main();
tick();

そして、その比較として次のコード

const foo = () => {
  return Promise.resolve(42);
};

const tick = () => {
  // ...
};

const main = async () => {
  await foo();
  console.log("function foo finished");
};

tick();
main();

をあげられていますが、コードの違いとしては tick()main() の順番が変わって、foo 自体も Async function ではなくなっています。

-const foo = async () => {
-  return Promise.resolve(42);
-};
+const foo = () => {
+  return Promise.resolve(42);
+};

const tick = () => {
  // ...
};

const main = async () => {
  await foo();
  console.log("function foo finished");
};

-main();
tick();
+main();

しかし、記事内にて

次に foo() 関数を return await しないように修正します。

とあり、さらに冒頭のコードではつぎのような Async function 内での returnreturn await の比較になっています。

const fetchUsers1 = async () => {
  return axios.get("/users");
};

// または

const fetchUsers2 = async () => {
  return await axios.get("/users");
};

従って、記事の文脈的に Async function における returnreturn await の違いの説明をしていると思われますので、コードの比較としてはつぎのようになるのではないでしょうか?

const foo = async () => {
- return await Promise.resolve(42);
+ return Promise.resolve(42);
};

const tick = () => {
  let i = 1;
  const exec = async () => {
    await Promise.resolve();
    console.log(i++);
    if (i < 5) {
      exec();
    }
  };
  exec();
};

const main = async () => {
  await foo();
  console.log("function foo finished");
};

tick();
main();

ただこの場合、手元で実際に実行してみますと return の方では deno でも node でも同じように次の出力となりました。

# return のみの場合
❯ deno run difftest.js
1
2
3
function foo finished
4

一方、return await の方も実行してみると次の出力になります。

# return await の場合
❯ deno run difftest.js
1
2
function foo finished
3
4

この場合、return await の方が早く終ることから、「return await は冗長ではない」という結論になるのではないでしょうか?

ただ、記事内でつぎのようにも説明されているので混乱してしまいました。

1 点興味深い仕様として async 関数で return await しなかった場合には return await した場合よりも 1 つ後のマイクロタスクで実行されます。

私はコードの比較自体が単純なミスなのではないかと推測したのですが、もしコードの比較が意図通りのものだとしたら、記事の結論としては「Promise を返す通常の関数と return await する Async function を比較したときに、後者は冗長である」ということを意味しているのでしょうか?

azukiazusaazukiazusa

をあげられていますが、コードの違いとしては tick() と main() の順番が変わって、foo 自体も Async function ではなくなっています。

tick()main() の順番が変わっているのは誤りです🙇‍♂️ ありがとうございます。tick()main() の順番で実行するのが正しいです。

async の有無についてですが記事内で統一性がなく混乱させてしまいました。「マイクロタスクのタイミング」のケースの場合にだけ「通常の関数で Promisereturn した場合」「async 関数で Promisereturn した場合」「async 関数で Promiseawait return した場合」の3つの比較となっております。

「return await は冗長」という主張は「通常の関数で Promisereturn した場合」と「async 関数で Promiseawait return した場合」との比較した場合の結論となっております。

PADAone🐕PADAone🐕

なるほど、理解することができました。ありがとうございます👍

PADAone🐕PADAone🐕

読み直してみたのですが、コメントで指摘した「マイクロタスクの可視化」の項目において、最初のコードで return await Promise.resolve(42); ではなく、return Promise.resolve(42); になってしまっています。これによってコード上では「async 関数で Promisereturn した場合」と「通常の関数で Promisereturn した場合」の比較になってしまっています。なのでawait を付け忘れているのではないでしょうか?

azukiazusaazukiazusa

本当ですね await をつけ忘れていました🙇‍♂️
ご指摘ありがとうございます!

PADAone🐕PADAone🐕

やっぱりそうでしたか!
ちょうど非同期処理について学習していて、実際に実行したら違和感があったので気づいてよかったです👍