はじめに
Aigle
というPromiseライブラリを開発しています。このライブラリはベンチマーク上だけでなく実際の本番環境を考慮した高速化を実現しています。
今回はその過程でBluebird
に大変お世話になったので、なぜBluebird
が速いのか、そしてなぜAigle
が速いのかついて説明したいと思います。
基本的なPromiseの知識
まず本題に入る前にPromiseの基本的なことを説明しておきます。
Promiseにはpending
, fulfilled
, rejected
の3つの状態があります。一度pending
から別の状態に変わった場合は、そのPromiseの状態が変わることはありません。
(引用: MDN)
またPromiseは非同期であることが保証されています。この事は少し重要なので触れておきます。
new Promise(resolve => resolve(1)) // 同期
.then(num => {
// 非同期
});
もっと詳しく知りたい方はコチラを読んで下さい。
高速なライブラリを作る上で重要なこと
本題に入る前に、高速なライブラリを作る上で重要なことについて触れておきます。こちらにも詳しく書かれていますが、私が思う重要なことは以下の3点です。
- 無駄な変数・関数・インスタンスを生成しない
- 無駄な関数の実行をしない
- 非同期処理をスマートに行う
v8(JS実行エンジン)も読んでいないですし、bit演算レベルの配慮もしていませんし、そんなにギークでは無い私ですが、高速なライブラリは上記3つに従えば作れると思っています。
無駄な変数・関数・インスタンスを生成しない
上記の記事にも書かれていますが、基本的に無駄なものを作成しないということは、不要なメモリの割当をしないということになります。以下の例を見てみます。
function sum(array) {
return array.reduce(function iterator(result, num) {
return result + num;
});
}
sum([1, 2, 3]); // 6
このiterator
という関数はsum
が呼ばれる度に生成されてしまいます。このコードは以下のように修正できます。
function iterator(result, num) {
return result + num;
}
function sum(array) {
return array.reduce(iterator);
}
sum([1, 2, 3]); // 6
これにより不要な関数の生成を回避できます。
また以下の例を見てみます。
function get(type) {
const array = [];
const object = {};
const num = 0;
const str = '';
switch (type) {
case 'array':
return array;
case 'object':
return object;
case 'number':
return num;
case 'string':
return str;
}
}
get('string');
この場合、欲しいのはstr
だけになりますので、array
, object
, num
は不要な割当になります。この例ではパフォーマンスは大差ありませんが、これがインスタンスの生成や関数の生成になってくると少しずつパフォーマンスに差が出てきます。そのため以下のように書き換えます。
function get(type) {
switch (type) {
case 'array':
return [];
case 'object':
return {};
case 'number':
return 0;
case 'string':
return '';
}
}
get('string');
必要なときだけ変数・関数・インスタンスの生成を行うことで高速化できます。
無駄な関数の実行をしない
例えばapiを作成するときにリクエストのパラメータをチェックします。
function api(req, res) {
const { id } = req.body;
if (!isNumber(id)) {
return res.sendStatus(400);
}
innerFunc(id)
.then(...)
.catch(...)
}
function isNumber(id) {
return typeof id === 'number';
}
function innerFunc(id) {
if (!isNumber(id)) {
return Promise.reject(new Error('error'));
}
...
}
apiを実装する上では、内部関数のところでチェックしたほうが良いケースも多々ありますが、ライブラリを作る上では不要です。基本的に内部関数の呼び出しの前で一度チェックされているので不要な関数呼び出しになります。
非同期処理をスマートに行う
これは以下のBluebird
の例を見ながら説明していきます。
なぜBluebird
が速いのか
Bluebird
を開くとまず目につくのがbitField
です。これは思考を停止させる会心の一撃と言っても過言ではないでしょう。しかし今は忘れてください。
Bluebird
には大きくわけて2つの状態があり、pending
とそれ以外で処理が異なります。その状態を管理するためにBluebird
ではbitField
が使われています。しかし今は忘れてください、処理が大きく異なるのはpending
かそれ以外です。
pending
の場合
以下の例を見てみましょう。
new Bluebird(function executor(resolve) {
// この関数は同期的に呼ばれる
setTimeout(function timer() {
resolve(1); // このresolveは非同期で呼ばれる
}, 10);
})
.then(function onFulfilled(value) {
// 非同期が保証されている
});
この実行順序は
1. Bluebird
インスタンス(親)の生成
2. executor
の実行
3. then
の実行
4. timer
の実行
5. resolve
の実行
6. onFulfilled
の実行
です。
then
の実行時にBluebird
インスタンスが生成され、親のインスタンスの子要素として紐付きます。この時、timer
内のresolve
は実行されていないので親のインスタンスはpending
になります。その後setTimeout
によりtimer
が実行されresolve
が呼ばれます。そしてその結果を子要素に反映していき、onFulfilled
が実行されます。
状態がpending
のときは親に子要素をセットする。親の結果が出たら子要素を実行する、ただこれだけです。
pending
以外の場合
次の例は同期的呼び出しの例です。
new Bluebird(function executor(resolve) {
resolve(); // 同期的に実行される
})
.then(function onFulfilled(value) {
// 非同期を保証しなければならない
});
実行順序は
1. Bluebird
インスタンス(親)の生成
2. executor
の実行
3. resolve
の実行
4. then
の実行
5. onFulfilled
の実行
です。
この場合、then
が呼ばれる前にresolve
が呼ばれており、親のBluebird
インスタンスはすでにpending
以外の状態になっています。この時にonFulfilled
をそのまま呼び出してしまうと、同期的に呼ばれてしまうため非同期が保証されなくなってしまいます。
そこで内部で非同期関数を呼ぶ必要があります。まず内部で非同期を保証するasync.invoke
という関数が呼ばれます。これは非同期を引き起こすという意味です。そして子要素のonFulfilled
はqueue
にセットされます。そして内部でschedule
という関数が呼ばれます。このschedule
とはNode.js
ではsetImmediate
です。このsetImmediate
により子要素が非同期で実行されます。
pending
以外のときはqueue
にセットして非同期関数を呼ぶ。非同期関数によりqueue
の要素を非同期で実行する。シンプルであるがゆえに高速です。
ここで疑問に思うのが、なぜわざわざqueue
にセットする必要があるのか、という点です。ただ非同期処理をしたいのであればそのまま非同期関数をそのまま呼べば良いのです。ここがBluebird
の非常にスマートな点です。
非同期のスマートな処理
以下の例を見てみましょう。
Bluebird.resolve(1) // 同期的に呼ばれる
.then(num => console.log(num)); // 非同期でなければならない
Bluebird.resolve(2) // 同期的に呼ばれる
.then(num => console.log(num)); // 非同期でなければならない
Bluebird.resolve(3) // 同期的に呼ばれる
.then(num => console.log(num)); // 非同期でなければならない
実際にはこのようなコードは書きませんが、複雑な非同期の並列処理をする際にこのような同期的に呼ばれるケースが多々存在します。この際、毎回非同期関数を呼び出してしまうとパフォーマンス低下につながるため、Bluebird
では全ての同期呼び出しをqueue
に追加し、一度の非同期関数の実行で全ての同期呼び出しを非同期で呼び出しています。
はっきり言ってbitField
がどこまで恩恵を与えているのかがわかりません、もしかしたらすごい恩恵を与えているのかもしれません。しかしBluebird
が本当に速い理由はこのシンプルさにあると思います。
なぜAigle
が速いのか
3つの原則
- 無駄な変数・関数・インスタンスを生成しない
- 無駄な関数の実行をしない
- 非同期処理をスマートに行う
基本的に上記の3つの原則をまもる、これ以外にすることはありません。特別なことはしてません、私にはできません。しかしこの3つの原則を守れば速いコードは作れます。ベンチマークの結果がこちらです。
benchmark | aigle | bluebird |
---|---|---|
all | 31.3μs | 42.3μs [0.741] |
join | 1.62μs | 2.59μs [0.626] |
map | 35.2μs | 57.5μs [0.613] |
promise:then | 178μs | 348μs [0.511] |
promisify | 2.06μs | 28.0μs [0.0737] |
promisifyAll | 28.8μs | 118μs [0.245] |
props | 56.8μs | 69.1μs [0.821] |
race | 30.4μs | 42.8μs [0.712] |
using | 2.67μs | 9.60μs [0.278] |
シンプルがゆえに高速です。ただそれだけです。
本番環境を考慮した高速化
Aigle
の本当にすごいところはaigle-core
と呼ばれるdependency
です。
Bluebird
ではbitField
を管理するために全てのPromiseインスタンスがBluebird
インスタンスである必要があります。またそれをチェックするためにinstanceof
を使用します。instanceof
でチェックしたPromiseがBluebird
のインスタンスではない場合、新たにBluebird
のインスタンスを生成しラップします。しかしこのinstanceof
ですが、Bluebird
上では現在のクラスのインスタンスであるかどうかをチェックしています。例えば、v3.4.6
とv3.4.7
のBluebird
を併用した場合、新たにインスタンスが作られてしまいます。
Aigle
ではこれを避けるべく、aigle-core
という別のdependency
を持っています。全てのAigle
クラスは共通のAigleCore
を継承することにより、この上記のパフォーマンス低下を防いでいます。
実際に作ったベンチマークツールを見てみます。
$ npm list
aigle-benchmark@0.0.0
├─┬ aigle@0.4.1
│ └── aigle-core@0.2.0 <- aigle-core v0.2.0
├─┬ benchmark@2.1.3
│ └── platform@1.3.3
├── bluebird@3.4.7
├── lodash@4.17.4
├── minimist@1.2.0
└─┬ promise-libraries@0.3.0
├── aigle@0.4.0 <- aigle-core v0.2.0
└── bluebird@3.4.6
npmでは同じバージョンのdependency
のときに多重インストールはされずに一つだけインストールされます。この時、v0.4.1
とv0.4.0
では共通のaigle-core
が使用されます。これがAigle
です。
ベンチマークの結果はこちらです。ベンチマークにはbenchmark.js
を使用しています。コードは少し長いのでこちらを参照してください。
環境は以下の通りです。
- Aigle v0.4.0, v0.4.1
- Bluebird v3.4.6, v3.4.7
- Node v6.9.1
$ node --expose_gc . -t then
======================================
[Aigle] v0.4.1
[Bluebird] v3.4.7
======================================
[promise:then:same] Preparing...
--------------------------------------
[promise:then:same] Executing...
[1] "aigle" 179μs[1.00][1.00]
[2] "bluebird" 334μs[0.535][1.87]
======================================
[promise:then:diff] Preparing...
--------------------------------------
[promise:then:diff] Executing...
[1] "aigle" 180μs[1.00][1.00]
[2] "bluebird" 512μs[0.351][2.85]
promise:then:same
は全て同じバージョン、promise:then:diff
では子要素を異なるバージョンを使用しています。異なるバージョンを使用した場合、Bluebird
ではパフォーマンスが顕著に低下してしまいます。これは私が作ったベンチマークなので、測定方法によりまた違う結果が出るかもしれません。ぜひ計測してみてください。
まとめ
Bluebird
は本当に高速で特にjoin
が高速です。
Aigle
は速いだけではなく、便利でかつ安全に使えるライブラリを目指しています。Aigle
はAsync
, Neo-Async
の関数をそのまま保持しているので、まだcallback
スタイルを使っている方も簡単に移行できると思います。
少しでもこのNode.js
、JavaScript
界隈にインパクトを与えられたら嬉しいです。
まだまだAigle
も改善の余地があります、ぜひレビュー・PRいただけたらと思います。
ちなみにえいぐるって読みます、フランス語です。