非同期関数とプロミスの高速化
JavaScriptにおける非同期処理は従来、特に速いとは言えないレピュテーションを持っていました。さらに悪いことに、ライブJavaScriptアプリケーション、特にNode.jsサーバーのデバッグは簡単ではありません。特に 非同期プログラミングに関してはそうです。しかし、時代は変わりつつあります。本記事では、V8で非同期関数とプロミスをどのように最適化したか(そしてある程度は他のJavaScriptエンジンでも)、および非同期コードのデバッグ体験をどのように改善したかを探ります。
注意: 記事を読むよりもプレゼンを見る方が好きな場合は、以下のビデオをお楽しみください!興味がない場合は、動画をスキップして次へ進んでください。
非同期プログラミングへの新しいアプローチ
コールバックからプロミスへ、そして非同期関数へ
プロミスがJavaScript言語の一部になる前は、Node.jsで特に非同期コードに使われるコールバックベースのAPIが一般的でした。以下はその例です:
function handler(done) {
validateParams((error) => {
if (error) return done(error);
dbQuery((error, dbResults) => {
if (error) return done(error);
serviceCall(dbResults, (error, serviceResults) => {
console.log(result);
done(error, serviceResults);
});
});
});
}
このように深くネストされたコールバックを使用する特定のパターンは、一般的に 「コールバック地獄」 と呼ばれ、コードが読みにくく、保守が難しくなります。
幸運にも、現在ではプロミスがJavaScript言語の一部になり、同じコードをよりエレガントで保守しやすい方法で記述できるようになりました:
function handler() {
return validateParams()
.then(dbQuery)
.then(serviceCall)
.then(result => {
console.log(result);
return result;
});
}
さらに最近、JavaScriptは 非同期関数 のサポートを取得しました。上記の非同期コードは、同期コードに非常に似た方法で記述することができます:
async function handler() {
await validateParams();
const dbResults = await dbQuery();
const results = await serviceCall(dbResults);
console.log(results);
return results;
}
非同期関数を使用すると、コードが簡潔になり、制御とデータフローがはるかに追跡しやすくなります。それでも実行は非同期のままであるという事実にもかかわらずです。(JavaScriptの実行はまだ単一スレッドで行われます。つまり、非同期関数が物理的なスレッドを作成するわけではありません。)
イベントリスナーコールバックから非同期イテレーションへ
Node.jsで特に一般的なもう一つの非同期のパラダイムは、ReadableStream
のものです。以下はその例です:
const http = require('http');
http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.write(body);
res.end();
});
}).listen(1337);
このコードは少々理解しづらいかもしれません。受信データはコールバック内でのみアクセスできるチャンクごとに処理され、ストリームの終端を示す信号もコールバック内で発生します。ここで、関数が即座に終了し、実際の処理がコールバックで行われる必要があることを認識しないと、バグを導入しやすくなります。
幸運にも、非同期イテレーション と呼ばれるES2018の新機能がこのコードを簡潔にすることができます:
const http = require('http');
http.createServer(async (req, res) => {
try {
let body = '';
req.setEncoding('utf8');
for await (const chunk of req) {
body += chunk;
}
res.write(body);
res.end();
} catch {
res.statusCode = 500;
res.end();
}
}).listen(1337);
'data'
と 'end'
コールバックに処理ロジックを分ける代わりに、新しい for await…of
ループを使用してチャンクを非同期的にイテレーションし、単一の非同期関数にすべてをまとめることができます。また、try-catch
ブロックを追加し、unhandledRejection
問題1 を回避しました。
これらの新しい機能は今すぐ本番環境で使用できます!非同期関数は、Node.js 8 (V8 v6.2 / Chrome 62)以降で完全にサポートされており、非同期イテレーターとジェネレーターは、Node.js 10 (V8 v6.8 / Chrome 68)以降で完全にサポートされています!
非同期パフォーマンスの改善
V8 v5.5 (Chrome 55 & Node.js 7)からV8 v6.8 (Chrome 68 & Node.js 10)までの間に、非同期コードのパフォーマンスを大幅に向上させることができました。この新しいプログラミングパラダイムを速度を気にせず安全に使用できるレベルに達しました。
上のチャートは、doxbee ベンチマークを示しており、promise重視のコードのパフォーマンスを測定します。チャートは実行時間を可視化しており、低い方が良いことを意味します。
並列ベンチマークの結果はさらに興奮します。特にPromise.all()
の性能を強調しています:
Promise.all
の性能を8倍向上させることができました。
ただし、上記のベンチマークは合成的なマイクロベンチマークです。V8チームは実際のユーザーコードの実世界のパフォーマンスに対する最適化の影響により関心があります。
上記のチャートは、promisesや非同期関数を多用する一部の人気HTTPミドルウェアフレームワークのパフォーマンスを可視化しています。このグラフはリクエスト/秒を示しており、前のチャートとは異なり、数字が高いほど良いことを意味します。これらのフレームワークのパフォーマンスは、Node.js 7 (V8 v5.5)とNode.js 10 (V8 v6.8)の間で大幅に改善されました。
これらのパフォーマンス改善は次の3つの重要な成果の結果です:
TurboFanをリリースした際に、Node.js 8において全体的な大幅な性能向上を達成しました。
また、新しいガベージコレクターであるOrinocoにも取り組んでおり、メインスレッドからガベージコレクションの作業を移行することで、リクエスト処理が大幅に改善されます。
そして最後に、Node.js 8でawait
が場合によってはマイクロティックをスキップする便利なバグがあり、これにより性能が向上しました。このバグは意図しない仕様違反として始まりましたが、後に最適化のアイデアを提供しました。このバグ的挙動から説明を始めましょう。
注意: 以下の挙動は執筆時点でJavaScriptの仕様に従った正当なものでした。その後、私たちの仕様提案が受け入れられ、以下の「バグ的挙動」が現在では正しいものになりました。
const p = Promise.resolve();
(async () => {
await p; console.log('after:await');
})();
p.then(() => console.log('tick:a'))
.then(() => console.log('tick:b'));
上記のプログラムは満たされたpromise p
を作成し、その結果をawait
しますが、それに対して2つのハンドラーをチェーンします。console.log
呼び出しがどの順序で実行されることを期待しますか?
p
が満たされた状態なので、最初に'after:await'
を出力し、その後'tick'
を出力すると予想するかもしれません。実際、Node.js 8ではそのような挙動になります:
この挙動は直感的に見えるかもしれませんが、仕様に従ったものではありません。Node.js 10は正しい挙動を実装しており、最初にチェーンされたハンドラーを実行し、その後非同期関数を続行します。
この_「正しい挙動」_はすぐには明らかではないかもしれず、JavaScript開発者にとって驚くべきものでした。そのため説明に値します。promiseと非同期関数の魔法の世界に移る前に、いくつかの基盤から始めましょう。
タスク vs. マイクロタスク
高レベルでは、JavaScriptには_タスク_と_マイクロタスク_があります。タスクはI/Oやタイマーのイベントを処理し、一度に1つずつ実行されます。マイクロタスクはasync
/await
とpromiseの遅延実行を実装し、各タスクの後に実行されます。マイクロタスクキューは常にイベントループに戻る前に空になります。
詳細については、Jake Archibald のブラウザにおけるタスク、マイクロタスク、キュー、スケジュールの説明をご覧ください。Node.jsのタスクモデルも非常に似ています。
非同期関数
MDN によると、非同期関数とは、その結果を返すために暗黙的なプロミスを利用して非同期に動作する関数のことです。非同期関数は、非同期コードを同期コードのように見せることを意図しており、開発者から非同期処理の複雑さの一部を隠します。
最も単純な非同期関数は以下のようになります:
async function computeAnswer() {
return 42;
}
呼び出すとプロミスを返し、他のプロミスと同様にその値を取得できます。
const p = computeAnswer();
// → プロミス
p.then(console.log);
// 次のターンで 42 を出力
このプロミス p
の値を取得できるのは、次回マイクロタスクが実行されたときだけです。言い換えれば、上記のプログラムは、値を指定して Promise.resolve
を使用するのと意味的に同等です:
function computeAnswer() {
return Promise.resolve(42);
}
await
式の力により、非同期関数が本来の力を発揮します。await
はプロミスが解決されるまで関数の実行を一時停止し、完了後に再開します。await
の値は、その満たされたプロミスの値です。以下の例を見てみましょう:
async function fetchStatus(url) {
const response = await fetch(url);
return response.status;
}
fetchStatus
の実行は await
で停止し、fetch
プロミスが完了した後に再開します。これは、fetch
から返されたプロミスにハンドラを連結することとほぼ同等です。
function fetchStatus(url) {
return fetch(url).then(response => response.status);
}
このハンドラは、非同期関数内の await
に続くコードを含みます。
通常は await
に Promise
を渡しますが、任意の JavaScript 値にも待機することが可能です。await
に続く式の値がプロミスでない場合、それはプロミスへ変換されます。したがって、必要に応じて await 42
を使用することもできます:
async function foo() {
const v = await 42;
return v;
}
const p = foo();
// → プロミス
p.then(console.log);
// 最終的に `42` を出力
もっと興味深いのは、await
が 「thenable」(then
メソッドを持つオブジェクト)であれば、実際のプロミスでなくても動作することです。これにより、例えばスリープの実際の時間を計測するような非同期スリープを作成することができます:
class Sleep {
constructor(timeout) {
this.timeout = timeout;
}
then(resolve, reject) {
const startTime = Date.now();
setTimeout(() => resolve(Date.now() - startTime),
this.timeout);
}
}
(async () => {
const actualTime = await new Sleep(1000);
console.log(actualTime);
})();
次に、仕様に従って、V8がawait
の内部で何をしているかを見てみましょう。以下のような単純な非同期関数foo
を例にします:
async function foo(v) {
const w = await v;
return w;
}
呼び出されると、パラメータ v
をプロミスとしてラップし、そのプロミスが解決されるまで非同期関数の実行を一時停止します。それが完了すると、関数の実行が再開され、w
に満たされたプロミスの値が割り当てられます。この値が非同期関数から返されます。
await
の内部動作
まず初めに、V8はこの関数を_再開可能_とマークします。これは、実行が一時停止し、後日(await
ポイントで)再開できることを意味します。その後、いわゆる暗黙的プロミス
を作成します。これが、非同期関数を呼び出すと返されるプロミスで、最終的には非同期関数によって生成された値を解決します。
次に興味深い部分に進みます: 実際の await
。まず、await
に渡された値がプロミスとしてラップされます。その後、このラップされたプロミスにハンドラが接続され、プロミスが満たされたら関数を再開します。そして、非同期関数の実行は一時停止され、暗黙的プロミス
が呼び出し元に返されます。プロミスが満たされると、非同期関数の実行がプロミスの値 w
で再開され、暗黙的プロミス
が w
で解決されます。
要するに、await v
の初期ステップは次の通りです:
await
に渡された値v
をプロミスとしてラップする。- 後で非同期関数を再開するためのハンドラを接続する。
- 非同期関数を一時停止し、
暗黙的プロミス
を呼び出し元に返す。
個々の操作をステップごとに説明します。ここでは、await
されているものが既にプロミスで、そのプロミスが値 42
で満たされていると仮定します。その後、エンジンは新しいプロミスを作成し、それに await
されている値を解決します。これは、仕様がPromiseResolveThenableJob
と呼ぶ方法で、次のターンでこれらのプロミスを遅延的に連鎖します。
次にエンジンは「使い捨て」と呼ばれる新しいプロミスを作成します。それは完全にエンジン内部のものであり、誰もこれにチェーンしないため、使い捨てと言われます。この「使い捨て」プロミスはpromise
に適切なハンドラでチェーンされ、非同期関数を再開します。このperformPromiseThen
操作は、裏でPromise.prototype.then()
と本質的に同じことをしています。そして、非同期関数の実行は中断され、制御は呼び出し元に戻ります。
実行は呼び出し元側で続行され、やがてコールスタックが空になります。そしてJavaScriptエンジンはマイクロタスクを開始します。エンジンは先にスケジュールされたPromiseResolveThenableJob
を実行し、await
に渡された値にプロミスをチェーンする新しいPromiseReactionJob
をスケジュールします。その後、エンジンはマイクロタスクキューの処理を続けます。マイクロタスクキューが空にならない限り、メインのイベントループに進むことはできません。
次にPromiseReactionJob
が実行されます。このジョブはawait
しているプロミスからの値(この場合は42
)でpromise
を満たし、そのリアクションを「使い捨て」プロミスにスケジュールします。そしてエンジンは再びマイクロタスクループに戻り、残りのマイクロタスクを処理します。
次にこの2番目のPromiseReactionJob
が「使い捨て」プロミスに解決を伝播し、非同期関数の中断された実行を再開します。そしてawait
から値42
を返します。
学んだことをまとめると、各await
に対してエンジンは追加で2つのプロミスを作成する必要があります(右辺がすでにプロミスであっても)。そして少なくとも3つのマイクロタスクキューのティックが必要です。1つのawait
式がこれほどのオーバーヘッドを引き起こすとは誰も思わなかったでしょう!
このオーバーヘッドがどこから来るのかを見てみましょう。最初の行はラッパープロミスを作成する責任があります。2行目は即座にそのラッパープロミスをawait
された値v
で解決します。この2行が追加プロミス1つと3つのマイクロティックのうち2つを占めています。これはv
がすでにプロミスである場合(通常のケースです。なぜなら通常アプリケーションはプロミスでawait
します)にはかなり高価です。開発者が例えば42
でawait
するような稀なケースでも、エンジンはそれをプロミスにラップする必要があります。
実際、仕様には必要に応じてラップのみを行うpromiseResolve
操作がすでにあります:
この操作はプロミスを変更せず、必要に応じて他の値をプロミスにラップするだけです。この方法で、await
に渡される値がすでにプロミスである通常のケースでは追加のプロミス1つとマイクロタスクキューのティック2つを節約できます。この新しい動作はV8 v7.2ではすでにデフォルトで有効化されています。V8 v7.1では--harmony-await-optimization
フラグを使用して新しい動作を有効化できます。この変更をECMAScript仕様に提案しています。
ここで新しく改善されたawait
が内部でどのように動作するのか、ステップごとに見てみましょう:
再び、42
で満たされたプロミスでawait
することを仮定します。promiseResolve
の魔法のおかげで、promise
は現在同じプロミスv
を参照しているだけなので、このステップでは何もする必要がありません。その後、エンジンは以前とまったく同じように進み、「使い捨て」プロミスを作成し、PromiseReactionJob
をスケジュールしてマイクロタスクキューの次のティックで非同期関数を再開し、関数の実行を中断して呼び出し元に戻ります。
やがてすべてのJavaScriptの実行が完了すると、エンジンはマイクロタスクを開始し、PromiseReactionJob
を実行します。このジョブはpromise
の解決を「使い捨て」に伝播し、非同期関数の実行を再開して、await
から42
を返します。
この最適化により、await
に渡される値がすでにプロミスである場合には、ラッパープロミスの作成を回避することができ、この場合最低3つのマイクロティックからわずか1つのマイクロティックに減少します。この動作はNode.js 8が行うものに似ていますが、今回はもうバグではなく、標準化されつつある最適化です!
エンジンが完全に内部のものである「使い捨て」プロミスを作成しなければならないのはまだ違和感があります。実際、「使い捨て」プロミスは仕様の内部操作performPromiseThen
のAPI制約を満たすためだけに存在していることがわかります。
これは最近のECMAScript仕様への編集変更で対処されました。エンジンは、もはやawait
のためのthrowaway
プロミスを作成する必要はありません — ほとんどの場合2。
Node.js 10におけるawait
と、最適化されたNode.js 12でのawait
を比較すると、この変更のパフォーマンスへの影響が見られます:
async
/await
は手書きのプロミスコードを上回るパフォーマンスを発揮します。重要なポイントは、非同期関数のオーバーヘッドを大幅に削減したことです — V8だけでなく、すべてのJavaScriptエンジンで、仕様を修正することでこれを実現しました。
更新: V8 v7.2およびChrome 72以降、--harmony-await-optimization
はデフォルトで有効化されています。この修正はECMAScript仕様に統合されました。
開発者体験の向上
パフォーマンスだけでなく、JavaScript開発者は問題の診断と修正能力にも関心を持っています。非同期コードに取り組む場合、それが必ずしも容易であるとは限りません。Chrome DevToolsは非同期スタックトレース、すなわち現在の同期スタック部分だけでなく非同期部分も含むスタックトレースをサポートしています:
これはローカル開発中に非常に便利な機能です。ただし、このアプローチはアプリケーションがデプロイされた後ではあまり役に立ちません。ポストモーテムデバッグ時には、ログファイルにError#stack
出力しか表示されず、それには非同期部分についての情報は含まれていません。
最近、非同期関数呼び出しでError#stack
プロパティを充実させるゼロコストの非同期スタックトレースに取り組んでいます。「ゼロコスト」と聞くと興奮を覚えるでしょう。このChrome DevTools機能には大きなオーバーヘッドを伴うのに、どうやったらゼロコストになるのでしょうか?foo
が非同期的にbar
を呼び出し、bar
がプロミスをawait
した後に例外を投げる場合の例を考えてみてください:
async function foo() {
await bar();
return 42;
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch(error => console.log(error.stack));
このコードをNode.js 8またはNode.js 10で実行すると、次のような出力が得られます:
$ node index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
foo()
の呼び出しがエラーを引き起こしているにもかかわらず、foo
がスタックトレースに全く含まれていない点に注意してください。これにより、ウェブアプリケーションやクラウドコンテナ内でコードがデプロイされているかどうかに関わらず、JavaScript開発者がポストモーテムデバッグを行うことが難しくなります。
ここで興味深いのは、bar
が完了した際にエンジンがどこで続けるべきかを知っている点です: foo
関数のawait
の直後です。同時に、これがfoo
関数が一時停止された場所でもあります。エンジンはこの情報を使って非同期スタックトレースの特定部分 — await
サイト — を再構成できます。この変更により、出力は以下のようになります:
$ node --async-stack-traces index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
at async foo (index.js:2:3)
スタックトレースでは、最上位の関数が最初に来て、その後に残りの同期スタックトレースが続き、それに続いて関数foo
での非同期呼び出しbar
が表示されます。この変更は新しい--async-stack-traces
フラグの背後にあるV8に実装されています。更新: V8 v7.3以降、--async-stack-traces
はデフォルトで有効になっています。
しかし、これを上記のChrome DevToolsの非同期スタックトレースと比較すると、スタックトレースの非同期部分からfoo
の実際の呼び出し箇所が欠けていることに気づくでしょう。前述の通り、このアプローチでは、await
の場合、再開地点と中断地点が同じであるという事実を利用しています。しかし、通常のPromise#then()
やPromise#catch()
の呼び出しの場合はそうではありません。この背景については、Mathias Bynensが説明したなぜawait
がPromise#then()
を上回るのかをご覧ください。
結論
2つの重要な最適化のおかげで、非同期関数が高速になりました:
- 余分な2つのマイクロチックの削除、および
throwaway
プロミスの削除。
さらに、開発者体験を向上させるためにゼロコスト非同期スタックトレースを導入しました。これは非同期関数でのawait
やPromise.all()
で利用可能です。
そして、JavaScript開発者に向けた良いパフォーマンスアドバイスもあります:
- 手書きのプロミスコードよりも
async
関数とawait
を優先すること、そして - JavaScriptエンジンが提供するネイティブプロミス実装を使用してショートカットの利点を活用すること、つまり
await
で2つのマイクロチックを回避すること。
Footnotes
-
Matteo Collinaさん、この問題を指摘していただきありがとうございます。 ↩
-
V8は
async_hooks
(Node.jsで使用される)のコンテキスト内でthrowaway
プロミスでbefore
とafter
フックが実行されるため、まだthrowaway
プロミスを作成する必要があります。 ↩