node-process

Node 进程初认识

我们都知道Node是基于V8的,而在V8中JavaScropt是单线程的。单线程带来的好处就是:程序是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好的提高CPU的使用率。

但是这种单进程单线程存在缺陷,就是我们无法利用服务器CPU多核的性能,一个Node进程只能利用一个CPU核。而且单线程模式下一旦代码抛出异常没有被捕获,将会引起整个进程的奔溃。

在Node v0.8版本之前,多进程架构必须通过的child_process来实现,要创建单机Node集群,由于有很多细节需要工程师自己处理。于是在v0.8时直接引入了cluster模块,用以解决多核CPU的利用率问题, 通过master-worker模式启用多个进程实例。下面我们了解下,Node如何使用多进程模型利用多核CPU,以及自带的cluster模块具体的工作原理。

如何创建子进程

Node提供了child_process模块,用来创建子进程。他提供了4个方法用于创建子进程。

  • spawn

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    let cp = require('child_process');
    let child = cp.spawn('ls', ['-lh']);
    child.stderr.on('data', function (data) {
    console.log('stderr', data);
    });
    child.stdout.on('data', function (data) {
    console.log('stdout', data.toString());
    });
    // child.stdin.on('data', function (data) {
    // console.log('stdin', data);
    // });
    child.on('close', function (code) {
    console.log('child close', code);
    });
  • exec / execFile
    exce最后调用的就是execFile,唯一的区别就是,exec通过normalizeExecArgs方法将option中的shell属性默认设置为true,源码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    function exec(command, options, callback) {
    const opts = normalizeExecArgs(command, options, callback);
    return module.exports.execFile(opts.file,
    opts.options,
    opts.callback);
    }

    function normalizeExecArgs(command, options, callback) {
    if (typeof options === 'function') {
    callback = options;
    options = undefined;
    }

    // Make a shallow copy so we don't clobber the user's options object.
    options = { ...options };
    options.shell = typeof options.shell === 'string' ? options.shell : true;

    return { file: command, options: options,callback: callback };
    }

    在execFile中,最终调用的是spawn方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function execFile(file /* , args, options, callback */) {
    // ...
    const child = spawn(file, args, {
    cwd: options.cwd,
    env: options.env,
    gid: options.gid,
    uid: options.uid,
    shell: options.shell,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!options.windowsVerbatimArguments
    });
    // ...
    }

    exec会将spawn的输入输出流转换成String,默认使用UTF-8的编码,然后传递给回调函数

    1
    2
    3
    4
    5
    let cp = require('child_process');
    let child = cp.exec('ls -lh', function (error, stdout, stderr) {
    console.log(`stdout: ${stdout}`);
    stderr && console.log(`stderr: ${stderr}`);
    });
  • fork
    fork也是通过spawn创建子进程的,是spawn的一个特例,专门用于衍生新的Node.js进程。

    1
    2
    3
    4
    function fork(modulePath /* , args, options */) {
    // ...
    return spawn(options.execPath, args, options);
    }

    通过fork创建子进程后,父子进程之间会建立一条IPC(Inter-Procoss Commounication,即进程间通信)通道,方便父子进程间通信。在JavaScript层,父子进程通过message和send传递消息,而在Node底层具体细节实现是由libuv提供,不同操作系统实现不同。在Windows下由命名管道(name pipe)实现,在*nix系统则采用Unix Domain Socket实现。


    > 常见进程间通信方式:命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等

    下面通过一个实例来了解父子进程间是如何通信的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // Parent.js
    let cp = require('child_process');
    let n = cp.fork(__dirname + '/Child.js');
    n.on('message', function (msg) {
    console.log('message from child', msg);
    });
    n.send({'Hello': 'World'});

    // Child.js
    process.on('message', function (msg) {
    console.log('message from parent->', msg);
    });
    process.send({'Child': 'Test Send'});
    // node Parent.js
    // out->
    // message from parent-> { Hello: 'World' }
    // message from child { Child: 'Test Send' }

    父进程在创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_ID)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。


    > **只有启动的子进程是Node进程时,子进程才会根据环境变量去连接IPC通道,对于其它类型的子进程则无法实现进程间通信, 除非其它进程也按约定去连接这个已经创建好的IPC通道**

    接下来,我们试通过fork实现Master-Worker模型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // Master.js
    let cp = require('child_process');
    let os = require('os');
    let cpus = os.cpus();

    for (let i = 0; i < cpus.length; i++) {
    cp.fork('./Worker.js');
    }

    // Child.js
    let http = require('http');
    http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World!/n');
    }).listen(8000);

    上述代码,如果CPU多核的话。我们fork了多个子进程,这时只有一个工作进程监听到端口上,其余的进程在监听的过程中都抛出了异常(Error: listen EADDRINUSE),这是端口被占用的情况。要解决这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口,主进程对外接收所有的网络请求,再将这些请求分别代理到不同的端口的进程上。


    通过代理,可以避免端口不能重复监听的问题,甚至可以在代理过程上做适当的负载平衡。由于进程每接收到一个连接,将会用掉一个文件描述符。操作系统的文件描述符是有限的。为了解决这个问题,Node引入了进程间发送句柄的功能。


    > 句柄是一种可以用来标记资源的引用,他的内部包含了指向对象的文件描述符。

    我们可以在Master中启动一个TCP,然后通过IPC服务将句柄发送给子进程,子进程再对服务的连接监听:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    // Master.js
    let cp = require('child_process');
    let os = require('os');
    let cpus = os.cpus();

    let net = require('net');
    let server = net.createServer();
    server.on('connection', function (socket) {
    socket.end('handle by parent!\n');
    });
    server.listen(8000);

    for (let i = 0; i < cpus.length; i++) {
    let child = cp.fork('./Worker.js');
    server.on('listening', function () {
    child.send('server', server);
    });
    }

    // Child.js
    let http = require('http');
    let server = http.createServer(function (req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('handled by child ,pid is ' + process.pid + '!\n');
    });

    process.on('message', function (m, tcp) {
    console.log('msg from parent', m == 'server');
    if ('server' == m)
    tcp.on('connection', function (socket) {
    server.emit('', socket);
    });
    });

Cluster 模块

Cluster是Node在后来引入用以解决多核CPU的利用率问题的模块,提供了较完善的API,用以处理进程的健壮性问题。有了Cluster模块,实现Node进程集群是很轻松的事情了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let cluster = require('cluster');

let os = require('os');
let cpus = os.cpus();

if (cluster.isMaster) {
console.log('master ' + process.pid + ' is running!');
cluster.on('exit', function (worker, code, signal) {
console.log('worker ' + worker.process.pid + ' exit')
});
for (let i = 0; i < cpus.length; i++)
cluster.fork();
} else {
let http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('handled by child ,pid is ' + process.pid + '!\n');
}).listen(8000);

}

cluster模块就是child_process和net模块的组合应用。cluster启动时,通过isMaster判断是否主进程,如果主进程就执行fork操作,子进程创建http服务。这里cluster.fork是执行的当前文件,最终调用的也是child_process.fork。因此它们可以使用IPC和父进程通信,从而使各进程交替处理连接服务。