🧵

Node.jsが単一のプロセスで動作していることを身体で理解する

2022/10/01に公開

こんにちは、わたる です。

はじめに

訳あって Node.js を触る機会がありまして、Web サーバを気軽に起動できる機構が面白いと思ったのですが、その時に学んだことを記録として残します。

Node.js の WebAPI

Node.js の WebAPI を開けるには、ネット等で色々と探せば出てくると思いますが、おおよそ下記のような実装になるかと思います。

server.js
const express = require('express') ;
const app = express() ;

app.get('/start', (req, res) => {
    res.send('Hello World.');
}

app.listen(55555, () => {
    console.log('Start. port on 55555.') ;
}) ;

サーバの起動は下記で行います。

node server.js

クライアントからは、

$ curl http://localhost:55555/start

を行うことで「Hello World.」というレスポンスを受け取れるかと思います。

API にて時間がかかる処理を行った場合

では仮に、getのイベントで時間がかかる処理を行った場合はどうなるでしょうか。
下記の実装例を考えてみます。

server.js
const express = require('express') ;
const app = express() ;
const url = require('url') ;

app.get('/start', (req, res) => {
    console.log('start API.') ;
    const url_parse = url.parse(req.url, true) ;

    // 開始時刻を取得
    const start = new Date()
    const msg_start = 'start : ' + getStringFromDate(start) ;
    console.log(msg_start)

    // 時間のかかる処理
    const result = fib(Number(url_parse.query.number))

    // 終了時刻を取得
    const end = new Date()
    const msg_end = 'end : ' + getStringFromDate(end) + '\n' + `result : ${result}` + '\n' + `lap time : ${end - start} msec.`;
    console.log(msg_end)

    // クライアントへ送る
    const msg = msg_start + '\n' + msg_end + '\n' ;
    res.send(msg) ;
    res.end() ;
}) ;

function getStringFromDate(date) {
    var year_str = date.getFullYear();
    var month_str = 1 + date.getMonth();
    var day_str = date.getDate();
    var hour_str = date.getHours();
    var minute_str = date.getMinutes();
    var second_str = date.getSeconds();

    month_str = ('0' + month_str).slice(-2);
    day_str = ('0' + day_str).slice(-2);
    hour_str = ('0' + hour_str).slice(-2);
    minute_str = ('0' + minute_str).slice(-2);
    second_str = ('0' + second_str).slice(-2);

    format_str = 'YYYY-MM-DD hh:mm:ss';
    format_str = format_str.replace(/YYYY/g, year_str);
    format_str = format_str.replace(/MM/g, month_str);
    format_str = format_str.replace(/DD/g, day_str);
    format_str = format_str.replace(/hh/g, hour_str);
    format_str = format_str.replace(/mm/g, minute_str);
    format_str = format_str.replace(/ss/g, second_str);

    return format_str;
};

function fib(n) {
    if (n <= 1) {
        return n ;
    } else {
        return fib(n - 1) + fib(n - 2) ;
    }
}

// listen
app.listen(55555, () => {
    console.log('Start. port on 55555.') ;
}) ;

時間のかかる処理は引数で指定した番目のフィボナッチ数を求める関数にしました。
node server.js でサーバを起動し、仮にクライアントから curl http://localhost:55555/start?number=40 とした場合、少し待つと

start : 2022-09-30 23:04:51
end : 2022-09-30 23:05:04
result : 102334155
lap time : 12369 msec.

…という結果が返ってくると思います。
では、この待っている間に別のクライアントから同じ curl での要求が来た場合、サーバ側でどのようになっているかを表示させてみると、

start API.            ←一つ目の curl
start : 2022-09-30 23:04:51
end : 2022-09-30 23:05:04
result : 102334155
lap time : 13648 msec.
start API.            ←二つ目の curl
start : 2022-09-30 23:05:28
end : 2022-09-30 23:05:41
result : 102334155
lap time : 12566 msec.

…と、二つ目の curl は一つ目の curl の処理が終わってから実行しており、node が1プロセス1スレッドで動作しているということが良く分かると思います。

しかし、この仕組みは二つ目のクライアントからは応答もなく待ち続けるため、あまりよろしくない状況ではないでしょうか。

ワーカスレッドを使ってマルチスレッドにしてみる

ということで、サーバー側をマルチスレッドにして、受付られるようなプログラムを作ってみるとどうなるか動かしてみます。

server.js
const express = require('express') ;
const app = express() ;
const url = require('url') ;

const { Worker } = require('worker_threads') ;

app.get('/start', (req, res) => {
    console.log('start API.') ;
    const url_parse = url.parse(req.url, true) ;
    var worker = new Worker('./worker.js') ;
    var msg_start = '' ;
    var msg_end = '' ;

    worker.on('message', msg => {
        const { action, message } = msg ;
        if ( action === 'progress' ) {
            msg_start = message ;
            console.log(msg_start) ;
        } else if ( action === 'finish' ) {
            // 終了時刻を取得
            msg_end = message ;
            console.log(msg_end) ;

            // クライアントへ送る
            const msg = msg_start + '\n' + msg_end + '\n' ;
            res.send(msg) ;
            res.end() ;
        }
    }) ;
    worker.postMessage({ action : 'start', args : [Number(url_parse.query.number)] }) ;
}) ;

// listen
app.listen(55555, () => {
    console.log('Start. port on 55555.') ;
}) ;
worker.js
const { parentPort } = require('worker_threads') ;

parentPort.on('message', async msg => {
    const { action, args } = msg ;
    if ( action === 'start' ) {
        const start = new Date()
        const msg_start = 'start : ' + getStringFromDate(start) ;
        parentPort.postMessage({ action : 'progress', message : msg_start }) ;

        // await は入れた方が良い…?
        result = fib(args[0]) ;

        const end = new Date()
        const msg_end = 'end : ' + getStringFromDate(end) + '\n' + `result : ${result}` + '\n' + `lap time : ${end - start} msec.`;
        parentPort.postMessage({ action : 'finish', message : msg_end }) ;
    } else {
        throw new Error('Unknown action.') ;
    }
    process.exit() ;
}) ;

function fib(n) {
    if (n <= 1) {
        return n ;
    } else {
        return fib(n - 1) + fib(n - 2) ;
    }
}

function getStringFromDate(date) {
    var year_str = date.getFullYear();
    var month_str = 1 + date.getMonth();
    var day_str = date.getDate();
    var hour_str = date.getHours();
    var minute_str = date.getMinutes();
    var second_str = date.getSeconds();

    month_str = ('0' + month_str).slice(-2);
    day_str = ('0' + day_str).slice(-2);
    hour_str = ('0' + hour_str).slice(-2);
    minute_str = ('0' + minute_str).slice(-2);
    second_str = ('0' + second_str).slice(-2);

    format_str = 'YYYY-MM-DD hh:mm:ss';
    format_str = format_str.replace(/YYYY/g, year_str);
    format_str = format_str.replace(/MM/g, month_str);
    format_str = format_str.replace(/DD/g, day_str);
    format_str = format_str.replace(/hh/g, hour_str);
    format_str = format_str.replace(/mm/g, minute_str);
    format_str = format_str.replace(/ss/g, second_str);

    return format_str;
};

これでサーバを node server.js で起動し、クライアントから2つの curl を動作させてみたログが下記です。

start API.            ←一つ目の curl
start : 2022-10-01 00:49:43
start API.            ←二つ目の curl
start : 2022-10-01 00:49:45
end : 2022-10-01 00:49:56
result : 102334155
lap time : 13248 msec.
end : 2022-10-01 00:49:58
result : 102334155
lap time : 13349 msec.

実行中でも他からの API を受け付けていますね。

まとめ

この仕組みは大量の API を受信すると大量のワーカスレッドが立ち上がるのでサーバのリソース依存になり何らかの「交通整理」が必要、といった課題はありますが、時間のかかる処理が入った場合はこのようなやり方でクライアント側にストレスの無い仕組みを考える必要はあるかと思います。

参考文献

Node.js メインスレッドで重い処理を行う
 ⇒fib(フィボナッチ数を求める関数)を参考にさせていただきました。
JavaScriptで【日付(Date)型⇔文字列(String)型】に変換する方法
 ⇒getStringFromDate(日付から文字列に変換する関数)を参考にさせていただきました。
Node.js Worker Threads: スレッド間でデータを送受信する方法
 ⇒ワーカスレッド作成の仕組みを参考にさせていただきました。

Discussion