Edited at

最速のPromiseライブラリ作る方法

More than 1 year has passed since last update.


はじめに

AigleというPromiseライブラリを開発しています。このライブラリはベンチマーク上だけでなく実際の本番環境を考慮した高速化を実現しています。

今回はその過程でBluebirdに大変お世話になったので、なぜBluebirdが速いのか、そしてなぜAigleが速いのかついて説明したいと思います。


基本的なPromiseの知識

まず本題に入る前にPromiseの基本的なことを説明しておきます。

Promiseにはpending, fulfilled, rejectedの3つの状態があります。一度pendingから別の状態に変わった場合は、そのPromiseの状態が変わることはありません。

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という関数が呼ばれます。これは非同期を引き起こすという意味です。そして子要素のonFulfilledqueueにセットされます。そして内部で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.6v3.4.7Bluebirdを併用した場合、新たにインスタンスが作られてしまいます。

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.1v0.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は速いだけではなく、便利でかつ安全に使えるライブラリを目指しています。AigleAsync, Neo-Asyncの関数をそのまま保持しているので、まだcallbackスタイルを使っている方も簡単に移行できると思います。

少しでもこのNode.jsJavaScript界隈にインパクトを与えられたら嬉しいです。

まだまだAigleも改善の余地があります、ぜひレビュー・PRいただけたらと思います。

ちなみにえいぐるって読みます、フランス語です。


リンク