Promiseは何時呼ばれるのか?
使う分には今まであまり意識してこなかったのですが、async/awaitを呼ぶことで処理がブロッキングされるのではないかというのを懸念していたのと、そもそもどのタイミングでPromiseのcallbackがされるのか気になったので今更ですが調べてみました。
Promiseが呼ばれる仕組みについては先にEventLoopとmicrotaskについて知る必要があります。
先に結論から書くと以下の感じです。
- PromiseはEventLoop内のmicrotaskキューでFIFO実行される。
- Timer系の処理(setImmediateやsetTimeout)はmicrotaskが全て実行された後に実行される。(つまり、
setTimeout(fn, 0)
はmicrotaskを全て実行した後にfnを実行するという意味) - async/awaitはPromiseの箇所でsuspendしているに過ぎない(イベントループをブロッキングするかどうかはPromise内部の処理に依存する)、ジェネレータ文法(yield)やコルーチンの概念と同じ。
- NodeJS v12以降ではasync/awaitによるパフォーマンスの劣化は改善されているので、処理の実行順序の見やすさ的に積極的に書いて問題ない。(ただし、async/await関係なしに待たなくて良い処理に関してはレスポンスを返した後に実行すべしなのとI/O系のSyncメソッドは使わないほうが良い)
- ブラウザでもPromiseはqueueMicrotaskによって実装されている(そもそもPromise、async/awaitサポートされてないブラウザもまだ生き残っているのでトランスパイル必須だが)
NodeJSの場合
NodeJSはV8エンジン(Google ChromeのChromiumでも使われている)によるJavaScriptで動く実行環境です。
元々はC10K問題(サーバーのハードウェア性能は問題ないにもかかわらず、クライアントの同時接続数が多くなるとサービスの応答が遅くなる)を解決するバックエンド環境(アプリケーションサーバ)として開発されました。
C10K問題はハードウェア性能ではなく、OSの制限によって引き起こされるクライアント同時接続数の上限です。
- プロセス数の上限
- コンテキストスイッチ(切り替え)のコスト
- ファイルディスクリプタの上限
これらの問題を解決するためにNodeJSはシングルプロセス・シングルスレッドでリクエストを捌くという設計になっています。(実際にはマルチプロセスもマルチスレッドも作れるのですが根本的な設計思想はこれです。)
シングルプロセス・シングルスレッドで全部のリクエストを処理することでプロセス数の上限にもひっかからず、大量のマルチプロセス、マルチスレッドでのコンテキストスイッチも発生しない。
DBコネクションもマルチプロセス単位で都度接続するのでなく、シングルスレッド内で使いまわしをすることでファイルディスクリプタの上限にならない。
ただ、ファイルの読み書きに関してはシングルスレッドで行うと他の処理をブロッキングするほど重たいため、非同期でのI/Oをサポート・推奨しています。
シングルスレッドで飛んでくるリクエストを管理するための仕組みがイベントキュー(イベントループ)です。
従来の1リクエストにつき1プロセス立ち上がるマルチプロセス型のアプリケーションサーバに対し、
シングルプロセス、シングルスレッド内でイベントキューにリクエストを順次詰め込み、DBやファイルからのデータ取得を非同期に取得し、レスポンスを返すことを可能にしています。
以上により、NodeJSはシングルプロセス・シングルスレッドによるイベントループでC10K問題を解決しています。
ただし、シングルスレッドならではの問題として
CPU負荷が高いループ処理などが発生するとイベントループをブロッキングしてしまい、全てのリクエストに遅延や最悪レスポンスが返って来ないほどの影響を与えてしまいます。
さて、ようやくNodeJS上でPromiseがいつ呼ばれるのかという話しなのですが
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
(() => console.log(5))();
実行結果は次のようになります。
同期実行→nextTick→Promise(microtask)→setTimeout→setImmediate
の順番です。
5
3
4
1
2
(() => console.log(5))()
のみ同期タスクで他は非同期タスクです。
同期タスクが一番最初なのは理解しやすいとして、非同期タスクの順番はどのように決まっているのでしょうか?
これはなぜなのかはもう少し踏み込んでイベントループについて見てみる必要があります。
イベントループはlivuvにより実装されていて、
NodeJSが起動すると以下のイベントループが初期化されます。
(ただし、nextTickQueue、microTaskQueueはNodeJS側で実装されている)
まず、イベントループに入る前にもしくはイベントループの各フェーズの後にキューのタスクがある場合はキューが空になるまで実行されます。
(イベントループはシングルスレッドで複数のtaskを同時に処理することはできないため)
- nextTickQueueは全てのキューの中で最速に処理される→nextTickが実行される
- microTaskQueueはnextTickQueueが空になり次第、実行(Promisesオブジェクトのコールバックはここに所属)→Promiseが実行される
キューが解消されるとTimerフェーズからイベントループに入ります
- TimerフェーズでsetTimeoutが呼ばれる
- CheckフェーズでsetImmediateが必ず呼ばれる
なので同期実行→nextTick→Promise(microtask)→setTimeout→setImmediate
となります。
参考:Node.jsでのイベントループの仕組みとタイマーについて
先程シングルスレッドならではの問題点として、他の処理をブロッキングしてしまうという問題点をあげました。
次の例のようにasync functionだとしても裏側はシングルスレッドで動いているため、高負荷な処理を実行すると全てのリクエストをブロッキングしてしまいます。
app.get('/compute-async', async function computeAsync(req, res) {
log('computing async!');
const hash = crypto.createHash('sha256');
const asyncUpdate = async () => hash.update(randomString());
for (let i = 0; i < 10e6; i++) {
await asyncUpdate();
}
res.send(hash.digest('hex') + '\n');
});
実は次のようにsetTimeoutを挟むことで他のリクエストをブロッキングすることなく、
高負荷な処理を継続することができます。
先程のイベントループが理解できていれば、Promise await(microtask)の間にsetTimeoutを挟むことでmicrotaskに大量の処理を全て詰め込んでから実行するのでなく、microtask→setTimeout→microtask→setTimeoutとインターバルを挟むようになるので他の処理をブロッキングするのを防ぐことができます。
(あくまでイベントループの仕様に基づいた回避策なのでそもそも重すぎるCPU処理の実行を直接サーバ上で行うのはNodeJSは向いていません)
app.get('/compute-with-set-timeout', async function computeWSetTimeout (req, res) {
log('computing async with setTimeout!');
function setTimeoutPromise(delay) {
return new Promise((resolve) => {
setTimeout(() => resolve(), delay);
});
}
const hash = crypto.createHash('sha256');
for (let i = 0; i < 10e6; i++) {
hash.update(randomString());
await setTimeoutPromise(0);
}
log('done ' + req.url);
res.send(hash.digest('hex') + '\n');
});
参考:Node.js: How even quick async functions can block the Event-Loop, starve I/O
このようにNodeJSでは1つの処理がボトルネックになってサーバ全体のパフォーマンスを下げてしまうという危険があります。
後は同一プロセスで実行し続けるため、メモリリークが起きるとサーバの継続実行ができなくなってしまうのでデバッグツールや計測ツールでどこに問題があるのか調査する必要があります。
さて、Promiseが何時実行されるかはわかりました。
(EventLoopの合間のmicrotaskキューで実行される)
ではasync/awaitで実行した場合はどうなるでしょうか?
async/awaitの関数は次のような関数に変換されてV8上で実行されます。
suspendされて、microtaskキューの実行でsuspendの戻り値を返却し、
implicit_promiseをPromiseの結果として最終的に返します。
後は懸念すべき点のasync/awaitを使った場合のパフォーマンス低下ですがNodeJS v12以降では素のPromiseとほぼ問題ないくらいの速度は出ているのでパフォーマンスを気にすることなく積極的にasync/awaitは使って良いレベルだと思います。
つまり、async/awaitそのものでパフォーマンス低下やイベントループがブロッキングされるということはないです。
(Promise.thenでの実装次第ではありえますが)
参考:Faster async functions and promises
ブラウザの場合
ブラウザの場合もJavaScriptの実行フローはNodeJS同様EventLoopに基づいています。
PromiseやMutationObserverはmicrotaskで実行されます。
setTimeout(() => console.log("0"));
Promise.resolve()
.then(() => console.log("1"));
console.log("2");
NodeJSのときと同様に同期処理→Promise(microtask)→setTimeout(Timer)
の順番に実行されます。
2
1
0
もう一つ大事な点はDOMのイベントハンドリングやレンダリングを挟む場合は
すべてのmicrotaskは他のイベントハンドリングやレンダリングやTimer系処理の前に完了します。
(つまりイベントハンドリングしている最中にPromiseのネットワーク処理でデータが書き換わるなんてことはない)
参考:イベントループ(event loop): microtask と macrotask
ChromeのWebWorkerを実装したJake氏のブログのほうがサンプル付きで実行例がわかりやすいかもしれません。
補足としてはブラウザ間の差異は解消され、FireFox、Safari、EdgeはすべてChromeと同じ挙動になっているのを確認しました。(2020/06/21現在)
元記事自体が2015年と古いため、古いブラウザでは差異があったのでしょう。
参考:Tasks, microtasks, queues and schedules
ちなみに以上がわかると次記事の上級問題が解けます
参考:何問分かる?Promise に関するクイズ13問【解説付き】