はじめに
大規模なシステムを作成していると、大規模な演算を必要とすることがあると思います。そのとき、Node.jsではどのように作っていくのが良いのか、気になったため調べ始め、この記事を書くに至りました。
大規模な演算を必要なときにNode.jsを使うなよなどなどあるかもしれませんが、チームの特性上など色々あるかと思うので、ご容赦願います。
素数を求める処理を直列と並列の2パターンで行い、それぞれの実行時間の計測を行います。計測に用いた実コードと計測結果から、並列化を行うかどうかの今後の判断にお使い頂ければと思います。
並列化の手段
並列化の手段として、Node.jsで用意されているのは、『Cluster』と『Worker Threads』です。
Node is designed to build scalable network applications.
Node.jsはWebアプリケーションを作るためにあります。そして、そのWebアプリケーションをスケーラブルにするための機能を担っているのが『Cluster』です。
では、Webアプリケーション以外を並列処理するためにはどうすれば良いでしょうか?
そのための方法こそが『Worker Threads』になります。
Workers are useful for performing CPU-intensive JavaScript operations
公式ドキュメント中で、このようにCPUを使う処理で有効な操作であると説明されています。
この記事では、大規模な演算というところに絞りたいため、『Worker Threads』のみ計測を行います。
注)『Worker Threads』の機能は、試験的機能です。安定版として提供されている機能でない点にはご注意ください。突如仕様が変更されるなどの危険性があります。
計測
計測コード
実際に作成したコードは以下のGitHubリポジトリに上げています。
walk8243/NodeJS-Worker
素数判定の方法が雑ですが、演算量が多い単純な処理をさせたかっただけですので、気にしないで頂けると幸いです。
並列処理
import { Worker } from "worker_threads";
import Main from "./thread/Main";
const loop = 4;
const workerData = {
maxNumber: 100000,
};
const main = new Main(loop);
for(let i=0; i<loop; i++) {
const worker = new Worker(require.resolve('./thread/worker'), { workerData: workerData });
worker.on('message', (message) => {
main.emit('result', worker.threadId, message.result);
});
}
import { EventEmitter } from "events";
export default class Main extends EventEmitter {
public obj: { id: number, result: number[] }[] = [];
private counter: number = 0;
constructor(private finish = 4) {
super();
this.on('result', (threadId: number, result: number[]) => {
this.obj.push({ id: threadId, result: result });
});
this.on('result', () => {
if(++this.counter == this.finish) {
console.log(this.obj.map((value) => value.result.length));
}
});
}
}
import { threadId, parentPort, workerData } from "worker_threads";
console.log('worker thread', threadId);
const naturalNumber = [];
const maxNumber = workerData.maxNumber || 10000;
target: for(let i=2; i<maxNumber; i++) {
check: for(let j=2; j<i; j++) {
if(i % j == 0) {
continue target;
}
}
naturalNumber.push(i);
}
const data = { result: naturalNumber };
parentPort ? parentPort.postMessage(data) : console.log(data);
# 実行方法
# 実行時 --experimental-worker が必須です
yarn run tsc
node --experimental-worker index.js
直列処理
const data = [];
const loop = 4;
const maxNumber = 100000;
for(let i=0; i<loop; i++) {
const naturalNumber = [];
target: for(let i=2; i<maxNumber; i++) {
check: for(let j=2; j<i; j++) {
if(i % j == 0) {
continue target;
}
}
naturalNumber.push(i);
}
data.push({ result: naturalNumber });
}
// console.log(data);
console.log(data.map((value) => value.result.length));
結果
計測は以下の環境で行った。
項目 | 値 |
---|---|
CPU | Intel(R) Core(TM) i7-7Y75 CPU @ 1.30GHz 1.60GHz |
コア数 | 2 |
スレッド数 | 4 |
OS | Windows 10 Home 17134.885 |
パターン1[10万までの素数,4回演算]
直列 | 並列 | |
---|---|---|
1回目 | 7.168s | 3.836s |
2回目 | 7.466s | 3.904s |
3回目 | 8.084s | 3.849s |
4回目 | 8.654s | 3.963s |
5回目 | 7.698s | 3.961s |
6回目 | 7.590s | 4.072s |
7回目 | 7.767s | 4.040s |
8回目 | 7.839s | 4.078s |
9回目 | 7.592s | 4.072s |
10回目 | 7.655s | 3.830s |
平均値 | 7.751s | 3.961s |
パターン2[10万までの素数,2回演算]
直列 | 並列 | |
---|---|---|
1回目 | 3.859s | 2.617s |
2回目 | 3.949s | 2.518s |
3回目 | 3.920s | 2.671s |
4回目 | 3.871s | 2.637s |
5回目 | 4.039s | 2.706s |
6回目 | 3.950s | 2.655s |
7回目 | 3.987s | 2.608s |
8回目 | 3.938s | 2.566s |
9回目 | 4.090s | 2.742s |
10回目 | 3.824s | 2.656s |
平均値 | 3.943s | 2.638s |
パターン3[10万までの素数,1回演算]
直列 | 並列 | |
---|---|---|
1回目 | 1.983s | 1.963s |
2回目 | 2.305s | 1.955s |
3回目 | 1.919s | 1.984s |
4回目 | 2.066s | 1.910s |
5回目 | 1.960s | 1.947s |
6回目 | 1.821s | 2.116s |
7回目 | 1.881s | 2.042s |
8回目 | 1.960s | 1.989s |
9回目 | 1.883s | 1.997s |
10回目 | 1.870s | 2.128s |
平均値 | 1.965s | 2.003s |
パターン4[5万までの素数,4回演算]
直列 | 並列 | |
---|---|---|
1回目 | 2.330s | 1.155s |
2回目 | 2.072s | 1.163s |
3回目 | 2.044s | 1.183s |
4回目 | 2.048s | 1.179s |
5回目 | 1.987s | 1.165s |
6回目 | 1.963s | 1.128s |
7回目 | 2.567s | 1.144s |
8回目 | 2.521s | 1.185s |
9回目 | 2.800s | 1.147s |
10回目 | 2.531s | 1.164s |
平均値 | 2.286s | 1.161s |
パターン5[5万までの素数,2回演算]
直列 | 並列 | |
---|---|---|
1回目 | 1.154s | 0.842s |
2回目 | 1.149s | 0.871s |
3回目 | 1.107s | 0.797s |
4回目 | 1.101s | 0.779s |
5回目 | 1.128s | 0.874s |
6回目 | 1.123s | 0.877s |
7回目 | 1.049s | 0.764s |
8回目 | 1.123s | 0.852s |
9回目 | 1.118s | 0.833s |
10回目 | 1.078s | 0.870s |
平均値 | 1.113s | 0.836s |
パターン6[1万までの素数,4回演算]
直列 | 並列 | |
---|---|---|
1回目 | 0.243s | 0.304s |
2回目 | 0.213s | 0.280s |
3回目 | 0.227s | 0.297s |
4回目 | 0.219s | 0.326s |
5回目 | 0.219s | 0.306s |
6回目 | 0.208s | 0.311s |
7回目 | 0.226s | 0.297s |
8回目 | 0.240s | 0.294s |
9回目 | 0.216s | 0.322s |
10回目 | 0.202s | 0.301s |
平均値 | 0.221s | 0.304s |
パターン7[1万までの素数,2回演算]
直列 | 並列 | |
---|---|---|
1回目 | 0.174s | 0.230s |
2回目 | 0.185s | 0.254s |
3回目 | 0.176s | 0.246s |
4回目 | 0.185s | 0.223s |
5回目 | 0.164s | 0.219s |
6回目 | 0.172s | 0.255s |
7回目 | 0.212s | 0.249s |
8回目 | 0.172s | 0.230s |
9回目 | 0.206s | 0.243s |
10回目 | 0.163s | 0.281s |
平均値 | 0.181s | 0.243s |
パターン8[10万までの素数,8回演算]
CPUのスレッド数を超えるケースとして、追加計測しました。
直列 | 並列 | |
---|---|---|
1回目 | 15.382s | 8.289s |
2回目 | 14.982s | 8.294s |
3回目 | 15.264s | 8.277s |
4回目 | 16.475s | 7.940s |
5回目 | 15.564s | 7.822s |
平均値 | 15.533s | 8.124s |
さいごに
総括すると、『Worker Threads』を使うことで、有意義なレベルで実行時間の短縮ができました。
演算量の多い場合に関しては、直列でも並列でも実行時間に差はほとんどありませんでした。
一方で、演算量の少ない場合は、実行回数を上げても直列の方が早い結果となりました。
『Worker Threads』で並列処理を行い、演算結果をメインスレッドに返したい場合、Workerの管理が必要となってきます。手間がかかる部分なので、実行時間のことも踏まえて、全てを並列処理にするように考えるのではなく、演算量と相談をして決める必要があります。
シングルスレッドを言い訳にNodeが遅いなんて言わせない世界を目指しましょう!