Unhandled Rejection の考え方
はじめに
twitter 上で議論になっていたネタを本人の許可を得て記載しています。
Node.js でだけ発生する非同期関連の謎現象を発見した🤔
— shqld🦭 (@shqld) January 4, 2022
複数回連続で、非同期処理を挟んだ関数から返した非同期関数を、非同期に実行すると allSettled で待ち受けされずにその場で例外が発生する。
これはバグなのかな...https://t.co/w5C9wKEAOA pic.twitter.com/y3pz4ajndF
実はこの話は会社の中でも一回議論になったネタなんですよね。僕も microtask と呼ばれる Promise キューイングの仕組みとイベントループでタスクをハンドリングする仕組みの両方が組み合わさった時に Unhandled Rejection が起きる理由がわかりにくくなるなーと思っています。誤解していた様子でしたが、それもうなずけます。僕も誤解してました。今回はその理由を補足しながら解説してみます。
Unhandled Rejection
Promise には Pending, Fulfilled, Rejection の3状態があります。Pending は処理中の状態を示し、 Fulfilled と Rejection は Promise が成功したか失敗したかという2つの状態を指しています。 Rejection だった時に catch
して処理が行われたのであれば、 Rejection はハンドリングされたものとなります。誰も catch しなかったら Rejection はハンドリングされなかった状態になります。この状態を Unhandled Rejection と呼びます。簡略化のために下記のコード例を記載します。
main() async function main() { await fail(); // Unhandled Rejection console.log('Successfully handled') } async function fail() { throw new Error('failed') }
4行目で throw されてる例外を誰も catch
していないので、 Unhandled Rejection 扱いになります。try-catch
か .catch
などの方法で catch すれば Unhandled Rejection 扱いではなくなります。
じゃあこのときはどうなるのか
// https://gist.github.com/shqld/be21d1e82cec4dccfc933477f9b60356 main() async function main() { const tasks = [] tasks.push(fail()) // Commenting out this line makes it work await new Promise((resolve) => setTimeout(resolve, 0)) tasks.push(fail()) await Promise.allSettled(tasks) console.log('Successfully handled') } async function fail() { throw new Error('failed') }
該当のスクリプトですね。この時も Unhandled Rejection になります。 main 関数の3行目の await new Promise((resolve) => setTimeout(resolve, 0))
で起きます。一見うまくいきそうに見えるから不思議ですね。しかもこのコードをそのまま Chrome 等で実行してみると特にエラーも何も出ずに成功したかのように見えます。
しかし、 Node.js/deno のときは Unhandled Rejection で process が落ちます。
Error: failed at fail (/private/tmp/t3.js:10:11) at main (/private/tmp/t3.js:5:11) at Object.<anonymous> (/private/tmp/t3.js:2:1) at Module._compile (node:internal/modules/cjs/loader:1097:14) at Object.Module._extensions..js (node:internal/modules/cjs/loader:1149:10) at Module.load (node:internal/modules/cjs/loader:975:32) at Function.Module._load (node:internal/modules/cjs/loader:822:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) at node:internal/main/run_main_module:17:47
ただし、 Chrome であったとしても、 ログのレベルを verbose に上げた状態で上のコードを実行すると Unhandled Rejection が発生していることが確認できます。
一旦スクリプトを細かく分割して整理しつつ、中でどんな事が起きてるのかを解説します。
もう少し細かく
Unhandled Rejection が起きるといいましたが、一応条件があります。それは Promise が内部的に利用している microtask キューと呼ばれるキューが空になったタイミング で Rejection 状態で handle されてない Promise があった時です。 また、 microtask キューは "queue" と名付けられているだけあって、 FIFO (First In First Out) な動きをします。つまり、queue にタスクが積まれるのですが、それは先頭から消費されていきます。
function main() { Promise.resolve().then( () => console.log("promise first") // キューに積まれる ).then( () => console.log("promise second") // キューに積まれる ); console.log("show first"); // 同期的に実行される } main(); // show first // promise first // promise second
microtask キューが空になったタイミングで Rejection 状態になると Unhandled Rejection になります。
function main() { Promise.resolve().then( () => { throw new Error("failed") } // rejection 状態になる ).then( () => console.log("promise second") // rejection 状態なので then は通らず、ここは queue に積まれない ); // taskqueue が空になった状態で Rejection のままになる。このままだと Unhandled Rejection になる console.log("show first"); // 同期的に実行される } main();
元のスクリプトを見てみる
この状況で元のスクリプトを確認してみましょう。まずは setTimeout の Promise が無い状態だとどうなるかを考えてみます。
main() async function main() { const tasks = [] tasks.push(fail()) // Commenting out this line makes it work // await new Promise((resolve) => setTimeout(resolve, 0)) tasks.push(fail()) await Promise.allSettled(tasks) console.log('Successfully handled') } async function fail() { throw new Error('failed') }
この状態だと、 Promise.allSettled
関数が throw したエラーを内部的に catch
してくれます。 Promise.allSettled
はすべての Promise のタスクが Fulfilled にせよ Rejection にせよどの状態になったとしても終了するまでハンドルする関数です。この動きをさせるために内部的には Promise の例外をキャッチしてくれています。なので、この状況だと Promise は Unhandled Rejection にはなりません。普通に終了します。
ただ逆に最初の tasks.push(fail())
で止めたらどうなるでしょうか。
main() async function main() { const tasks = [] tasks.push(fail()) } async function fail() { throw new Error('failed') }
この場合は Unhandled Rejection になります。 microtasks のキューが空になった状態で fail() 内で Error をスローしてそのままだからです。
さて、 もう一回もとに戻してみましょう。今度は該当の setTimeout を入れてみます。
main() async function main() { const tasks = [] tasks.push(fail()) await new Promise((resolve) => setTimeout(resolve, 0)) tasks.push(fail()) await Promise.allSettled(tasks) console.log('Successfully handled') } async function fail() { throw new Error('failed') }
さらにもう少し説明を追加します。
Node.jsのイベントループモデルと一緒に解説します。下記のように EventLoop でのタスク管理と Promise でのタスク管理 (図で microtask queue と書いたところ)に分かれています。
緑色の箱で示したフェーズと青い箱で示した nexttick queue / microtask queue が存在しています。各フェーズにもコールバックを貯めておくキューが存在します。それぞれ赤い線で nexttick queue や microtask queue を適宜呼び出し、フェーズが終了した段階で microtask queue に積んだり、 nexttick queue に積んだりします。
上のコード例で言うと、 await new Promise((resolve) => setTimeout(resolve, 0))
の時に setTimeout をしているので、このタイミングで、 EventLoop の Timer のコールバックにタスクが積まれます。この時点で一旦 EventLoop 側に Task が移ります。
この際に microtask queue は一旦空になります。空になったタイミングで、 Handle されていない1つ目の fail()
関数が Error を Throw しているため、 Unhandled Rejection 扱いになります。
さぁ、わかりにくいですよね。非常にトリッキーです。Promise の処理が行われた後に 「EventLoop 側の処理機構に移る」という言い方をしていますが、この際に別なフェーズに処理が移ってしまいます。中の青い箱で示したキューから緑のフェーズに処理が移るタイミングで中の Promise のキューがあれば即座に実行されます。その結果、 fail()
内部でエラーが throw
されているので Unhandled Rejection 扱いになってしまいます。ちなみに nexttick queue があればそちらを優先します。
同じ task の中で同期的に実行していれば (Timer の処理がなければ) Promise の Unhandled Rejection な状況にはなりません。同じタスク内の最後で allSettled
などで catch
してくれていれば、意図通りに動きます。しかし、その途中で setTimeout, setImmediate などで Timer の処理を動かしたり、 fs でファイルを操作したり、 http リクエストしたりするとその段階で EventLoop の各フェーズに処理が移ります。この時点で一回 microtask queue を実行します。この時に Rejection 状態になってしまっていると、 Unhandled Rejection
扱いになります。
わかりにくくしているポイント
実は Node.js / deno も Chrome もどちらも Unhandled Rejection に一回なっています。ただ Chrome の場合は一回 warn として処理された上で、次の tick に行った場合にそこでちゃんとハンドルされているのであれば、 verbose ログに出すだけで、エラーになりません。
一方で Node.js/deno は一回でも Unhandled Rejection になった場合はエラー扱いになり、落ちます。この動きの差はわかりにくいものの、仕様通りの挙動になっています。
Promise の ECMAScript 上での仕様は Unhandled Rejection 状況になったことを Platform 側に伝えるところまでが明記されています。ただ伝わった後どう Unhandled Rejection を処理するかは Platform 側で自由に決めて良いことになっています。 Chrome のようにクライアントサイドのブラウザであれば、Promise が例外だったとしても warn 扱いで留めた上で、次のイベントループで処理されていればログレベルを下げて表示する、という対策もありでしょう。逆に Node.js/deno はサーバサイドで未処理の例外が発生した状況のままなにも通知せずに動いてしまうとリソースの開放漏れがあったり、例外が発生してレスポンスを返せていないのに正常に動作してるかのように見えるといった、プロセスが落ちるよりもさらにひどい状況になり得ます。そのためプラットフォームごとに動きが異なる、という仕様になっています。非常にややこしいですね。。。
ちなみに Node.js の場合、デフォルトでその動きになっているものの、変更することも可能です。 --unhandled-rejections
という起動オプションに対していくつかの設定が用意されています。
none
UnhandledRejection が起きたことを警告はするが、特に詳細は表示しない。 Chrome とほぼ同じ扱いwarn
Unhandled Rejection が起きたことを詳細に警告ログに残すthrow
デフォルトの挙動、エラー扱いとしてunhandledRejection
イベントを process に通知、何もハンドラがなかったらuncaught exception
と同じ扱いとして、エラーにする。strict
ハンドラの有無に関わらず、uncaught exception
と同じ扱いとして、エラーにする。warn-with-error-code
Unhandled Rejection が起きたことを詳細に警告ログに残しつつ、終了時に exit code を 1 にする(異常終了扱いする)。
これらの扱いをうまく使えば Chrome と同じ挙動にすることも可能です。ただし、Node.js/deno といったプラットフォームが提供しているデフォルトの挙動は安全サイドに倒したものになっており、これを意図を考慮しないで変更することは個人的にはオススメしません。プラットフォームのデフォルトの動きがなぜそうなっているのかを把握した上で変更するのであれば、良いと思います。
まとめ
Unhandled Rejection の状況について解説しました。 Timer や fs といった EventLoop の機構と Promise の機構が混在しわかりにくくしている上に、Chrome などのブラウザは Unhandled Rejection なエラーを verbose でしか出していない所が、余計わかりにくくしています。ただ、それにはちゃんとした理由があるという所まで分かっていただけると幸いです。
個人的には Unhandled Rejection 状態になった時の Promise の仕様がプラットフォーム依存になっているところがもう少し周囲が把握できているといいなとは思います。 Chrome と Node/deno だけではなく、 Firefox とかも動きが違うので、本当に難しくなっています。 Node.js に関しては複数の動作パターンを用意していますが、 Unhandled Rejection 時のデフォルトの挙動をどうするべきか議論が白熱したため、このようにオプションできりかえられるようになっています。
今一度 Unhandled Rejection をプラットフォームがどう実装しているのかは確認してみると良いと思います。